Compare commits

..
Author SHA1 Message Date
dayuan.jiang 963839c4a9 ci: run auto-format with the Biome version in package.json again
package.json moved to Biome 2.5.7 but the auto-format job stayed on
2.4.13. The two format some files differently, so on this PR the bot
reformatted tests/unit/log-save-route.test.ts with 2.4.13 and the lint
job, on 2.5.7, then failed on it. The pin now matches package.json, as
its comment asks, and the file is back in 2.5.7's format.
2026-10-04 23:07:29 +09:00
dayuan.jiang 0855b35ff2 fix(server): count quota by the key actually used, and more review fixes
Found by the PR review, each with a test that failed first:
- Quota: any key header skipped it, even one the provider never reads
  (x-aws-access-key-id with OpenAI), so a request ran on the server's
  key without being counted. The check now runs after the model is
  resolved and uses usesServerCredentials. On main already.
- usesServerCredentials read the raw base URL; "/" cleans up to none, so
  an Ollama request ran on the server's key past the server-model check.
- SGLang's default 127.0.0.1:8000 only fills the settings form. Chat and
  the model list used it as a real address, so the server called its own
  machine even with private URLs blocked. Now a base URL is required.
- With a user's OpenAI key and no base URL, the SDK read the server's
  OPENAI_BASE_URL. The official endpoint is now passed. On main already.
- The Test button refused nothing on the server's keys (Ollama Cloud),
  and a 15 s timeout reported "connected, no tool call".
- The model list for Ollama without a base URL came from ollama.com while
  chat went to the server's Ollama.
- Bedrock's "Too many tokens, please wait" counted as context too long.
- On the server's keys the provider's error text stays in the server log;
  it can name the server's AWS account, role or internal hosts.
- Desktop app: the preset keys are the user's own (NEXT_AI_DRAWIO_DESKTOP),
  so Max Output Tokens can be raised and keyless models in settings work
  again. A launch that found the remembered port taken no longer replaces
  it, which hid the user's chats and settings for good.
2026-10-04 23:04:21 +09:00
dayuan.jiang 504d2fa812 fix(mcp-server): keep both pages when get_diagram meets a page export, and more review fixes
Found by the PR review, each with a test that failed first:
- get_diagram during a page export returned the one-page projection on
  screen as the whole document (6 of 6 times when timed so). The preview
  page no longer answers a sync while a projection shows, and syncs after
  reloading, so the poll that restores the real document exports it.
- Exports are numbered on the server too: a late result of an export that
  timed out was saved as the next export's file.
- In Chrome, a new_xml with a syntax error counted the <parsererror>
  element as a second cell, so the web app rejected edits that auto-fix
  repairs ("must contain exactly one cell").
- hasCells missed single-quoted ids, so screenshot_diagram called such a
  diagram empty and auto-save never created its file.
- A literal \n directly under a <diagram> that has a model passed
  validation; only text-only pages are compressed data.
- A wrapped mxCell repeating its UserObject's id took the wrapper's place
  in edits, so delete and update left an empty or nested wrapper.
- Bare cells with a shape or edge id of "0" or "1" are rejected with a
  clear message instead of being renamed, which broke their edges.
- DRAWIO_DATA_DIR expands ~, which JSON configs pass on as it is.
2026-10-04 23:04:21 +09:00
dayuan.jiang 2bb69b58af fix(chat): draw the built-in examples again and undo edit previews on errors
Found by the PR review:
- The built-in examples showed a finished card and an empty canvas. They
  are answered in the browser, never reach the tool handler, and relied
  on the final redraw that an earlier commit removed. The example branch
  now loads its diagram itself.
- When the request failed while an edit was streaming (a provider error,
  a lost connection), its preview stayed on the canvas. The error handler
  now restores the diagram from before the preview.
- The model picker could not scroll with the wheel or touch: the settings
  dialog blocks those events outside itself, and the picker is rendered
  outside it. The popover is modal now.
- A fetch error and the open picker stayed when switching providers.
- Editing a model id kept the old test warning and response time, which
  also hid the "may not be able to draw" hint for the new id.
2026-10-04 23:04:21 +09:00
github-actions[bot] 48128acc65 style: auto-format with Biome 2026-10-04 12:43:03 +00:00
dayuan.jiang 153fa9e5f4 chore(mcp-server): release the new features as 0.3.0
npm has 0.2.3. The earlier commits raised the version step by step to
0.6.0, but they ship together as one release.
2026-10-04 21:41:30 +09:00
dayuan.jiang 2ac4c54eb2 fix(chat): an edit after a broken edit call no longer fails, found with Opus 5.5
- Claude Opus 5.5 sent an edit with invalid JSON, then the same edit
  again. The first call's streamed preview was never undone: its input
  has no operations, and the undo sat behind that check. The second edit
  then started from the preview, failed on a duplicate id, and the model
  had to try a third time. The undo now runs first, and an edit that
  starts in the same render uses the undone diagram.
- The SDK passes an invalid tool call's error as a string, which was
  wrapped as a provider error. streamErrorText keeps it as the text the
  model reads.
- Bedrock's "on-demand throughput isn't supported" gets the model id hint.
- The thinking header uses the page language ("Thought for 1 second" in
  English), from the dictionary entries that were already there.
2026-10-04 21:18:54 +09:00
dayuan.jiang 65e3dd1dde fix(providers): keep thinking on the newest Claude models, fix the Bedrock ids
Measured on Bedrock: Claude Opus 4.7, 4.8 and every Claude 5 model reject
a thinking budget ("thinking.type.enabled") and a temperature, and all of
them accept adaptive thinking. The retry used to drop thinking, so with
a thinking budget set these models did not think at all. It now switches
to adaptive thinking with display "summarized"; without that setting the
models think but send no thinking text to show.

The suggested Bedrock ids had no region prefix, and the newer models only
answer through an inference profile id: "anthropic.claude-sonnet-5-5"
fails with "on-demand throughput isn't supported". Each suggested id was
called once; the Claude ones now start with "global.", Llama, Pixtral and
Nova 2 Lite with "us.". Nova Premier (end of life) and the ids without a
working profile are gone. The docs example had the same problem and a
wrong date.
2026-10-04 21:18:45 +09:00
dayuan.jiang 89a712bca4 feat(providers): suggest the Claude 5 and GPT 6 models, show GPT 6 reasoning
The suggested models stopped at Claude Opus 4.8 and GPT 5.5. They now
start with Claude Opus 5.5, Sonnet 5.5 and Fable 5.1, and gpt-6.1-sol,
gpt-6-sol, gpt-6-luna and gpt-6-astra (ids checked against the provider
lists). The Bedrock list is unchanged until its ids are checked.

The reasoning summary was only turned on for model ids containing o1,
o3, o4 or gpt-5, so GPT 6 models showed no thinking. It now matches the
o-series and gpt-5 or later by version, like the OpenAI SDK.
2026-10-04 20:51:12 +09:00
dayuan.jiang 6a99915446 fix(chat): clearer provider errors and no empty bubble, found with real models
- An error object sent inside the stream (OpenRouter's { code, message })
  showed as "[object Object]"; its message and status code are read now.
- A problem+json "detail" is added to the message: NVIDIA only said
  "Gone" for a retired model. 410 counts as model not found.
- "Cannot connect to API" from the SDK gets the connection hint.
- Text that is only whitespace (Kimi K2.6 sends a space before a tool
  call) no longer shows an empty bubble.
- allowSystemInMessages stops the warning on every request. Our system
  messages carry cache points; a client's own system messages are already
  dropped by the empty-content filter.
2026-10-04 20:15:51 +09:00
dayuan.jiang 6f5f7b668b fix(mcp-server): reject text between tags, which draw.io cannot open
draw.io reads any text inside a page as compressed page data, so a stray
text node makes the whole page fail with an atob error. gpt-5-mini sends
new cells with a literal "\n" between the tags; the edit card said
Complete while draw.io showed the error and kept the old diagram.

Validation now reports text between tags, and auto-fix turns a literal
\n, \t or \r between tags into whitespace. Other text goes back to the
model as an error. The compressed data directly under <diagram> is fine.
2026-10-04 20:15:44 +09:00
dayuan.jiang 6236124338 feat(settings): fetch the model list from any provider and flag models that cannot draw
The "Fetch models" button asks the provider for its models (OpenAI-style
/models, Anthropic, Google, Ollama, OpenRouter, Vercel Gateway, AIHubMix)
and shows them in a searchable picker. This replaces the route that only
worked for AIHubMix.

A snapshot of models.dev (MIT) says which models support tool calls.
Models without them get a "no tool calls" badge in the picker and a hint
in the model list, since drawing needs tool calls. Refresh the snapshot
with scripts/update-model-catalog.mjs.
2026-10-04 14:02:54 +09:00
dayuan.jiang ed1722ee9d refactor(settings): drop an unused index parameter 2026-10-04 13:49:57 +09:00
dayuan.jiang ab1a58f999 feat(errors): classify provider errors and show a hint the user can act on
- lib/llm-errors.ts sorts an error into about a dozen kinds (key
  rejected, no access, unknown model, no credit, rate limited, context
  too long, no image input, no tool calls, output cut off, provider down,
  cannot connect, timeout): first texts that name the cause precisely,
  then the HTTP status code, then general texts. It unwraps RetryError and
  hides keys and Bearer tokens in the provider's message
- The chat route uses it for errors before the stream and, through
  toUIMessageStreamResponse's onError, for errors in the stream. Errors
  of the model's own tool call stay as they are: the same text goes back
  to the model so it can fix the call
- The chat shows the hint in the user's language, then the provider's
  message; a rejected key, missing access or unknown model adds an "Open
  model settings" button. The Test button shows the same hints
- Fixes: our message "API key is required when using a custom base URL"
  was replaced by "Authentication failed" because it contains "key"; a
  provider's "Rate limit exceeded" opened this site's quota toast; an
  error body like {"error": ...} was shown as raw JSON; the Test button
  matched "401" in the message, where providers rarely put it
- Remove the string matching fallbacks in the chat panel
2026-10-04 13:49:34 +09:00
dayuan.jiang 99890e9e37 feat(settings): link to each provider's key page and clean up base URLs
- A "Get API key" link next to the API Key field for the 19 providers
  that have a key page (from env.example and the providers' docs). 17
  answered 200 to curl; OpenAI's is behind a Cloudflare challenge and
  DeepSeek's behind a regional block, both checked in Chrome
- Base URLs drop spaces, trailing slashes and a pasted endpoint path
  (/chat/completions, /completions, /messages, /responses), which the
  SDK would otherwise append a second time and get a 404. getAIModel does
  this for the chat and the Test button; the field does it on blur and
  shows the URL requests go to
2026-10-04 13:37:39 +09:00
dayuan.jiang c24aae6de0 refactor(providers): one model factory for chat and the settings Test button
- getAIModel resolves credentials (client key, server env vars, the
  existing SSRF rules) and createModel builds the model by SDK. The
  provider-by-provider switch shrinks from 24 cases to the few that
  differ (lib/ai-providers.ts 1531 -> 1106 lines)
- /api/validate-model calls getAIModel instead of its own 24-case switch
  (503 -> 175 lines), which had drifted from the chat: it built Azure
  with createOpenAI, Kimi and MiMo with createOpenAI instead of
  createDeepSeek, and the official OpenAI endpoint with Chat Completions.
  A passing test now means the chat works
- Plain OpenAI-compatible providers (SiliconFlow, SGLang, ModelScope,
  GLM, Qwen, Qiniu, Novita, Atlas Cloud, EdgeOne, Doubao, MiniMax in
  OpenAI mode, AIHubMix on a custom URL) use @ai-sdk/openai-compatible,
  which reads reasoning_content, so their reasoning shows, and accepts
  SGLang's stream as is (its 95-line stream rewrite is gone).
  includeUsage keeps token usage for quotas. <think> tags in their text
  become reasoning (extractReasoningMiddleware)
- SGLang without a base URL used OpenAI's endpoint; it now defaults to
  http://127.0.0.1:8000/v1 like the Test button did
- Chat requests to a client base URL refuse redirects, as the Test
  button already did (redirectGuardedFetch moves to lib/ssrf-protection)
- The Test button streams like the chat (the ModelScope special case is
  gone), times out after 15 s, does not retry, asks the model to call a
  ping tool and warns when it answers without one, and tests all models
  at once. The time each test took shows on its check mark
- Unknown provider names are rejected with Object.hasOwn, and the error
  texts list providers from PROVIDER_INFO instead of hand-kept lists
2026-10-04 13:31:47 +09:00
dayuan.jiang b7c26916cd refactor(chat): check the last tool part with the SDK's isToolUIPart
Drop the hand-written MessagePart and ChatMessage types that only served
this check.
2026-10-04 13:18:34 +09:00
dayuan.jiang 22a1d3f03b refactor: simpler streaming preview and small AI SDK cleanups
- useChat throttles streamed message updates (experimental_throttle,
  150 ms), replacing the two hand-written 150 ms timers of the
  display_diagram and edit_diagram previews (94 lines less). The preview
  now only runs while the input streams; once it is complete the tool
  handler takes over, so a queued preview can no longer redraw an edit
  the handler rejected and rolled back. Measured on a streamed 60-cell
  diagram: 41 redraws at least 97 ms apart, before 37 with gaps down to
  48 ms
- The diagram check endpoint uses streamText with Output.object instead
  of the deprecated streamObject, and returns its fixed result as a plain
  text response; new route test
- Import createGateway/gateway from ai and drop the direct
  @ai-sdk/gateway dependency
- The per-request message structure logs only print with
  DEBUG_LLM_PAYLOAD=true
- Remove an empty onFinish callback
2026-10-04 13:16:38 +09:00
dayuan.jiang ac62a58c9f fix(providers): update the v6 SDK packages and fix Claude and Gemini settings
- Update ai to 6.0.300 and the @ai-sdk providers to their latest v6-line
  versions. @ai-sdk/anthropic 3.0.47 did not know claude-opus-4-7/4-8
  and capped their output at 32000 tokens; 3.0.127 allows 128000
- Drop the fine-grained-tool-streaming beta header for the Anthropic API:
  the provider now streams tool input per tool (eager_input_streaming)
- Claude 4.7 and later reject a non-default temperature/top_p/top_k and
  the extended thinking budget with a 400. A middleware retries once
  without them, so TEMPERATURE and *_THINKING_BUDGET_TOKENS no longer
  break those models
- Prompt caching also reaches Claude on the Anthropic API and OpenRouter;
  before, only Bedrock got a cache marker
- GOOGLE_TOP_K and GOOGLE_TOP_P never reached Gemini: they were sent as
  Google provider options, which drops them. They are call settings now.
  GOOGLE_CANDIDATE_COUNT and GOOGLE_REASONING_EFFORT, which the provider
  does not support, are removed
- Add @ai-sdk/openai-compatible as a direct dependency
2026-10-04 13:07:56 +09:00
dayuan.jiang d32eb25523 refactor(web): share prompt examples and the shape library with the MCP server
- The three XML examples (swimlanes, two edges, waypoints) that the web
  system prompt, the display_diagram description and the MCP drawing
  guide each had a copy of now live in packages/mcp-server/src/xml-examples.ts
- The shape library group list and reader come from the MCP
  shape-library.ts; getShapeLibrary takes the folder, and the web route
  passes docs/shape-libraries under the working directory as before.
  Only the 30 known library names are read. Error texts differ slightly
- next.config.ts traces docs/shape-libraries/*.md for /api/chat, since
  the read now happens in another module
- Every prompt is byte-for-byte unchanged: the web system prompt for five
  model ids with and without minimal style, and the MCP drawing guide,
  compared before and after; the library list and swimlane example match
  the old tool description text exactly
2026-10-04 12:58:24 +09:00
dayuan.jiang bf4e95dcfb refactor(web): edit and wrap diagrams with the MCP server's code
- edit_diagram runs the MCP server's editDiagram: every new_xml is checked
  first, one cell per operation, and after the edit only the target page
  is checked, rejecting only errors this edit introduced. An unrelated
  problem elsewhere in the document no longer blocks every edit. The
  error lists each failed operation
- The streaming edit preview uses the MCP applyDiagramOperations
- Delete applyDiagramOperations (292 lines) and wrapWithMxFile from
  lib/utils.ts, and the unused hand-copied scripts/test-diagram-operations.mjs
- One blank document (BLANK_MXFILE) for the web app and the MCP preview,
  replacing four copies
- Saving a .drawio wraps a bare model with normalizeToMxfile
- The empty-diagram check uses hasCells, which also counts cells wrapped
  in a UserObject/object
- DiagramOperation is the MCP type
- The wrapped-cell and empty-diagram tests now run against the MCP code
- New e2e test: edit_diagram changes the canvas, and a failing edit
  leaves it as it was
2026-10-04 12:51:20 +09:00
dayuan.jiang e49e9f0c47 refactor(web): validate and repair diagram XML with the MCP server's engine
- Delete the web app's own copy of the XML checks and repairs from
  lib/utils.ts (1,074 lines). loadDiagram now uses the MCP server's
  validateAndFixXml without the strict checks, because the XML may hold
  the user's own diagram
- display_diagram and append_diagram prepare the model's XML with the new
  shared prepareNewDiagram, also used by the MCP create_new_diagram: wrap,
  validate strictly and auto-fix while it is still a bare model (where
  duplicate ids are renamed), then turn it into an mxfile
- The streaming preview of display_diagram no longer redraws the model's
  raw cells after the tool handler loaded the checked diagram, and drops
  a queued preview once the input is complete. That redraw lost
  auto-fixes and UserObject/object wrappers, so a linked cell lost its
  label; it also showed a second error toast
- The web repair regression tests now run against the MCP functions
- New e2e test checks the canvas content after display_diagram
- Fix the e2e upload tests, whose file input locator also matched the
  template import input
2026-10-04 12:44:01 +09:00
dayuan.jiang b7c543ca70 refactor(mcp-server): make the diagram modules usable from the web app
The web app will reuse the MCP server's XML engine instead of its own
copy in lib/utils.ts, so these modules now run in the browser too.

- Relative imports end in .ts, rewritten to .js by tsc
  (rewriteRelativeImportExtensions); Next.js resolves them directly
- Every module uses the global DOMParser/XMLSerializer: native in the
  browser, linkedom in Node via installDomPolyfill. pages.ts parsed with
  linkedom but serialized with the global serializer, which throws in
  the browser
- The saxes syntax check moves to xml-syntax.ts, so the browser does not
  pull in linkedom; it now also rejects undeclared prefixes such as
  xlink:, as the browser does
- Page decompression uses pako and atob instead of node:zlib and Buffer
- hasCells moves to pages.ts, away from the file system code
- The duplicate cell id check counts UserObject/object ids
- wrapCellsInModel drops comments and text before the first cell, which
  the web app accepts today
- validateAndFixXml takes { strict: false } for diagrams with user content
- Web tests run these modules with a browser DOM (jsdom)
- saxes becomes a direct dependency of the web app
2026-10-04 12:25:53 +09:00
dayuan.jiang 289e6bc25f fix: log Editable SVG saves, use local dates in file names, drop unused Electron dialogs
- /api/log-save accepted only drawio, png and svg, so saving as Editable
  SVG (xmlsvg) got a 400 and was never recorded in Langfuse
- The default download name and the template export name used the UTC
  date, which is the previous day on mornings in East Asia
- Remove the Electron openFile/saveFile IPC handlers, their preload
  bindings and types; nothing in the app calls them
2026-10-04 11:52:53 +09:00
dayuan.jiang 899924ba98 fix(mcp-server): fix duplicate page exports and auto-save deleting user files
- Preview page: keep an MCP export open until the server has its result.
  A poll answered before that still saw the request and started the same
  export again, so a parallel page export could write the previous
  page's image into its file
- Auto-save only removes its own mcp-*.drawio files, so a DRAWIO_DATA_DIR
  that also holds the user's diagrams keeps them
- screenshot_diagram captures a page that has no id attribute by loading
  just that page, like export_diagram
- An empty <Array as="points"/> no longer hides orphan mxPoints that
  come after it
- POST /api/state refuses a push without xml, which used to wipe the
  stored diagram
- Clear exportOptions when an export ends, reuse hasCells for the empty
  diagram check, and reword two log lines
2026-10-04 07:38:17 +09:00
dayuan.jiang 392c8af84a docs(mcp-server): describe the new MCP features in all READMEs
- MCP server README: 13 tools, drawing rules and shape libraries,
  screenshots, all-or-nothing edits, auto-save and how to continue a
  diagram later, DRAWIO_DATA_DIR and DEBUG, offline use with a local
  draw.io, and what to do when a background tab makes exports time out
- Fix two errors: History is a button at the top right of the preview
  page, and exports are not limited to .drawio
- Claude Code plugin README: same tool list, formats and settings
- Root READMEs (English, Chinese, Japanese): short list of what the MCP
  server can do
2026-10-04 07:12:45 +09:00
dayuan.jiang 127562b0e5 feat(mcp-server): add theme menu, dark mode and editable SVG to the preview
- Pass themes=1 and dark=auto to the draw.io iframe, so the Extras menu
  offers the Theme submenu and draw.io follows the system dark mode; the
  header and dialogs follow it too through prefers-color-scheme
- Download dialog: new Editable SVG (.drawio.svg) format through draw.io's
  xmlsvg export, default name diagram-YYYY-MM-DD, Enter saves and Escape
  closes it and the history dialog
- export_diagram accepts format "drawio.svg" and detects the .drawio.svg
  extension
- Use system fonts instead of Google Fonts, so the page works offline with
  a self-hosted draw.io (DRAWIO_BASE_URL)
2026-10-04 07:10:01 +09:00
dayuan.jiang f52f95025f refactor(mcp-server): move the preview page into src/preview
The 580-line page template in http-server.ts becomes index.html,
preview.css and preview.js, copied to dist/preview by the build and
filled at request time. The rendered page is unchanged apart from the
session id and draw.io origin now coming from a small config script.
Biome skips the folder because of the {{placeholders}}, as it never
linted the old template string either.
2026-10-04 06:46:09 +09:00
dayuan.jiang 667f678eb3 feat(mcp-server): auto-save each session's diagram to a .drawio file
- Save the latest diagram of every session 1 second after each change
  (AI write, browser edit, history restore) to ~/.next-ai-drawio/<id>.drawio,
  keep the newest 50, flush on shutdown; DRAWIO_DATA_DIR changes the folder
  and "off" disables it, like the web app's IndexedDB sessions
- start_session names the file, so a resumed conversation can reopen the
  diagram with load_diagram after the MCP process restarted
- Fix PNG/SVG exports randomly timing out: a previous export's 10 second
  timer cleared the export in progress, and a late reply could be taken
  for the current one; exports are now numbered
2026-10-04 06:42:44 +09:00
dayuan.jiang 81ad317375 feat(mcp-server): add screenshot_diagram so the model can check its render
- New read-only screenshot_diagram tool returns the rendered page as a PNG
  plus the web app's visual checklist (overlaps, edges crossing shapes,
  readability, layout, rendering errors), replacing the web app's
  separate vision model with the host model's own vision
- PNG exports use draw.io's width and pageId options: screenshots stay
  under ~140,000 base64 characters and page exports no longer swap the
  page on screen
- Fail fast with a clear message when the preview tab stopped polling
  (browsers throttle background tabs)
- Mention the screenshot step in the drawing guide and instructions
2026-10-04 06:29:33 +09:00
dayuan.jiang 9de281627e feat(mcp-server): bring the web app's drawing knowledge to MCP
- Add a drawing guide adapted from the web system prompt (layout, edge
  routing, styles, minimal style, editing rules), returned by
  start_session, a new get_drawing_guide tool and the diagram-workflow prompt
- Add get_shape_library with the 30 icon libraries; the build copies
  docs/shape-libraries into dist and CI checks the packed files
- Accept bare mxCell lists in create_new_diagram and add_page; the server
  adds the wrapper and root cells
- Send server instructions, shorten create_new_diagram's description to
  fit Claude Code's 2,048 character limit, and annotate every tool
- Fix dead links and the totals in docs/shape-libraries/README.md
2026-10-04 06:15:46 +09:00
dayuan.jiang 6d67a0ec69 fix(mcp-server): make edit_diagram all-or-nothing and fix preview sync races
- edit_diagram applies nothing when any operation fails, rejects invalid or
  multi-cell new_xml, validates only the target page, and returns the
  current page XML on every rejection (including stale edits)
- Fix get_diagram reading the old diagram right after an AI write: the
  preview pushed its sync reply with a newer version than it was taken at
- Keep a user edit that loses the race with an AI write in history and
  tell the user in the preview
- Autofix removes only exact foreign tags (a stray <mxGraph/> deleted
  <mxGraphModel>), fixes tag case, drops orphan <mxPoint>s, and rejects
  unknown element names in model XML
- Edit empty and compressed pages; PNG exports use the page on screen;
  tag download exports; reload from the server after a page export
- Expand ~ in paths, tell the model when the browser sync timed out,
  use registerPrompt, require SDK ^1.31.0
2026-10-03 22:03:44 +09:00
dayuan.jiang a46787c1b8 fix(mcp-server): fix XSS and crashes, make XML validation strict
- Validate and escape the mcp session id; only serve localhost Host/Origin
- Malformed URLs and session ids return errors instead of crashing the process
- Strict XML syntax check with saxes (linkedom never reports parse errors)
- autoFixXml no longer corrupts valid XML; attribute newlines serialized as entities
- Sessions stay alive while polled; browser pushes carry a base version (409 on conflict)
- Page tools respect the edit gate; UTF-8 bodies decoded correctly
- Export replies matched to requests and serialized; xml sync export handled
- UserObject/object cells addressable by id; history restored by stable id; logs off stdout
2026-10-03 17:45:41 +09:00
dayuan.jiang 95f4b4b92b fix(electron): decrypt keys after ready and harden navigation and IPC
- Apply preset env after app ready, so Windows/Linux get decrypted keys
- Never re-encrypt ciphertext; restore env when switching or removing presets
- Block navigation away from the app, open external links in the browser, check IPC senders
- Keep inherited proxy settings, default NO_PROXY for localhost
- Serialize server start/restart, kill stuck processes, follow port changes
- Atomic config writes, keep corrupt files as backups, remember the server port
- Menu and settings window stay in sync; dev script gets the decrypted preset env
- Use app.isPackaged, parse inline .env comments, drop .env files from the bundle
2026-10-03 17:45:41 +09:00
dayuan.jiang 3193d20e00 fix(model-config): keep model selection valid and fix admin panel edge cases
- Fall back to the default server model when a saved one disappears
- Sync model config across tabs
- Validation uses the base path and sends the access code
- Model ids edited as drafts (no empty, duplicate or padded ids)
- Credential changes reset validation; stale validation results are dropped
- Admin: generateId over HTTP, env-locked group switches, discard and toggle fixes,
  clearing a secret field keeps the saved key, first provider not auto-default when .env sets AI_MODEL
- Model selector items use unique values
2026-10-03 17:45:41 +09:00
dayuan.jiang 87edf2e19d fix(chat-input): stop template dialogs from sending and fix attachment races
- Template dialogs no longer submit the outer chat form
- Sending is blocked while files or URLs are still extracting
- File and URL extraction no longer drop or resurrect entries
- IME composition Enter no longer sends
- Tool call cards show the error text; keyboard handling on cards fixed
- Template import available when empty, edit dialog resets, saved templates refresh
- Only png/jpeg/gif/webp images accepted, SVG sent as text; PDF objects released
- parse-url request sends the access code
2026-10-03 17:45:41 +09:00
dayuan.jiang 5c7613ea09 fix(diagram): fix autosave staleness and XML repair corrupting valid diagrams
- Autosave guard reads refs, so edits after a theme or dark mode switch are kept
- Duplicate-id check and rename run per page; repair loop no longer quadratic
- autoFixXml no longer breaks style values, rich text &quot; or single-line cells
- extractCompleteMxCells keeps the cell after a self-closing cell
- Better truncation detection; object/UserObject wrapped cells are editable
- Exports for thumbnail, PNG and save are routed by tag instead of a shared resolver
- History stores the full document; storage errors are reported, no auto-deletion of chats
- IndexedDB connection reopens after errors; focus refresh throttled
- Keep ?session= on locale redirect, map zh-Hant to zh-tw for draw.io
2026-10-03 17:45:41 +09:00
dayuan.jiang 79b4c52741 fix(chat): keep saved diagrams and pages when restoring, editing and retrying
- Restored sessions no longer replay the last display_diagram over the saved diagram
- Failed or stopped edit_diagram restores the canvas
- Message snapshots keep the full multi-page document
- "Improve with suggestions" uses the normal send path (headers, xml, retry counters)
- Editing a message keeps its file/URL sections; cached example edits work
- New chat's first autosave no longer resets the UI
- Validation retries counted per user turn; validate-diagram sends the access code
- Cached examples only match the example files on an empty canvas
- Template sends keep attachments and wait for extraction
2026-10-03 17:45:41 +09:00
dayuan.jiang 528b6e54c8 fix(api): require access codes and limit sizes on helper routes
- Shared checkAccessCode for validate-diagram, validate-model, parse-url, verify-access-code
- parse-url: 5 MB streamed body limit; validate-diagram: 5 MB image limit
- validate-model refuses redirects when private URLs are blocked
- Admin settings state shared across module instances via globalThis
- Server model ids: unique slugs (non-ASCII names encoded), duplicates rejected
- Panel Bedrock credentials stored as ADMIN_AWS_* so the DynamoDB client keeps its own
- Locale redirect keeps basePath and query; EdgeOne function drops open CORS and checks the access code
- Providers payload reports whether .env sets a default model
2026-10-03 17:45:41 +09:00
dayuan.jiang 366480426d fix(chat): close credential leaks and harden the chat route
- Vertex: a client-supplied base URL only works with the client's own Vertex key
- Accept only data: URLs for file parts in every message, so the server never downloads them
- Output budget retry accounts for the thinking budget Bedrock/Anthropic add, and reads
  Volcengine, DashScope, SGLang and vLLM rejections; falls back to 16000 once
- x-max-output-tokens can only lower the budget on server credentials
- On server credentials only server models or AI_MODEL entries can be used
- Drop tool results together with the invalid tool calls they belong to
- Count quota tokens as input + output (cached tokens were counted twice)
- Private-URL check for custom base URLs, end Langfuse traces on error/abort/early return
- Fix repairToolCall ordering and placeholder, align edit_diagram prompt with operations
- Panel Bedrock keys are read from ADMIN_AWS_*; forward the access code to EdgeOne
- isMinimalDiagram only treats root cells as an empty canvas
2026-10-03 17:45:41 +09:00
165 changed files with 13547 additions and 6479 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Run Biome format
# Pin to the version in package.json so CI matches local/pre-commit
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
run: npx @biomejs/biome@2.5.7 check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
+4
View File
@@ -62,6 +62,10 @@ jobs:
if: steps.version.outputs.publish == 'true'
run: npm test
- name: Build and check package contents
if: steps.version.outputs.publish == 'true'
run: npm run build && npm run check-package
- name: Publish to npm
if: steps.version.outputs.publish == 'true'
run: npm publish
+4
View File
@@ -38,6 +38,10 @@ jobs:
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
# Tests run from src/, so check the built npm package separately
- name: Build MCP server and check package contents
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
e2e:
name: E2E Tests
runs-on: ubuntu-latest
+7
View File
@@ -138,6 +138,13 @@ Then ask Claude to create diagrams:
The diagram appears in your browser in real-time!
The MCP server includes most of the web app's drawing features:
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
- A screenshot tool, so the AI can check the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
## Getting Started
+14 -2
View File
@@ -35,6 +35,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
@@ -225,6 +226,7 @@ function ProviderDetail({
</Button>
{suggestions.length > 0 && (
<Select
value=""
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
@@ -390,12 +392,14 @@ function ProviderDetail({
export function ModelsSection({
providers,
envProviders,
envHasDefaultModel,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
envHasDefaultModel: boolean
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
@@ -409,10 +413,16 @@ export function ModelsSection({
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
id: crypto.randomUUID(),
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
id: generateId(),
provider,
models: [],
isDefault: providers.length === 0,
// Only the very first provider becomes the default, and only when
// the env config has no default that it would replace on save
isDefault:
providers.length === 0 &&
!envProviders.some((p) => p.isDefault) &&
!envHasDefaultModel,
}
onChange([...providers, newProvider])
setSelectedId(newProvider.id)
@@ -496,7 +506,9 @@ export function ModelsSection({
))}
</div>
<div className="border-t p-2">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)}
>
+49 -14
View File
@@ -37,6 +37,19 @@ import { SettingField } from "./setting-field"
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
// For each toggleable group, whether any of its settings has a value (from
// the settings file or the environment)
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
const result: Record<string, boolean> = {}
for (const group of SETTING_GROUPS) {
if (!group.toggleable) continue
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return result
}
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
@@ -62,6 +75,8 @@ export default function AdminPage() {
// Models section state
const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
// Whether .env itself sets AI_MODEL (a default the panel would override)
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
const [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders
@@ -88,15 +103,13 @@ export default function AdminPage() {
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// Seed each toggle once from whether the group has configured
// values; don't stomp a user's explicit toggle on later saves
// A group stays on while it still has values (e.g. from env vars
// that saving can't remove); a user's explicit "on" for a group
// with no values yet is kept across saves
setEnabledGroups((prev) => {
const next = { ...prev }
for (const group of SETTING_GROUPS) {
if (!group.toggleable || group.id in next) continue
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
const next = groupsWithValues(map)
for (const id of Object.keys(next)) {
next[id] = next[id] || !!prev[id]
}
return next
})
@@ -108,10 +121,12 @@ export default function AdminPage() {
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
envHasDefaultModel?: boolean
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
setEnvHasDefaultModel(!!data.envHasDefaultModel)
},
[],
)
@@ -181,8 +196,9 @@ export default function AdminPage() {
return () => observer.disconnect()
}, [authedPassword])
// value undefined drops the pending change (back to the saved value)
const handleChange = useCallback(
(key: string, value: string | null) => {
(key: string, value: string | null | undefined) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
@@ -201,7 +217,7 @@ export default function AdminPage() {
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (isRevert || isNoop) {
if (value === undefined || isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
@@ -225,9 +241,10 @@ export default function AdminPage() {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// Stage deletion only for values currently set
if (settings[key]?.source !== "default")
next[key] = null
// Stage deletion of saved values; drop unsaved input
if (settings[key]?.source === "default")
delete next[key]
else next[key] = null
} else if (next[key] === null) {
delete next[key]
}
@@ -447,6 +464,7 @@ export default function AdminPage() {
<ModelsSection
providers={providers}
envProviders={envProviders}
envHasDefaultModel={envHasDefaultModel}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
@@ -462,6 +480,11 @@ export default function AdminPage() {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
// Values from env vars can't be removed here, so the
// group can't be turned off from the panel
const envLocked = defs.some(
(d) => settings[d.key]?.source === "env",
)
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
@@ -480,6 +503,11 @@ export default function AdminPage() {
</h2>
{group.toggleable && (
<label
title={
envLocked
? dict.admin.sourceEnvTitle
: undefined
}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
enabledGroups[group.id]
@@ -494,7 +522,11 @@ export default function AdminPage() {
checked={
!!enabledGroups[group.id]
}
disabled={!writable || saving}
disabled={
!writable ||
saving ||
envLocked
}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
@@ -579,6 +611,9 @@ export default function AdminPage() {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
setEnabledGroups(
groupsWithValues(settings),
)
}}
>
{dict.admin.discard}
+10 -5
View File
@@ -73,8 +73,10 @@ export function SecretInput({
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// The stored marker as it was at mount, to revert to on empty
const [original] = useState(value)
// The stored marker to revert to on empty. Refreshed whenever the parent
// passes server state (a marker or nothing), e.g. after a save.
const [original, setOriginal] = useState(value)
if (typeof value !== "string" && value !== original) setOriginal(value)
const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value)
@@ -146,7 +148,8 @@ export function SettingField({
pendingValue: string | null | undefined
error?: string
disabled: boolean
onChange: (value: string | null) => void
// undefined drops the pending change (back to the saved value)
onChange: (value: string | null | undefined) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
@@ -226,16 +229,18 @@ export function SettingField({
case "secret":
control = (
<div className="w-full max-w-md">
{/* Clearing a saved secret reverts to it; the X button deletes */}
<SecretInput
id={inputId}
keepOnEmpty={source === "file"}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? currentValue)
: (secretState ?? undefined)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : "")
onChange(typeof v === "string" ? v : undefined)
}
/>
</div>
+12 -17
View File
@@ -37,7 +37,6 @@ export default function Home() {
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
// Load preferences from localStorage after mount
useEffect(() => {
@@ -48,7 +47,9 @@ export default function Home() {
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
router.replace(`/${pathParts.join("/")}`)
// Keep the query (e.g. ?session=) and hash
const { search, hash } = window.location
router.replace(`/${pathParts.join("/")}${search}${hash}`)
return // Wait for redirect
}
}
@@ -106,27 +107,17 @@ export default function Home() {
resetDrawioReady()
}
// Check mobile - reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
// Check mobile. The draw.io iframe is not remounted when crossing the
// breakpoint (only the chat panel is), so its ready state stays as is.
useEffect(() => {
const checkMobile = () => {
const newIsMobile = window.innerWidth < 768
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
setIsDrawioReady(false)
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [resetDrawioReady])
}, [])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -193,7 +184,11 @@ export default function Home() {
noExitBtn: true,
dark:
darkMode || drawioUi === "dark",
lang: currentLang,
// draw.io names Traditional Chinese "zh-tw"
lang:
currentLang === "zh-Hant"
? "zh-tw"
: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
+8 -1
View File
@@ -7,7 +7,11 @@ import {
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
import {
getEnvFallback,
isSettingsWritable,
saveSettings,
} from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
@@ -33,6 +37,9 @@ async function payload() {
models: p.models,
isDefault: !!p.default && !adminHasDefault,
})) ?? [],
// Whether .env sets a default model. getEnvFallback skips the value
// the panel overlays onto process.env, so a panel default doesn't count.
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
}
}
-61
View File
@@ -1,61 +0,0 @@
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()
}
}
+221 -318
View File
@@ -4,43 +4,58 @@ import {
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
LoadAPIKeyError,
stepCountIs,
streamText,
} from "ai"
import fs from "fs/promises"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import {
CACHE_POINT,
getAIModel,
SINGLE_SYSTEM_PROVIDERS,
supportsPromptCaching,
usesServerCredentials,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
isMinimalDiagram,
dropInvalidToolCalls,
fixToolInputJson,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import {
endTrace,
getTelemetryConfig,
setTraceInput,
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
import {
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
} from "@/lib/output-token-limit"
import { findServerModelById } from "@/lib/server-model-config"
import {
type FlattenedServerModel,
findServerModelById,
} from "@/lib/server-model-config"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
// 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,
@@ -76,24 +91,16 @@ function createCachedStreamResponse(xml: string): Response {
return createUIMessageStreamResponse({ stream })
}
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>()
// Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
async function handleChatRequest(req: Request): Promise<Response> {
// Check for access code
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
if (accessCodes.length > 0) {
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
return Response.json(
{
error: "Invalid or missing access code. Please configure it in Settings.",
},
{ status: 401 },
)
}
}
const accessDenied = checkAccessCode(req)
if (accessDenied) return accessDenied
const body = await req.json()
const { messages, xml, previousXml, sessionId } = body
@@ -126,36 +133,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
userId: userId,
})
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
const hasOwnApiKey = !!(
req.headers.get("x-ai-provider") &&
(req.headers.get("x-ai-api-key") ||
req.headers.get("x-aws-access-key-id") ||
req.headers.get("x-vertex-api-key"))
)
// Skip quota check if: quota disabled, user has own API key, or is anonymous
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// === SERVER-SIDE QUOTA CHECK END ===
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) {
@@ -165,7 +142,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
const isEmptyDiagram = !xml || !hasCells(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
@@ -192,6 +169,15 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrl = `${origin}/api/edgeai`
}
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
// not point the server at a private or internal address
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return Response.json(
{ error: "Private or internal base URLs are not allowed." },
{ status: 400 },
)
}
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie")
@@ -201,8 +187,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrlEnv?: string
provider?: string
} = {}
let serverModel: FlattenedServerModel | null = null
if (selectedModelId?.startsWith("server:")) {
const serverModel = await findServerModelById(selectedModelId)
serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
@@ -221,7 +208,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
provider: serverModelConfig.provider || provider,
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
modelId: req.headers.get("x-ai-model"),
// A server model runs the model it was configured with, whatever the header says
modelId: serverModel?.modelId || req.headers.get("x-ai-model"),
// AWS Bedrock credentials
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
@@ -231,11 +219,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
...serverModelConfig,
// Vertex AI credentials (Express Mode)
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication
...(provider === "edgeone" &&
cookieHeader && {
headers: { cookie: cookieHeader },
}),
// Pass cookies for EdgeOne Pages authentication, and the access code,
// which the EdgeOne function checks too
...(provider === "edgeone" && {
headers: {
...(cookieHeader && { cookie: cookieHeader }),
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
}
// Read minimal style preference from header
@@ -249,17 +240,66 @@ async function handleChatRequest(req: Request): Promise<Response> {
const {
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)
// On the server's own keys, only run models the server offers: a server
// model picked by id (its model name is fixed above) or one in AI_MODEL.
// With their own key, users can run any model.
const onServerCredentials = usesServerCredentials(
resolvedProvider,
clientOverrides,
)
const envModels =
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
if (onServerCredentials && !serverModel && !envModels.includes(modelId)) {
return Response.json(
{
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
},
{ status: 400 },
)
}
// User setting wins over server env, so desktop users can raise it themselves
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
// server's keys. Decided by the key actually used: a key header the
// provider never reads must not skip it.
const countsQuota =
isQuotaEnabled() && onServerCredentials && userId !== "anonymous"
if (countsQuota) {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// === SERVER-SIDE QUOTA CHECK END ===
// Retry once if the provider rejects the requested budget, or (newer
// Claude models) the sampling or thinking settings
const model = withOutputTokenLimitFallback(
withDeprecatedParamsFallback(baseModel),
)
// The user setting can raise the budget only on their own key (in the
// desktop app every key is the user's); on the server's keys it can only
// lower it
const maxOutputTokens = resolveMaxOutputTokens(
req.headers.get("x-max-output-tokens"),
onServerCredentials,
)
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
@@ -294,35 +334,37 @@ ${userInputText}
// Convert UIMessages to ModelMessages and add system message
const modelMessages = await convertToModelMessages(messages)
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
}
// Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
@@ -340,61 +382,43 @@ ${userInputText}
)
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
enhancedMessages = enhancedMessages
.map((msg: any) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const filteredContent = msg.content.filter((part: any) => {
if (part.type === "tool-call") {
// Check if input is a valid object (not null, undefined, or empty)
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
console.warn(
`[route.ts] Filtering out tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
return false
}
}
return true
})
return { ...msg, content: filteredContent }
})
.filter((msg: any) => msg.content && msg.content.length > 0)
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
// JSON object, and every provider rejects a tool result whose call is gone.
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
// DEBUG: Log modelMessages structure (what's being sent to AI)
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (part.type === "tool-call" || part.type === "tool-result") {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-call" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
}
// Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) {
@@ -410,7 +434,7 @@ ${userInputText}
contentParts.push({
type: "image",
image: filePart.url,
mimeType: filePart.mediaType,
mediaType: filePart.mediaType,
})
}
@@ -430,9 +454,7 @@ ${userInputText}
if (enhancedMessages[i].role === "assistant") {
enhancedMessages[i] = {
...enhancedMessages[i],
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
providerOptions: CACHE_POINT,
}
break // Only cache the last assistant message
}
@@ -471,7 +493,7 @@ ${previousXml}
${xml || ""}
"""
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.`
const systemMessages = isSingleSystemProvider
? [
@@ -485,21 +507,13 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
{
role: "system" as const,
content: finalSystemMessage,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
...(shouldCache && { providerOptions: CACHE_POINT }),
},
// Cache breakpoint 2: Previous and Current diagram XML context
{
role: "system" as const,
content: xmlContext,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
...(shouldCache && { providerOptions: CACHE_POINT }),
},
]
@@ -507,6 +521,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const result = streamText({
model,
// The system messages carry cache points, so they go in messages.
// A client's own system messages have string content and were
// dropped by the empty-content filter above.
allowSystemInMessages: true,
abortSignal: req.signal,
// 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.
@@ -528,23 +546,11 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
error.name === "AI_InvalidToolInputError"
) {
try {
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
let inputToRepair = toolCall.input
if (typeof inputToRepair === "string") {
// Fix `:=` instead of `: ` (LLM sometimes generates this)
inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
// Fix inconsistent quote escaping in XML attributes within JSON strings
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
// Example: y="-20\" should be y=\"-20\"
inputToRepair = inputToRepair.replace(
/(\w+)="([^"]*?)\\"/g,
'$1=\\"$2\\"',
)
}
// Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(inputToRepair)
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
// then use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(
fixToolInputJson(toolCall.input),
)
console.log(
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
)
@@ -554,26 +560,8 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
repairError,
)
// Return a placeholder input to avoid API errors in multi-step
// The tool will fail gracefully on client side
if (toolCall.toolName === "edit_diagram") {
return {
...toolCall,
input: {
operations: [],
_error: "JSON repair failed - no operations to apply",
},
}
}
if (toolCall.toolName === "display_diagram") {
return {
...toolCall,
input: {
xml: "",
_error: "JSON repair failed - empty diagram",
},
}
}
// Keep the original error, so the model and the client see why
// the input was rejected and the model can retry the call
return null
}
}
@@ -582,7 +570,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
},
messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({
@@ -596,21 +583,20 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
// Record token usage for server-side quota tracking (if enabled)
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
// Include all 4 token types: input, output, cache read, cache write
if (
isQuotaEnabled() &&
!hasOwnApiKey &&
userId !== "anonymous" &&
totalUsage
) {
// inputTokens already includes cache reads and writes in AI SDK 6
if (countsQuota && totalUsage) {
const totalTokens =
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0) +
(totalUsage.cachedInputTokens || 0) +
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
(totalUsage.outputTokens || 0)
recordTokenUsage(userId, totalTokens)
}
},
// onFinish is skipped when the stream fails or is aborted, so end the trace here
onError: ({ error }) => {
console.error(error) // what AI SDK does without an onError
endTrace()
},
onAbort: () => endTrace(),
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
@@ -625,21 +611,7 @@ VALIDATION RULES (XML will be rejected if violated):
6. Escape special chars in values: &lt; &gt; &amp; &quot;
Example (generate ONLY this - no wrapper tags):
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
${SWIMLANE_EXAMPLE}
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
@@ -717,14 +689,7 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
- Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android, material_design
- Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons
${SHAPE_LIBRARY_LIST}
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
@@ -735,45 +700,12 @@ Call this tool to get shape names and usage syntax for a specific library.`,
),
}),
execute: async ({ library }) => {
// Sanitize input - prevent path traversal attacks
const sanitizedLibrary = library
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
if (sanitizedLibrary !== library.toLowerCase()) {
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
}
const baseDir = path.join(
process.cwd(),
"docs/shape-libraries",
// Only known library names reach the file system
const result = await getShapeLibrary(
library,
path.join(process.cwd(), "docs/shape-libraries"),
)
const filePath = path.join(
baseDir,
`${sanitizedLibrary}.md`,
)
// Verify path stays within expected directory
const resolvedPath = path.resolve(filePath)
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
return `Invalid library path.`
}
try {
const content = await fs.readFile(filePath, "utf-8")
return content
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
}
console.error(
`[get_shape_library] Error loading "${library}":`,
error,
)
return `Error loading library "${library}". Please try again.`
}
return result.ok ? result.text : result.error
},
},
},
@@ -782,8 +714,9 @@ Call this tool to get shape names and usage syntax for a specific library.`,
}),
})
return result.toUIMessageStreamResponse({
const response = result.toUIMessageStreamResponse({
sendReasoning: true,
onError: (error) => streamErrorText(error, onServerCredentials),
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
@@ -796,63 +729,28 @@ Call this tool to get shape names and usage syntax for a specific library.`,
return undefined
},
})
modelStreamResponses.add(response)
return response
}
// Helper to categorize errors and return appropriate response
// Errors before the stream starts, as JSON the chat panel reads
function handleError(error: unknown): Response {
console.error("Error in chat route:", error)
const isDev = process.env.NODE_ENV === "development"
// Check for specific AI SDK error types
if (APICallError.isInstance(error)) {
return Response.json(
{
error: error.message,
...(isDev && {
details: error.responseBody,
stack: error.stack,
}),
},
{ status: error.statusCode || 500 },
)
}
if (LoadAPIKeyError.isInstance(error)) {
return Response.json(
{
error: "Authentication failed. Please check your API key.",
...(isDev && {
stack: error.stack,
}),
},
{ status: 401 },
)
}
// Fallback for other errors with safety filter
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
const status = (error as any)?.statusCode || (error as any)?.status || 500
// Prevent leaking API keys, tokens, or other sensitive data
const lowerMessage = message.toLowerCase()
const safeMessage =
lowerMessage.includes("key") ||
lowerMessage.includes("token") ||
lowerMessage.includes("sig") ||
lowerMessage.includes("signature") ||
lowerMessage.includes("secret") ||
lowerMessage.includes("password") ||
lowerMessage.includes("credential")
? "Authentication failed. Please check your credentials."
: message
const classified = classifyLLMError(error)
const status =
(error as { statusCode?: number })?.statusCode ||
(error as { status?: number })?.status ||
(classified.code === "invalid_api_key" ? 401 : 500)
return Response.json(
{
error: safeMessage,
...classified,
...(isDev && {
details: message,
details: APICallError.isInstance(error)
? error.responseBody
: undefined,
stack: error instanceof Error ? error.stack : undefined,
}),
},
@@ -862,11 +760,16 @@ function handleError(error: unknown): Response {
// Wrap handler with error handling
async function safeHandler(req: Request): Promise<Response> {
let response: Response
try {
return await handleChatRequest(req)
response = await handleChatRequest(req)
} catch (error) {
return handleError(error)
response = handleError(error)
}
// Early returns, cache hits and errors never reach streamText's callbacks,
// so their Langfuse trace has to be ended here
if (!modelStreamResponses.has(response)) endTrace()
return response
}
// Wrap with Langfuse observe (if configured)
+1 -1
View File
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
format: z.enum(["drawio", "png", "svg"]),
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
sessionId: z.string().min(1).max(200).optional(),
})
+40 -1
View File
@@ -1,9 +1,11 @@
import { extractFromHtml } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { checkAccessCode } from "@/lib/access-code"
import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
@@ -32,7 +34,36 @@ function detectCharset(
}
}
// Read the response body, giving up once it passes MAX_RESPONSE_BYTES so a
// huge download can't exhaust server memory. Returns null when too large.
async function readLimitedBody(
response: Response,
): Promise<ArrayBuffer | null> {
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
return null
}
if (!response.body) return new ArrayBuffer(0)
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel()
return null
}
chunks.push(value)
}
return new Blob(chunks as BlobPart[]).arrayBuffer()
}
export async function POST(req: Request) {
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const { url } = await req.json()
@@ -97,7 +128,15 @@ export async function POST(req: Request) {
)
}
const buffer = await response.arrayBuffer()
const buffer = await readLimitedBody(response)
if (!buffer) {
return NextResponse.json(
{
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
},
{ status: 413 },
)
}
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
} catch (err: any) {
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server"
import { checkAccessCode } from "@/lib/access-code"
import { classifyLLMError } from "@/lib/llm-errors"
import { canListModels, listProviderModels } from "@/lib/provider-models"
import {
allowPrivateUrls,
isPrivateUrl,
redirectGuardedFetch,
} from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
// Public lists need no key
const NO_KEY_NEEDED = new Set<ProviderName>([
"ollama",
"openrouter",
"aihubmix",
])
/**
* The models a provider offers, for the "Fetch models" button in model
* settings. Answers { models: null } for providers that cannot list them,
* so the dialog keeps its suggested models.
*/
export async function POST(req: Request) {
// Sends requests to a URL the client chose, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
const { provider, apiKey, baseUrl } = (await req.json()) as {
provider: ProviderName
apiKey?: string
baseUrl?: string
}
if (!canListModels(provider)) {
return NextResponse.json({ models: null })
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
}
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
)
}
try {
const models = await listProviderModels(
provider,
{ apiKey, baseUrl },
(baseUrl && redirectGuardedFetch()) || fetch,
)
return NextResponse.json({ models })
} catch (error) {
console.warn("[provider-models] Listing failed:", error)
const { code, message } = classifyLLMError(error)
return NextResponse.json({ code, error: message })
}
}
+23 -19
View File
@@ -3,7 +3,8 @@
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { streamObject } from "ai"
import { Output, streamText } from "ai"
import { checkAccessCode } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
@@ -13,6 +14,9 @@ import {
export const maxDuration = 30
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
@@ -25,25 +29,18 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
/** A fixed result in the text format useObject reads */
function createStreamingResponse(result: ValidationResult): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
// Uses the server's model credentials, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
@@ -72,6 +69,13 @@ export async function POST(req: Request): Promise<Response> {
)
}
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
return Response.json(
{ error: "Image data too large" },
{ status: 413 },
)
}
// Get the validation model
let model
try {
@@ -93,9 +97,9 @@ export async function POST(req: Request): Promise<Response> {
) || 10000
// Stream the VLM response for useObject consumption
const result = streamObject({
const result = streamText({
model,
schema: ValidationResultSchema,
output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
@@ -114,10 +118,10 @@ export async function POST(req: Request): Promise<Response> {
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ object }) => {
if (sessionId && object) {
onFinish: ({ output }) => {
if (sessionId && output) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
)
}
},
+90 -368
View File
@@ -1,27 +1,16 @@
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
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 { streamText, tool } from "ai"
import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2"
import {
AIHUBMIX_APP_CODE,
isAihubmixStandardBaseURL,
normalizeMiniMaxBaseURL,
} from "@/lib/ai-providers"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import { getAIModel, usesServerCredentials } from "@/lib/ai-providers"
import { classifyLLMError } from "@/lib/llm-errors"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
import type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
interface ValidateRequest {
provider: string
provider: ProviderName
apiKey: string
baseUrl?: string
modelId: string
@@ -33,7 +22,22 @@ interface ValidateRequest {
vertexApiKey?: string // Express Mode API key
}
const TEST_TIMEOUT_MS = 15_000
// Drawing works through tool calls, so the test asks for one
const PING_TOOL = tool({
description: "Report that the connection works.",
inputSchema: z.object({}),
})
const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
export async function POST(req: Request) {
// Lets the server send requests to arbitrary URLs, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const body: ValidateRequest = await req.json()
const {
@@ -90,369 +94,87 @@ export async function POST(req: Request) {
{ status: 400 },
)
}
let model: any
switch (provider) {
case "openai": {
const openai = createOpenAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openai.chat(modelId)
break
}
case "anthropic": {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || "https://api.anthropic.com/v1",
})
model = anthropic(modelId)
break
}
case "google": {
const google = createGoogleGenerativeAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = google(modelId)
break
}
case "vertexai": {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = vertex(modelId)
break
}
case "azure": {
const azure = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = azure.chat(modelId)
break
}
case "bedrock": {
const bedrock = createAmazonBedrock({
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
region: awsRegion,
})
model = bedrock(modelId)
break
}
case "openrouter": {
const openrouter = createOpenRouter({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openrouter(modelId)
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({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = ds(modelId)
} else {
model = deepseek(modelId)
}
break
}
case "siliconflow": {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
})
model = sf.chat(modelId)
break
}
case "ollama": {
// SECURITY: Mirror ai-providers.ts guard — only use server
// OLLAMA_API_KEY when the URL is also from server config.
const ollamaApiKey = baseUrl
? apiKey || undefined
: apiKey || process.env.OLLAMA_API_KEY || undefined
const ollamaProvider = createOllama({
baseURL:
baseUrl ||
process.env.OLLAMA_BASE_URL ||
"https://ollama.com/api",
...(ollamaApiKey && {
headers: { Authorization: `Bearer ${ollamaApiKey}` },
}),
})
model = ollamaProvider(modelId)
break
}
case "gateway": {
const gw = createGateway({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = gw(modelId)
break
}
case "edgeone": {
// EdgeOne uses OpenAI-compatible API via Edge Functions
// Need to pass cookies for EdgeOne Pages authentication
const cookieHeader = req.headers.get("cookie") || ""
const edgeone = createOpenAI({
apiKey: "edgeone", // EdgeOne doesn't require API key
baseURL: baseUrl || "/api/edgeai",
headers: {
cookie: cookieHeader,
},
})
model = edgeone.chat(modelId)
break
}
case "sglang": {
// SGLang is OpenAI-compatible
const sglang = createOpenAI({
apiKey: apiKey || "not-needed",
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
})
model = sglang.chat(modelId)
break
}
case "doubao": {
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
const doubaoBaseUrl =
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
const lowerModelId = modelId.toLowerCase()
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
break
}
case "modelscope": {
const baseURL =
baseUrl || "https://api-inference.modelscope.cn/v1"
const startTime = Date.now()
try {
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
const response = await fetch(
`${baseURL}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{ role: "user", content: "Say 'OK'" },
],
max_tokens: 20,
stream: true,
enable_thinking: false,
}),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`ModelScope API error (${response.status}): ${errorText}`,
)
}
const contentType =
response.headers.get("content-type") || ""
const isValidStreamingResponse =
response.status === 200 &&
(contentType.includes("text/event-stream") ||
contentType.includes("application/json"))
if (!isValidStreamingResponse) {
throw new Error(
`Unexpected response format: ${contentType}`,
)
}
const responseTime = Date.now() - startTime
if (response.body) {
response.body.cancel().catch(() => {
/* Ignore cancellation errors */
})
}
return NextResponse.json({
valid: true,
responseTime,
note: "ModelScope model validated (using streaming API)",
})
} catch (error) {
console.error(
"[validate-model] ModelScope validation failed:",
error,
)
throw error
}
}
case "minimax": {
const rawUrl =
baseUrl ||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
"https://api.minimaxi.com/anthropic"
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
normalizeMiniMaxBaseURL(rawUrl)
if (isAnthropicCompatible) {
const minimax = createAnthropic({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
}
break
}
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita":
case "atlascloud":
case "mimo": {
const baseURL =
baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
""
if (!baseURL) {
return NextResponse.json(
{
valid: false,
error: `No base URL configured for provider: ${provider}`,
},
{ status: 400 },
)
}
const openai = createOpenAI({
apiKey,
baseURL,
})
model = openai.chat(modelId)
break
}
default:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },
{ status: 400 },
)
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
if (
usesServerCredentials(provider, {
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
vertexApiKey,
})
) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// Make a minimal test request
const startTime = Date.now()
await generateText({
model,
prompt: "Say 'OK'",
maxOutputTokens: 20,
// The same model the chat would use. A client base URL makes it
// refuse redirects to internal hosts.
const { model } = getAIModel({
provider,
modelId,
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
})
// Streaming, like the chat (some models only stream). Stop at the
// first tool call; a reasoning model that runs out of tokens first
// proves the connection but not tool support.
const startTime = Date.now()
const result = streamText({
model,
prompt: "Call the ping tool.",
tools: { ping: PING_TOOL },
maxOutputTokens: 1024,
maxRetries: 0,
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
})
let calledTool = false
let finishReason: string | undefined
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
// The timeout ends the stream with an abort part, not an error
if (part.type === "abort") {
const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
)
timeout.name = "TimeoutError"
throw timeout
}
if (part.type === "tool-call") {
calledTool = true
break
}
if (part.type === "finish") finishReason = part.finishReason
}
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
...(!calledTool &&
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
})
} catch (error) {
console.error("[validate-model] Error:", error)
let errorMessage = "Validation failed"
if (error instanceof Error) {
// Extract meaningful error message
if (
error.message.includes("401") ||
error.message.includes("Unauthorized")
) {
errorMessage = "Invalid API key"
} else if (
error.message.includes("404") ||
error.message.includes("not found")
) {
errorMessage = "Model not found"
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
errorMessage = "Rate limited - try again later"
} else if (error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to server"
} else {
errorMessage = error.message.slice(0, 100)
}
}
const { code, message } = classifyLLMError(error)
return NextResponse.json(
{ valid: false, error: errorMessage },
{ valid: false, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
)
}
+4 -24
View File
@@ -1,29 +1,9 @@
import { checkAccessCode } from "@/lib/access-code"
export async function POST(req: Request) {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
// If no access codes configured, verification always passes
if (accessCodes.length === 0) {
return Response.json({
valid: true,
message: "No access code required",
})
}
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader) {
if (checkAccessCode(req)) {
return Response.json(
{ valid: false, message: "Access code is required" },
{ status: 401 },
)
}
if (!accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ valid: false, message: "Invalid access code" },
{ valid: false, message: "Invalid or missing access code" },
{ status: 401 },
)
}
+6 -1
View File
@@ -7,7 +7,12 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!public"]
"includes": [
"**",
"!public",
"!packages/mcp-server/src/preview",
"!lib/model-catalog.json"
]
},
"formatter": {
"enabled": true,
+70 -39
View File
@@ -11,7 +11,9 @@ import {
} from "lucide-react"
import type React from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
@@ -41,9 +43,20 @@ import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
function isValidFileType(file: File): boolean {
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
@@ -164,7 +177,7 @@ interface ChatInputProps {
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
sessionId?: string
error?: Error | null
@@ -244,6 +257,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Block sending until attached files and URLs have their text, otherwise
// their content would be silently dropped
const isExtractingAttachments =
files.some((file) => pdfData.get(file)?.isExtracting) ||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
@@ -281,6 +299,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
@@ -292,7 +313,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit()
}
}
@@ -380,13 +406,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
// Let validateFiles show a toast for unsupported types
const { validFiles, errors } = validateFiles(
supportedFiles,
Array.from(e.dataTransfer.files),
files.length,
dict,
)
@@ -401,33 +423,34 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
// Skip if the URL was removed while extracting
onUrlChange((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
@@ -463,11 +486,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
: undefined
}
/>
@@ -559,7 +583,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
@@ -588,7 +612,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
) : (
<Button
type="submit"
disabled={isDisabled || !input.trim()}
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
@@ -614,9 +642,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
{onUrlChange && (
<UrlInputDialog
@@ -629,7 +656,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => setShowSaveAsTemplate(false)}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()}
/>
</form>
+162 -211
View File
@@ -26,6 +26,7 @@ import {
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { ChatLobby } from "@/components/chat/ChatLobby"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
@@ -37,12 +38,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import {
applyDiagramOperations,
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
@@ -129,12 +130,14 @@ const getMessageTextContent = (message: UIMessage): string => {
.join("\n")
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
// Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message)
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
}
interface SessionMetadata {
@@ -146,6 +149,8 @@ interface SessionMetadata {
interface ChatMessageDisplayProps {
messages: UIMessage[]
// Shown on an error that a model setting can fix (bad key, unknown model)
onOpenModelConfig?: () => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>>
@@ -169,6 +174,7 @@ interface ChatMessageDisplayProps {
export function ChatMessageDisplay({
messages,
onOpenModelConfig,
setInput,
setFiles,
processedToolCallsRef,
@@ -188,6 +194,23 @@ export function ChatMessageDisplay({
currentInput = "",
}: ChatMessageDisplayProps) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
}
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
return (
<p>
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
</p>
)
}
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
@@ -208,20 +231,6 @@ export function ChatMessageDisplay({
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
}
}, [messages.length, processedToolCalls])
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
@@ -343,73 +352,30 @@ export function ChatMessageDisplay({
}
}
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string, showToast = false) => {
let currentXml = xml || ""
(xml: string) => {
const completeCells = extractCompleteMxCells(xml || "")
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
// During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML
if (!showToast) {
const completeCells = extractCompleteMxCells(currentXml)
if (!completeCells) {
return
}
currentXml = completeCells
}
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser()
// Wrap in root element for parsing multiple mxCell elements
const testDoc = parser.parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
const parseError = testDoc.querySelector("parsererror")
if (parseError) {
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.malformedXml)
}
return // Skip this update
}
try {
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
const baseXML =
chartXML ||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml)
// During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true)
if (!showToast) {
previousXML.current = convertedXml
onDisplayChart(replacedXML, true)
return
}
// Final output: run full validation and auto-fix
const validation = validateAndFixXml(replacedXML)
if (validation.valid) {
previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML
onDisplayChart(xmlToLoad, true)
} else {
toast.error(dict.errors.validationFailed)
}
} catch (error) {
console.error("Error processing XML:", error)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.failedToProcess)
}
}
try {
// An empty canvas gets a default mxfile to put the cells in
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(baseXML, convertedXml)
previousXML.current = convertedXml
onDisplayChart(replacedXML, true)
} catch (error) {
console.error("Error processing XML:", error)
}
},
[chartXML, onDisplayChart],
@@ -456,8 +422,17 @@ export function ChatMessageDisplay({
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
// The diagram without streamed previews. Undoing a failed edit's
// preview below changes it before chartXML catches up, and an edit
// streaming right after must start from the undone diagram.
let baseXml = chartXML
messagesToProcess.forEach((message) => {
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
const isRestoredMessage =
loadedMessageIdsRef?.current.has(message.id) ?? false
if (message.parts) {
message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) {
@@ -475,6 +450,8 @@ export function ChatMessageDisplay({
})
}
if (isRestoredMessage) return
if (
part.type === "tool-display_diagram" &&
input?.xml
@@ -488,63 +465,62 @@ export function ChatMessageDisplay({
return // Skip redundant processing
}
if (
state === "input-streaming" ||
state === "input-available"
) {
// Debounce streaming updates - queue the XML and process after delay
pendingXmlRef.current = xml
if (!debounceTimeoutRef.current) {
// No pending timeout - set one up
debounceTimeoutRef.current = setTimeout(
() => {
const pendingXml =
pendingXmlRef.current
debounceTimeoutRef.current = null
pendingXmlRef.current = null
if (pendingXml) {
handleDisplayChart(
pendingXml,
false,
)
lastProcessedXmlRef.current.set(
toolCallId,
pendingXml,
)
}
},
STREAMING_DEBOUNCE_MS,
)
}
// Messages update at most every 150 ms while
// streaming (useChat throttle in chat-panel)
if (state === "input-streaming") {
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final output - process immediately (clear any pending debounce)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
pendingXmlRef.current = null
}
// Show toast only if final XML is malformed
handleDisplayChart(xml, true)
// Input complete: the tool handler loads the
// validated diagram
processedToolCalls.current.add(toolCallId)
// Clean up the ref entry - tool is complete, no longer needed
lastProcessedXmlRef.current.delete(toolCallId)
}
}
// Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (
part.type === "tool-edit_diagram" &&
input?.operations
) {
if (part.type === "tool-edit_diagram") {
// Failed or stopped: if the original XML is still
// stored, the tool handler never ran (invalid
// JSON, or the user pressed stop), so undo the
// streamed preview here. Invalid JSON leaves no
// operations in the input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(
toolCallId,
)
onDisplayChart(originalXml, true)
baseXml = originalXml
}
return
}
if (!input?.operations) return
if (state !== "input-streaming") {
// Input complete: the tool handler applies the
// checked edit (it reads the original XML too)
if (
!processedToolCalls.current.has(toolCallId)
) {
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
}
return
}
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref)
@@ -553,7 +529,7 @@ export function ChatMessageDisplay({
toolCallId,
)
) {
if (!chartXML) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
@@ -561,10 +537,9 @@ export function ChatMessageDisplay({
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
chartXML,
baseXml,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
@@ -572,94 +547,36 @@ export function ChatMessageDisplay({
if (!originalXml) return
// Skip if no change from last processed state
const lastCount = lastProcessedXmlRef.current.get(
toolCallId + "-opCount",
)
if (lastCount === String(completeOps.length)) return
const countKey = `${toolCallId}-opCount`
const opCount = String(completeOps.length)
if (
state === "input-streaming" ||
state === "input-available"
lastProcessedXmlRef.current.get(countKey) ===
opCount
) {
// Queue the operations for debounced processing
pendingEditRef.current = {
operations: completeOps,
toolCallId,
}
if (!editDebounceTimeoutRef.current) {
editDebounceTimeoutRef.current = setTimeout(
() => {
const pending =
pendingEditRef.current
editDebounceTimeoutRef.current =
null
pendingEditRef.current = null
if (pending) {
const origXml =
editDiagramOriginalXmlRef.current.get(
pending.toolCallId,
)
if (!origXml) return
try {
const {
result: editedXml,
} = applyDiagramOperations(
origXml,
pending.operations,
)
handleDisplayChart(
editedXml,
false,
)
lastProcessedXmlRef.current.set(
pending.toolCallId +
"-opCount",
String(
pending.operations
.length,
),
)
} catch (e) {
console.warn(
`[edit_diagram streaming] Operation failed:`,
e instanceof Error
? e.message
: e,
)
}
}
},
STREAMING_DEBOUNCE_MS,
)
}
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final state - cleanup streaming refs (tool handler does final application)
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
return
}
try {
const { result } = applyDiagramOperations(
originalXml,
completeOps,
)
// Load the full document so other pages stay intact
onDisplayChart(result, true)
lastProcessedXmlRef.current.set(
countKey,
opCount,
)
} catch (e) {
console.warn(
"[edit_diagram streaming] Operation failed:",
e instanceof Error ? e.message : e,
)
processedToolCalls.current.add(toolCallId)
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
}
}
}
})
}
})
// NOTE: Don't cleanup debounce timeouts here!
// The cleanup runs on every re-render (when messages changes),
// which would cancel the timeout before it fires.
// Let the timeouts complete naturally - they're harmless if component unmounts.
}, [messages, handleDisplayChart, chartXML])
return (
@@ -845,7 +762,11 @@ export function ChatMessageDisplay({
!isRestoredMessage
}
>
<ReasoningTrigger />
<ReasoningTrigger
getThinkingMessage={
thinkingMessage
}
/>
<ReasoningContent>
{
reasoningPart.text
@@ -951,8 +872,12 @@ export function ChatMessageDisplay({
part.type?.startsWith(
"tool-",
)
// Blank text (some models send
// a lone space) gets no bubble
const isContentPart =
part.type === "text" ||
(part.type === "text" &&
part.text.trim() !==
"") ||
part.type === "file"
if (isToolPart) {
@@ -1302,6 +1227,32 @@ export function ChatMessageDisplay({
return null
},
)}
{message.role ===
"system" &&
(
message.metadata as
| {
openModelConfig?: boolean
}
| undefined
)
?.openModelConfig &&
onOpenModelConfig && (
<button
type="button"
onClick={
onOpenModelConfig
}
className="mt-2 text-xs font-medium underline underline-offset-2 hover:opacity-80"
>
{
dict
.errors
.llm
.openModelSettings
}
</button>
)}
</div>
)
},
+169 -147
View File
@@ -1,7 +1,7 @@
"use client"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import { DefaultChatTransport, isToolUIPart, type UIMessage } from "ai"
import {
MessageSquarePlus,
PanelRightClose,
@@ -41,8 +41,13 @@ import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
import type { ValidationState } from "./chat/ValidationCard"
import { ChatMessageDisplay } from "./chat-message-display"
import {
APPENDED_FILE_SECTIONS_PATTERN,
ChatMessageDisplay,
} from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence
@@ -51,21 +56,6 @@ const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
// sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
// Type for message parts (tool calls and their states)
interface MessagePart {
type: string
state?: string
toolName?: string
input?: { xml?: string; [key: string]: unknown }
[key: string]: unknown
}
interface ChatMessage {
role: string
parts?: MessagePart[]
[key: string]: unknown
}
interface ChatPanelProps {
isVisible: boolean
onToggleVisibility: () => void
@@ -88,25 +78,25 @@ const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retr
* Check if auto-resubmit should happen based on tool errors.
* Only checks the LAST tool part (most recent tool call), not all tool parts.
*/
function hasToolErrors(messages: ChatMessage[]): boolean {
function hasToolErrors(messages: UIMessage[]): boolean {
const lastMessage = messages[messages.length - 1]
if (!lastMessage || lastMessage.role !== "assistant") {
return false
}
const toolParts =
(lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>
part.type?.startsWith("tool-"),
) || []
if (toolParts.length === 0) {
return false
}
const lastToolPart = toolParts[toolParts.length - 1]
if (lastMessage?.role !== "assistant") return false
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
return lastToolPart?.state === TOOL_ERROR_STATE
}
/**
* Snapshots keep the full multi-page document, but the model only sees and
* edits the first page, so give it the first page's mxGraphModel.
* Older snapshots already hold a single mxGraphModel and are returned as is.
*/
function getFirstPageXml(xml: string): string {
if (!xml.includes("<mxfile")) return xml
const doc = new DOMParser().parseFromString(xml, "text/xml")
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
}
export default function ChatPanel({
isVisible,
onToggleVisibility,
@@ -336,19 +326,8 @@ export default function ChatPanel({
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
}, [])
// Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null)
// Callback to improve diagram with validation suggestions
const handleImproveWithSuggestions = useCallback((feedback: string) => {
if (sendMessageRef.current) {
// Send the feedback as a new user message to trigger regeneration
sendMessageRef.current({
role: "user",
parts: [{ type: "text", text: feedback }],
})
}
}, [])
// Failed VLM validations in the current user turn (reset on user action)
const validationRetryCountRef = useRef(0)
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
@@ -357,6 +336,7 @@ export default function ChatPanel({
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
@@ -384,95 +364,87 @@ export default function ChatPanel({
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
// An edit still streaming when the request failed never reaches
// the tool handler: undo its preview. The first stored original
// is the diagram before any of them.
const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true)
editDiagramOriginalXmlRef.current.clear()
// Server errors are JSON: a quota limit ({type: request, token or
// tpm}), a provider error ({type: "provider", code, message}) or
// {error}. The SDK puts the response body in error.message.
let data: any = null
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
data = JSON.parse(error.message)
} catch {
// Not JSON, fall through to string matching for backwards compatibility
// Plain text, e.g. a network failure in the browser
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
if (data?.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
if (data?.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
if (data?.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
const isAccessCodeError = String(
data?.error ?? error.message,
).includes("Invalid or missing access code")
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
if (!isAccessCodeError) console.error("Chat error:", error)
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url")
) {
friendlyMessage = "This model doesn't support image input."
// A hint the user can act on, then the provider's own words
let text: string = error.message
let openModelConfig = false
if (data?.type === "provider") {
const hints = dict.errors.llm as Record<string, string>
text = hints[data.code]
? `${hints[data.code]}\n\n${data.message}`
: data.message
openModelConfig = [
"invalid_api_key",
"forbidden",
"model_not_found",
].includes(data.code)
} else if (typeof data?.error === "string") {
text = data.error
} else if (error.message === "Failed to fetch") {
text = dict.errors.networkError
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
setMessages((currentMessages) => [
...currentMessages,
{
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
}
return [...currentMessages, errorMessage]
})
content: text,
parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings
...(openModelConfig && {
metadata: { openModelConfig: true },
}),
},
])
if (error.message.includes("Invalid or missing access code")) {
if (isAccessCodeError) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: () => {},
// Re-render streamed messages at most every 150 ms. The streaming
// diagram preview draws on each update, so this also limits redraws
experimental_throttle: 150,
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
const shouldRetry = hasToolErrors(messages)
if (!shouldRetry) {
// No error, reset retry count and clear state
@@ -518,11 +490,6 @@ export default function ChatPanel({
},
})
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => {
sendMessageRef.current = sendMessage
}, [sendMessage])
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
useEffect(() => {
@@ -531,6 +498,9 @@ export default function ChatPanel({
// Track last synced session ID to detect external changes (e.g., URL back/forward)
const lastSyncedSessionIdRef = useRef<string | null>(null)
// Messages array from our latest save. A session holding this exact array was
// created by our own save, so it must not be treated as an external switch.
const lastSavedMessagesRef = useRef<unknown[] | null>(null)
// Helper: Sync UI state with session data (eliminates duplication)
// Track message IDs that are being loaded from session (to skip animations/scroll)
@@ -597,8 +567,10 @@ export default function ChatPanel({
thumbnailDataUrl = latestSvgRef.current
}
}
const messages = sanitizeMessages(messagesRef.current)
lastSavedMessagesRef.current = messages
return {
messages: sanitizeMessages(messagesRef.current),
messages,
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
diagramXml: currentDiagramXml,
thumbnailDataUrl,
@@ -651,8 +623,13 @@ export default function ChatPanel({
// Skip if session ID hasn't changed (our own saves don't change the ID)
if (newSessionId === lastSyncedSessionIdRef.current) return
// Our own save created this session; the UI already shows its content
const isOwnNewSession =
newSession?.messages === lastSavedMessagesRef.current
// Update last synced ID
lastSyncedSessionIdRef.current = newSessionId
if (isOwnNewSession) return
// Sync UI with new session
if (newSession) {
@@ -793,16 +770,25 @@ export default function ChatPanel({
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"
if (input.trim() && !isProcessing) {
// Check if input matches a cached example (only when no messages yet)
if (messages.length === 0) {
// Attachments still extracting have no text yet. Template sends call
// requestSubmit() and skip the disabled send button, so check here too.
const isExtracting =
files.some((f) => pdfData.get(f)?.isExtracting) ||
Array.from(urlData.values()).some((d) => d.isExtracting)
if (input.trim() && !isProcessing && !isExtracting) {
// Check if input matches a cached example (only when no messages
// yet and the canvas is empty, same rule as the server)
if (messages.length === 0 && !hasCells(chartXMLRef.current || "")) {
// Pass the file name so a user's own file never matches an example
const cached = findCachedResponse(
input.trim(),
files.length > 0,
files.length === 1 ? files[0].name : undefined,
)
if (cached) {
// Add user message and fake assistant response to messages
// The chat-message-display useEffect will handle displaying the diagram
// Add the user message and a finished display_diagram
// answer, and load its diagram here: these messages never
// reach the tool handler
const toolCallId = `cached-${Date.now()}`
// Build user message text including any file content
@@ -834,6 +820,16 @@ export default function ChatPanel({
],
},
] as any)
// Snapshot the canvas before the example so editing this message works
xmlSnapshotsRef.current.set(
0,
chartXMLRef.current || BLANK_MXFILE,
)
const prepared = prepareNewDiagram(cached.xml, {
pageId: "page-1",
pageName: "Page-1",
})
if (prepared.ok) onDisplayChart(prepared.xml, true)
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
@@ -843,9 +839,6 @@ export default function ChatPanel({
}
try {
let chartXml = await onFetchChart()
chartXml = formatXML(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[] = []
@@ -860,32 +853,45 @@ export default function ChatPanel({
// Add the combined text as the first part
parts.unshift({ type: "text", text: userText })
// Get previous XML from the last snapshot (before this message)
const snapshotKeys = Array.from(
xmlSnapshotsRef.current.keys(),
).sort((a, b) => b - a)
const previousXml =
snapshotKeys.length > 0
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
: ""
await sendWithCurrentDiagram(parts)
// Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml)
sendChatMessage(parts, chartXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
} catch (error) {
console.error("Error fetching chart data:", error)
toast.error(dict.errors.failedToExport)
}
}
}
// Export the current diagram, snapshot it for this message, and send
const sendWithCurrentDiagram = async (parts: any[]) => {
const chartXml = formatXML(await onFetchChart())
const previousXml = getPreviousXml(messages.length)
// Snapshot the full multi-page document (kept fresh by autosave) so
// regenerate/edit can restore every page; the model gets page 1 only
xmlSnapshotsRef.current.set(
messages.length,
chartXMLRef.current || chartXml,
)
sendChatMessage(parts, chartXml, previousXml, sessionId)
}
// Send VLM validation feedback as a new user message through the normal send path
const handleImproveWithSuggestions = async (feedback: string) => {
if (status === "streaming" || status === "submitted") return
try {
await sendWithCurrentDiagram([{ type: "text", text: feedback }])
} catch (error) {
console.error("Error fetching chart data:", error)
toast.error(dict.errors.failedToExport)
}
}
// Handle session switching from history dropdown
const handleSelectSession = useCallback(
async (sessionId: string) => {
@@ -989,10 +995,9 @@ export default function ChatPanel({
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
// Keep attachments: they are sent along with the template prompt
flushSync(() => {
setInput(template.prompt)
setFiles([])
setUrlData(new Map())
})
const formElement = document.getElementById(
@@ -1002,7 +1007,7 @@ export default function ChatPanel({
formElement.requestSubmit()
}
},
[setInput, setFiles, setUrlData],
[setInput],
)
const handleInputChange = (
@@ -1017,13 +1022,15 @@ export default function ChatPanel({
}
// Helper functions for message actions (regenerate/edit)
// Extract previous XML snapshot before a given message index
// Extract previous XML snapshot (first page, as sent to the model) before a given message index
const getPreviousXml = (beforeIndex: number): string => {
const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys())
.filter((k) => k < beforeIndex)
.sort((a, b) => b - a)
return snapshotKeys.length > 0
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
? getFirstPageXml(
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
)
: ""
}
@@ -1075,6 +1082,7 @@ export default function ChatPanel({
// Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
validationRetryCountRef.current = 0
partialXmlRef.current = ""
const config = getSelectedAIConfig()
@@ -1223,7 +1231,12 @@ export default function ChatPanel({
})
// Now send the message after state is guaranteed to be updated
sendChatMessage(userParts, savedXml, previousXml, sessionId)
sendChatMessage(
userParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
}
const handleEditMessage = async (messageIndex: number, newText: string) => {
@@ -1250,10 +1263,13 @@ export default function ChatPanel({
// Clean up snapshots for messages after the user message (they will be removed)
cleanupSnapshotsAfter(messageIndex)
// Create new parts with updated text
// Create new parts with updated text. The edit box only shows the typed
// text, so keep the appended PDF/file/URL content
const newParts = message.parts?.map((part: any) => {
if (part.type === "text") {
return { ...part, text: newText }
const appended =
part.text.match(APPENDED_FILE_SECTIONS_PATTERN)?.[0] ?? ""
return { ...part, text: newText + appended }
}
return part
}) || [{ type: "text", text: newText }]
@@ -1266,7 +1282,12 @@ export default function ChatPanel({
})
// Now send the edited message after state is guaranteed to be updated
sendChatMessage(newParts, savedXml, previousXml, sessionId)
sendChatMessage(
newParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
}
// Collapsed view (desktop only)
@@ -1395,6 +1416,7 @@ export default function ChatPanel({
{/* Messages */}
<main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay
onOpenModelConfig={() => setShowModelConfigDialog(true)}
messages={messages}
setInput={setInput}
setFiles={handleFileChange}
+2
View File
@@ -194,6 +194,8 @@ export function ChatLobby({
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
+3
View File
@@ -55,6 +55,9 @@ export function TemplateCreateDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
e.stopPropagation()
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
+6 -3
View File
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Populate form when template changes
// Populate form each time the dialog opens, dropping any cancelled edits
useEffect(() => {
if (template) {
if (open && template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [template])
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
@@ -59,6 +59,9 @@ export function TemplateEditDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// any form this dialog may be rendered in
e.stopPropagation()
if (!template) return
+44 -19
View File
@@ -110,6 +110,10 @@ export function TemplatePanel({
useEffect(() => {
loadTemplates()
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates])
const handleCreateSuccess = () => {
@@ -220,7 +224,8 @@ export function TemplatePanel({
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
// Local date as YYYY-MM-DD (toISOString would give UTC)
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -302,6 +307,28 @@ export function TemplatePanel({
}
}
// Shared by the empty state and the list, so import works in both
const importInput = (
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
)
const importMessageBox = importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)
// Empty state: no templates at all
if (!loading && templates.length === 0) {
return (
@@ -332,6 +359,18 @@ export function TemplatePanel({
<Plus className="w-4 h-4" />
{dict.templates.createFirst}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
{importMessageBox && (
<div className="mt-3">{importMessageBox}</div>
)}
<TemplateCreateDialog
open={createDialogOpen}
@@ -389,27 +428,11 @@ export function TemplatePanel({
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
{importInput}
</div>
{/* Import message */}
{importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)}
{importMessageBox}
<div className="space-y-2">
{loading
@@ -447,6 +470,8 @@ export function TemplatePanel({
handleTemplateClick(template)
}
onKeyDown={(e) => {
// Ignore keys bubbling up from the action buttons
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
+28 -34
View File
@@ -66,7 +66,7 @@ export function ToolCallCard({
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
const { state, input, output, errorText } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
@@ -92,6 +92,14 @@ export function ToolCallCard({
}
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
const handleCopy = () => {
let textToCopy = ""
@@ -161,22 +169,15 @@ export function ToolCallCard({
</>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
(isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
))}
{input && Object.keys(input).length > 0 && (
<button
type="button"
@@ -224,23 +225,16 @@ export function ToolCallCard({
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* AI SDK stores tool errors in errorText */}
{state === "output-error" && (errorText || output) && (
<div
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
: (errorText ?? output)}
</div>
)}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
+4 -5
View File
@@ -1,8 +1,6 @@
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation }
export interface ToolPartLike {
type: string
@@ -13,4 +11,5 @@ export interface ToolPartLike {
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
errorText?: string
}
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { wrapWithMxFile } from "@/lib/utils"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
// Dev XML presets for streaming simulator
const DEV_XML_PRESETS: Record<string, string> = {
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
})
// Display the final diagram
const fullXml = wrapWithMxFile(xml)
onDisplayChart(fullXml)
const prepared = prepareNewDiagram(xml)
if (prepared.ok) onDisplayChart(prepared.xml)
setIsSimulating(false)
}
+414 -200
View File
@@ -4,12 +4,12 @@ import {
AlertCircle,
Check,
ChevronRight,
Clock,
Eye,
EyeOff,
Key,
Loader2,
Plus,
RefreshCw,
Server,
Settings2,
Sparkles,
@@ -34,6 +34,13 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
@@ -43,6 +50,11 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
@@ -56,7 +68,13 @@ 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 type { ListedModel } from "@/lib/provider-models"
import { STORAGE_KEYS } from "@/lib/storage"
import type {
ModelConfig,
ProviderConfig,
ProviderName,
} from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
@@ -125,22 +143,34 @@ export function ModelConfigDialog({
> | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState("")
const [validatingModelIndex, setValidatingModelIndex] = useState<
number | null
>(null)
// Models whose test is running (they are all tested at once)
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
() => new Set(),
)
const [duplicateError, setDuplicateError] = useState<string>("")
const [editError, setEditError] = useState<{
modelId: string
message: string
} | null>(null)
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
Partial<Record<ProviderName, string[]>>
// Model ID being typed; written to the config only when valid on blur
const [modelIdDraft, setModelIdDraft] = useState<{
id: string
value: string
} | null>(null)
// Bumped on every credential edit so a running test can tell that its
// results belong to the old credentials
const credentialsVersionRef = useRef(0)
// Models fetched from the provider, per provider config
const [fetchedModels, setFetchedModels] = useState<
Record<string, ListedModel[]>
>({})
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
Partial<Record<ProviderName, boolean>>
>({})
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
useState<ProviderName | null>(null)
const [fetchingModels, setFetchingModels] = useState(false)
const [fetchModelsError, setFetchModelsError] = useState("")
const [modelPickerOpen, setModelPickerOpen] = useState(false)
// models.dev data for hints, loaded with the dialog (it is ~180 KB)
const [getModelInfo, setGetModelInfo] = useState<
typeof import("@/lib/model-catalog").getModelInfo | null
>(null)
const {
config,
@@ -157,6 +187,11 @@ export function ModelConfigDialog({
(p) => p.id === selectedProviderId,
)
// Discard an unfinished model ID edit when the dialog closes
useEffect(() => {
if (!open) setModelIdDraft(null)
}, [open])
// Cleanup validation reset timeout on unmount
useEffect(() => {
return () => {
@@ -167,73 +202,74 @@ export function ModelConfigDialog({
}, [])
useEffect(() => {
if (
!open ||
selectedProvider?.provider !== "aihubmix" ||
loadedSuggestedProviders.aihubmix
) {
return
}
if (!open || getModelInfo) return
import("@/lib/model-catalog").then((catalog) =>
setGetModelInfo(() => catalog.getModelInfo),
)
}, [open, getModelInfo])
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) => ({
const handleFetchModels = async () => {
if (!selectedProvider) return
const providerId = selectedProvider.id
setFetchingModels(true)
setFetchModelsError("")
try {
const response = await fetch(
getApiEndpoint("/api/provider-models"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl: selectedProvider.baseUrl,
}),
},
)
const data = await response.json().catch(() => ({}))
if (Array.isArray(data.models)) {
setFetchedModels((current) => ({
...current,
aihubmix: true,
[providerId]: data.models,
}))
setLoadingSuggestedProvider(null)
})
return () => {
cancelled = true
setModelPickerOpen(true)
} else {
const hints = dict.errors.llm as Record<string, string>
setFetchModelsError(
[hints[data.code], data.error].filter(Boolean).join(" ") ||
`Request failed (${response.status})`,
)
}
} catch {
setFetchModelsError(dict.errors.networkError)
} finally {
setFetchingModels(false)
}
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
}
// Get suggested models for current provider
const suggestedModels = selectedProvider
? dynamicSuggestedModels[selectedProvider.provider] ||
SUGGESTED_MODELS[selectedProvider.provider] ||
[]
// The provider's own list once fetched, else the suggested models
const suggestedModels: ListedModel[] = selectedProvider
? fetchedModels[selectedProvider.id] ||
(SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
id,
}))
: []
const isLoadingSuggestedModels =
selectedProvider?.provider === loadingSuggestedProvider
// Tool calls are what drawing needs: false when known to be missing
const supportsTools = (model: ListedModel) =>
selectedProvider
? (model.tools ??
getModelInfo?.(selectedProvider.provider, model.id)?.tools)
: undefined
// Filter out already-added models from suggestions
const existingModelIds =
selectedProvider?.models.map((m) => m.modelId) || []
const availableSuggestions = suggestedModels.filter(
(modelId) => !existingModelIds.includes(modelId),
(model) => !existingModelIds.includes(model.id),
)
const emptyStateSuggestions = selectedProvider
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
@@ -246,6 +282,8 @@ export function ModelConfigDialog({
const newProvider = addProvider(providerType)
setSelectedProviderId(newProvider.id)
setValidationStatus("idle")
setFetchModelsError("")
setModelPickerOpen(false)
}
// Handle provider field updates
@@ -253,9 +291,9 @@ export function ModelConfigDialog({
field: keyof ProviderConfig,
value: string | boolean,
) => {
if (!selectedProviderId) return
updateProvider(selectedProviderId, { [field]: value })
// Reset validation when credentials change
if (!selectedProviderId || !selectedProvider) return
const updates: Partial<ProviderConfig> = { [field]: value }
// Reset validation of the provider and its models when credentials change
const credentialFields = [
"apiKey",
"baseUrl",
@@ -265,9 +303,21 @@ export function ModelConfigDialog({
"vertexApiKey",
]
if (credentialFields.includes(field)) {
credentialsVersionRef.current++
setValidationStatus("idle")
updateProvider(selectedProviderId, { validated: false })
setValidatingModelIds(new Set())
setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest)
setFetchModelsError("")
updates.validated = false
updates.models = selectedProvider.models.map((m) => ({
...m,
validated: undefined,
validationError: undefined,
validationWarning: undefined,
responseTime: undefined,
}))
}
updateProvider(selectedProviderId, updates)
}
// Handle adding a model to current provider
@@ -337,60 +387,96 @@ export function ModelConfigDialog({
let allValid = true
let errorCount = 0
const credentialsVersion = credentialsVersionRef.current
// Validate each model
for (let i = 0; i < selectedProvider.models.length; i++) {
const model = selectedProvider.models[i]
setValidatingModelIndex(i)
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}${getApiEndpoint("/api/edgeai")}`
: selectedProvider.baseUrl
try {
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}/api/edgeai`
: selectedProvider.baseUrl
const response = await fetch("/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl,
modelId: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
})
const data = await response.json()
if (data.valid) {
updateModel(selectedProviderId, model.id, {
validated: true,
validationError: undefined,
})
} else {
// Test every model at once; each row updates when its answer arrives
setValidatingModelIds(new Set(selectedProvider.models.map((m) => m.id)))
await Promise.all(
selectedProvider.models.map(async (model) => {
let update: Partial<ModelConfig>
try {
const response = await fetch(
getApiEndpoint("/api/validate-model"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(
STORAGE_KEYS.accessCode,
) || "",
},
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl,
modelId: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey:
selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
},
)
const data = await response.json().catch(() => ({}))
update = data.valid
? {
validated: true,
validationError: undefined,
validationWarning: data.warning,
responseTime: data.responseTime,
}
: {
validated: false,
// The hint for the error's kind, then the
// provider's own message
validationError:
[
(
dict.errors.llm as Record<
string,
string
>
)[data.code],
data.error,
]
.filter(Boolean)
.join(" ") ||
(response.ok
? "Validation failed"
: `Request failed (${response.status})`),
validationWarning: undefined,
}
} catch {
update = {
validated: false,
validationError: "Network error",
validationWarning: undefined,
}
}
// Credentials changed during the test: drop the result
if (credentialsVersionRef.current !== credentialsVersion) return
if (update.validated === false) {
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
}
} catch {
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: "Network error",
updateModel(selectedProviderId, model.id, update)
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
}
}
setValidatingModelIndex(null)
}),
)
if (credentialsVersionRef.current !== credentialsVersion) return
if (allValid) {
setValidationStatus("success")
@@ -407,7 +493,13 @@ export function ModelConfigDialog({
setValidationStatus("error")
setValidationError(`${errorCount} model(s) failed validation`)
}
}, [selectedProvider, selectedProviderId, updateProvider, updateModel])
}, [
selectedProvider,
selectedProviderId,
updateProvider,
updateModel,
dict,
])
// Get all available provider types
const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[]
@@ -563,6 +655,10 @@ export function ModelConfigDialog({
)
setValidationStatus("idle")
setShowApiKey(false)
// These belong to the
// provider shown before
setFetchModelsError("")
setModelPickerOpen(false)
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
@@ -615,7 +711,9 @@ export function ModelConfigDialog({
{/* Add Provider */}
<div className="p-3 border-t border-border-subtle">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
onValueChange={(v) =>
handleAddProvider(v as ProviderName)
}
@@ -836,57 +934,136 @@ export function ModelConfigDialog({
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Select
onValueChange={(value) => {
if (value) {
handleAddModel(
value,
)
{PROVIDER_INFO[
selectedProvider.provider
].modelList && (
<Button
variant="outline"
size="sm"
className="h-8 rounded-lg"
onClick={
handleFetchModels
}
}}
disabled={
isLoadingSuggestedModels ||
availableSuggestions.length ===
0
}
>
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
{isLoadingSuggestedModels ? (
disabled={
fetchingModels
}
title={
dict.modelConfig
.fetchModels
}
aria-label={
dict.modelConfig
.fetchModels
}
>
{fetchingModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span className="text-xs">
{availableSuggestions.length ===
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
{/* modal: the dialog blocks the
wheel outside itself, and the
list is rendered outside it */}
<Popover
modal
open={modelPickerOpen}
onOpenChange={
setModelPickerOpen
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-28 h-8 rounded-lg text-xs"
disabled={
availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</span>
)}
</SelectTrigger>
<SelectContent className="max-h-72">
{availableSuggestions.map(
(modelId) => (
<SelectItem
key={
modelId
}
>
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80 p-0"
align="end"
>
<Command>
<CommandInput
placeholder={
dict
.modelConfig
.searchModels
}
/>
<CommandList className="max-h-72">
<CommandEmpty>
{
dict
.modelConfig
.noModelsFound
}
value={
modelId
}
className="font-mono text-xs"
>
{modelId}
</SelectItem>
),
)}
</SelectContent>
</Select>
</CommandEmpty>
{availableSuggestions.map(
(model) => (
<CommandItem
key={
model.id
}
value={
model.id
}
onSelect={() => {
handleAddModel(
model.id,
)
setModelPickerOpen(
false,
)
}}
className="font-mono text-xs"
>
<span className="truncate">
{
model.id
}
</span>
{supportsTools(
model,
) ===
false && (
<span className="ml-auto shrink-0 font-sans text-[10px] text-amber-600 dark:text-amber-400">
{
dict
.modelConfig
.noTools
}
</span>
)}
</CommandItem>
),
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
}
>
{fetchModelsError && (
<p className="mb-2 text-xs text-destructive">
{fetchModelsError}
</p>
)}
{/* Model List */}
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
{selectedProvider.models.length ===
@@ -940,7 +1117,7 @@ export function ModelConfigDialog({
) : (
<div className="divide-y divide-border-subtle">
{selectedProvider.models.map(
(model, index) => (
(model) => (
<div
key={model.id}
className={cn(
@@ -950,28 +1127,24 @@ export function ModelConfigDialog({
<div className="flex items-center gap-3 p-3 min-w-0">
{/* Status icon */}
<div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
{validatingModelIndex !==
null &&
index ===
validatingModelIndex ? (
{validatingModelIds.has(
model.id,
) ? (
// Currently validating
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
</div>
) : validatingModelIndex !==
null &&
index >
validatingModelIndex &&
model.validated ===
undefined ? (
// Queued
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
) : model.validated ===
true ? (
// Valid
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
// Valid, with the time the test took
<div
className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center"
title={
model.responseTime
? `${(model.responseTime / 1000).toFixed(1)} s`
: undefined
}
>
<Check className="h-4 w-4 text-success" />
</div>
) : model.validated ===
@@ -989,7 +1162,10 @@ export function ModelConfigDialog({
</div>
<Input
value={
model.modelId
modelIdDraft?.id ===
model.id
? modelIdDraft.value
: model.modelId
}
title={
model.modelId
@@ -1007,24 +1183,14 @@ export function ModelConfigDialog({
null,
)
}
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
setModelIdDraft(
{
id: model.id,
value: e
.target
.value,
},
)
}}
onKeyDown={(
e,
@@ -1041,6 +1207,10 @@ export function ModelConfigDialog({
) => {
const newModelId =
e.target.value.trim()
// Drop the draft; an invalid ID falls back to the saved one
setModelIdDraft(
null,
)
// Helper to show error with shake
const showError =
@@ -1135,6 +1305,28 @@ export function ModelConfigDialog({
setEditError(
null,
)
if (
selectedProviderId &&
newModelId !==
model.modelId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
newModelId,
validated:
undefined,
validationError:
undefined,
validationWarning:
undefined,
responseTime:
undefined,
},
)
}
}}
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
/>
@@ -1162,6 +1354,28 @@ export function ModelConfigDialog({
}
</p>
)}
{!model.validationWarning &&
getModelInfo?.(
selectedProvider.provider,
model.modelId,
)?.tools ===
false && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
{
dict
.modelConfig
.mayNotDraw
}
</p>
)}
{model.validated &&
model.validationWarning && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
{
model.validationWarning
}
</p>
)}
{/* Show edit error inline */}
{editError?.modelId ===
model.id && (
+12 -6
View File
@@ -264,9 +264,13 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
@@ -351,9 +355,11 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
+45 -11
View File
@@ -13,7 +13,12 @@ import {
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
import {
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
@@ -77,6 +82,7 @@ export function ProviderCredentialsFields({
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
@@ -208,17 +214,29 @@ export function ProviderCredentialsFields({
</>
) : (
<>
{/* API Key */}
{/* API Key, with a link to where the provider issues keys */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
@@ -238,12 +256,28 @@ export function ProviderCredentialsFields({
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(
e.target.value,
)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
+87 -85
View File
@@ -1,16 +1,17 @@
"use client"
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import {
extractDiagramXML,
isRealDiagram,
validateAndFixXml,
} from "../lib/utils"
BLANK_MXFILE,
normalizeToMxfile,
} from "@/packages/mcp-server/src/pages.ts"
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
interface DiagramContextType {
chartXML: string
@@ -42,6 +43,12 @@ interface DiagramContextType {
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Exports for thumbnails, validation PNGs and file saves carry a tag in the
// request's `message` field. draw.io echoes the request back in the export
// event, so each result reaches its own caller; untagged exports (chat-panel's
// onFetchChart) resolve resolverRef.
type ExportTag = "thumbnail" | "validation"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("")
@@ -53,8 +60,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Resolver for PNG export (used for VLM validation)
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Pending thumbnail and validation PNG exports, keyed by their export tag
const taggedResolversRef = useRef<
Partial<Record<ExportTag, (value: string) => void>>
>({})
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
@@ -76,10 +85,12 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Keep chartXMLRef in sync with state for restoration after remount
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Update chartXML and its ref together, so callbacks that read the ref
// (export handler, autosave) see the new value right away
const updateChartXML = (xml: string) => {
chartXMLRef.current = xml
setChartXML(xml)
}
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
@@ -106,64 +117,52 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Export with a tag in `message` (draw.io echoes it back in the export
// event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally.
const requestTaggedExport = (
tag: ExportTag,
format: "xmlsvg" | "png",
timeoutMs: number,
) =>
new Promise<string | null>((resolve) => {
const finish = (value: string | null) => {
clearTimeout(timer)
if (taggedResolversRef.current[tag] === finish) {
delete taggedResolversRef.current[tag]
}
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
taggedResolversRef.current[tag] = finish
drawioRef.current?.exportDiagram({ format, message: tag })
})
// Get current diagram as SVG for thumbnail (used by session storage)
const getThumbnailSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
try {
const svgData = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 3000),
),
])
if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves
if (svgData?.includes("<svg")) {
setLatestSvg(svgData)
return svgData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
setLatestSvg(svgData)
return svgData
}
return null
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXML)) return null
if (!isRealDiagram(chartXMLRef.current)) return null
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
const pngData = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL
return pngData?.startsWith("data:image/png") ? pngData : null
}
const loadDiagram = (
@@ -172,9 +171,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
): string | null => {
let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal use)
// Validate XML structure before loading (unless skipped for internal
// use). Not strict: the XML may hold the user's own diagram, and the
// tool handlers check model XML strictly before it gets here.
if (!skipValidation) {
const validation = validateAndFixXml(chart)
const validation = validateAndFixXml(chart, { strict: false })
if (!validation.valid) {
console.warn(
"[loadDiagram] Validation error:",
@@ -193,7 +194,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
setChartXML(xmlToLoad)
updateChartXML(xmlToLoad)
if (drawioRef.current) {
drawioRef.current.load({
@@ -205,24 +206,17 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
const handleDiagramExport = (data: EventExport) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
// Tagged exports (thumbnail, validation PNG, file save) go only to
// their own caller, so they never take the result meant for resolverRef
const tag = data.message?.message
if (tag === "thumbnail" || tag === "validation") {
taggedResolversRef.current[tag]?.(data.data)
return
}
// 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, data.xml)
if (tag === "save") {
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
// xmlsvg is saved directly as SVG file, no need for extraction
if (format === "png" || format === "svg" || format === "xmlsvg") {
return
}
return
}
// Don't write chartXML here: exports don't change the diagram, and
@@ -236,12 +230,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20
if (expectHistoryExportRef.current) {
// Store the full multi-page document (extractedXML is only the
// first page), so restoring a version keeps every page
const historyXml = chartXMLRef.current || extractedXML
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: extractedXML,
xml: historyXml,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
@@ -256,18 +253,20 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// react-drawio registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
// Don't overwrite a pending restore - if we have a real diagram but
// DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
return
}
setChartXML(data.xml)
updateChartXML(data.xml)
}
const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const emptyDiagram = BLANK_MXFILE
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true)
setLatestSvg("")
@@ -301,11 +300,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
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>`
}
fileContent = xmlContent
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
@@ -365,7 +364,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({ format: drawioFormat })
drawioRef.current.exportDiagram({
format: drawioFormat,
message: "save",
})
}
// Log save event to Langfuse (just flags the trace, doesn't send content)
+7
View File
@@ -124,6 +124,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
图表会实时显示在浏览器中!
MCP服务器包含网页版的大部分画图功能:
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
- 截图工具,AI可以查看画好的图并自行修正
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
## 快速开始
+3 -1
View File
@@ -149,9 +149,11 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
AI_MODEL=global.anthropic.claude-sonnet-5-5
```
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
### OpenRouter
+3 -1
View File
@@ -164,9 +164,11 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
AI_MODEL=global.anthropic.claude-sonnet-5-5
```
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter
+7
View File
@@ -122,6 +122,13 @@ Claudeにダイアグラムの作成を依頼:
ダイアグラムがリアルタイムでブラウザに表示されます!
MCPサーバーには、Webアプリの主な作図機能が含まれています:
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
- スクリーンショットツール:AIが描画結果を確認して修正できます
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
## はじめに
+3 -1
View File
@@ -149,9 +149,11 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
AI_MODEL=global.anthropic.claude-sonnet-5-5
```
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
### OpenRouter
+2 -5
View File
@@ -11,7 +11,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure
@@ -20,7 +19,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|---------|--------|--------|-------------|------|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
@@ -30,7 +28,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams
@@ -48,6 +45,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
## Enterprise Software
@@ -73,6 +71,5 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
**Total: 33 libraries, 4,281 shapes**
**Total: 30 libraries, 4,184 shapes**
+43 -23
View File
@@ -67,41 +67,62 @@ const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
/**
* Create standardized response with CORS headers
* Create standardized JSON response
*/
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
})
}
/**
* Handle OPTIONS request for CORS preflight
*/
function handleOptionsRequest(): Response {
return new Response(null, {
headers: {
...CORS_HEADERS,
"Access-Control-Max-Age": "86400",
},
})
// Only the app's own server (/api/chat, /api/validate-model) calls this
// function, so no CORS headers are sent: other sites' pages can't call it
// from a browser and spend the deployment's Edge AI quota.
// Same rule as lib/access-code.ts, but reading the edge function's env.
// No codes configured (or env unavailable) means no check.
function hasValidAccessCode(request: Request, env: any): boolean {
const accessCodes: string[] =
env?.ACCESS_CODE_LIST?.split(",")
.map((code: string) => code.trim())
.filter(Boolean) || []
if (accessCodes.length === 0) return true
const accessCode = request.headers.get("x-access-code")
return !!accessCode && accessCodes.includes(accessCode)
}
export async function onRequest({ request, env: _env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
export async function onRequest({ request, env }: any) {
// Requiring JSON also makes any cross-site browser request need a CORS
// preflight, which fails without CORS headers
if (
request.method !== "POST" ||
!request.headers.get("content-type")?.includes("application/json")
) {
return createResponse(
{
error: {
message: "Expected a POST request with a JSON body",
type: "invalid_request_error",
},
},
400,
)
}
if (!hasValidAccessCode(request, env)) {
return createResponse(
{
error: {
message: "Invalid or missing access code",
type: "invalid_request_error",
},
},
401,
)
}
request.headers.delete("accept-encoding")
@@ -153,7 +174,7 @@ export async function onRequest({ request, env: _env }: any) {
type: "invalid_request_error",
},
},
429,
400,
)
}
@@ -216,7 +237,6 @@ export async function onRequest({ request, env: _env }: any) {
"Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
...CORS_HEADERS,
},
})
} catch (error: any) {
-4
View File
@@ -60,10 +60,6 @@ declare global {
maximize: () => void
/** Close the window */
close: () => void
/** Open file dialog and return file path */
openFile: () => Promise<string | null>
/** Save data to file via save dialog */
saveFile: (data: string) => Promise<boolean>
/** Get proxy configuration */
getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */
+57 -26
View File
@@ -32,6 +32,55 @@ export function rebuildAppMenu(): void {
buildAppMenu()
}
/**
* Apply a preset and restart the server so it takes effect.
* If the restart fails, go back to the previous preset and restart again,
* so the running server always matches the saved current preset.
* Throws an error describing the outcome on failure.
*/
export async function switchPreset(
id: string,
): Promise<Record<string, string>> {
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(id)
if (!env) {
throw new Error("Preset not found")
}
rebuildAppMenu()
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
if (!app.isPackaged) {
return env
}
try {
await restartNextServer()
return env
} catch (error) {
console.error("Failed to restart server:", error)
const reason = error instanceof Error ? error.message : String(error)
// Revert to previous preset on failure
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
try {
await restartNextServer()
} catch (retryError) {
console.error("Failed to restart server again:", retryError)
throw new Error(
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
)
}
throw new Error(
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
)
}
}
/**
* Get the menu template with translations
*/
@@ -192,32 +241,14 @@ function buildConfigMenu(
type: "radio",
checked: preset.id === currentPresetId,
click: async () => {
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(preset.id)
if (env) {
try {
await restartNextServer()
rebuildAppMenu() // Rebuild menu to update checkmarks
} catch (error) {
console.error("Failed to restart server:", error)
// Revert to previous preset on failure
if (previousPresetId) {
applyPresetToEnv(previousPresetId)
} else {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
// Show error dialog to notify user
dialog.showErrorBox(
"Configuration Error",
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
)
}
try {
await switchPreset(preset.id)
} catch (error) {
// Show error dialog to notify user
dialog.showErrorBox(
"Configuration Error",
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
)
}
},
}))
+123 -69
View File
@@ -1,5 +1,11 @@
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs"
import path from "node:path"
import { app, safeStorage } from "electron"
@@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false
* Warns if encryption is not available (API key stored in plaintext)
*/
function encryptValue(value: string): string {
if (!value) {
// Already encrypted (a value that could not be decrypted): keep it as is
// instead of wrapping it in a second layer of encryption
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
return value
}
@@ -61,6 +69,7 @@ function encryptValue(value: string): string {
/**
* Decrypt a sensitive value using safeStorage
* Returns the original value if it's not encrypted or decryption fails
* (so saving writes the stored ciphertext back unchanged)
*/
function decryptValue(value: string): string {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
@@ -179,6 +188,15 @@ export function loadPresets(): ConfigPresetsFile {
return data
} catch (error) {
console.error("Failed to load config presets:", error)
// Move the unreadable file aside so the next save can't overwrite
// the user's presets with an empty list
const backupPath = `${configPath}.corrupt-${Date.now()}`
try {
renameSync(configPath, backupPath)
console.error(`Unreadable config presets moved to ${backupPath}`)
} catch (renameError) {
console.error("Failed to back up config presets:", renameError)
}
return {
version: 1,
currentPresetId: null,
@@ -211,7 +229,11 @@ export function savePresets(data: ConfigPresetsFile): void {
}
try {
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
// Write a temp file and rename it, so a crash mid-write can't leave
// a truncated config file
const tempPath = `${configPath}.tmp`
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
renameSync(tempPath, configPath)
} catch (error) {
console.error("Failed to save config presets:", error)
throw error
@@ -307,9 +329,10 @@ export function deletePreset(id: string): boolean {
data.presets.splice(index, 1)
// Clear current preset if it was deleted
// Clear current preset (and its env vars) if it was deleted
if (data.currentPresetId === id) {
data.currentPresetId = null
setPresetEnv(null)
}
savePresets(data)
@@ -322,13 +345,15 @@ export function deletePreset(id: string): boolean {
export function setCurrentPreset(id: string | null): boolean {
const data = loadPresets()
let preset: ConfigPreset | null = null
if (id !== null) {
const preset = data.presets.find((p) => p.id === id)
preset = data.presets.find((p) => p.id === id) || null
if (!preset) {
return false
}
}
setPresetEnv(preset)
data.currentPresetId = id
savePresets(data)
return true
@@ -365,78 +390,23 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// Map generic AI_API_KEY to provider-specific key
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
process.env[providerApiKey] = value
appliedEnv[providerApiKey] = value
}
}
// Map generic AI_BASE_URL to provider-specific key
else if (
key === "AI_BASE_URL" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
if (providerBaseUrl) {
process.env[providerBaseUrl] = value
appliedEnv[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
process.env[key] = value
appliedEnv[key] = value
}
}
}
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Get environment variables from current preset
* Map a preset's config to environment variables
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
*/
export function getCurrentPresetEnv(): Record<string, string> {
const preset = getCurrentPreset()
if (!preset) {
return {}
}
function presetToEnv(preset: ConfigPreset): Record<string, string> {
const env: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// A key that could not be decrypted is useless to the server
if (value.startsWith(ENCRYPTED_PREFIX)) {
console.warn(
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
)
}
// Map generic AI_API_KEY to provider-specific key
if (
else if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
@@ -466,6 +436,90 @@ export function getCurrentPresetEnv(): Record<string, string> {
return env
}
/**
* Values that env vars had before a preset first set them
* (from the system or .env files), and the keys the active preset set
*/
const originalEnv: Record<string, string | undefined> = {}
let presetEnvKeys: string[] = []
/**
* Replace the env vars of the previous preset with those of the given preset
* (null leaves no preset applied). Restoring first means switching presets
* never leaves the previous preset's base URL, model or key behind.
*/
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
for (const key of presetEnvKeys) {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
}
const env = preset ? presetToEnv(preset) : {}
for (const [key, value] of Object.entries(env)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key]
}
process.env[key] = value
}
presetEnvKeys = Object.keys(env)
writeDevPresetEnv(env)
return env
}
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
/**
* Development only: write the active preset's env vars (decrypted and mapped)
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
* this file changes. The dev server can't decrypt the config file itself.
*/
function writeDevPresetEnv(env: Record<string, string>): void {
if (app.isPackaged) {
return
}
try {
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
writeFileSync(filePath, JSON.stringify(env, null, 2), {
encoding: "utf-8",
mode: 0o600,
})
} catch (error) {
console.error("Failed to write dev preset env:", error)
}
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv = setPresetEnv(preset)
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Apply the saved current preset's environment variables (used at startup)
*/
export function applyCurrentPresetToEnv(): void {
setPresetEnv(getCurrentPreset())
}
/**
* Get user's preferred locale from config
* Returns undefined if not set
+10 -6
View File
@@ -48,12 +48,16 @@ function loadEnvFromFile(filePath: string): void {
const key = trimmed.slice(0, equalIndex).trim()
let value = trimmed.slice(equalIndex + 1).trim()
// Remove surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
const quote = value[0]
const closingQuote =
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
if (closingQuote > 0) {
// Quoted value: keep what's inside the quotes and drop
// anything after them (e.g. a comment)
value = value.slice(1, closingQuote)
} else {
// Unquoted value: drop an inline comment ("value # comment")
value = value.replace(/\s+#.*$/, "")
}
// Don't override existing environment variables
+48 -20
View File
@@ -1,12 +1,17 @@
import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu"
import { getCurrentPresetEnv } from "./config-manager"
import { applyCurrentPresetToEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window"
import { createWindow, getMainWindow } from "./window-manager"
import {
createWindow,
getAppUrl,
getMainWindow,
isAppUrl,
} from "./window-manager"
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock()
@@ -28,16 +33,14 @@ if (!gotTheLock) {
// Apply proxy settings from saved config
applyProxyToEnv()
// Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) {
process.env[key] = value
}
const isDev = process.env.NODE_ENV === "development"
let serverUrl: string | null = null
const isDev = !app.isPackaged
app.whenReady().then(async () => {
// Apply saved preset environment variables (overrides .env).
// Must run after ready: on Windows and Linux safeStorage can't
// decrypt the API key before that.
applyCurrentPresetToEnv()
// Register IPC handlers
registerIpcHandlers()
registerSettingsWindowHandlers()
@@ -46,6 +49,7 @@ if (!gotTheLock) {
buildAppMenu()
try {
let serverUrl: string
if (isDev) {
// Development: use the dev server URL
serverUrl =
@@ -69,8 +73,9 @@ if (!gotTheLock) {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
if (serverUrl) {
createWindow(serverUrl)
const appUrl = getAppUrl()
if (appUrl) {
createWindow(appUrl)
}
}
})
@@ -87,24 +92,47 @@ if (!gotTheLock) {
stopNextServer()
})
// Pages allowed inside app windows: the app server and draw.io
const isInAppUrl = (url: string): boolean => {
if (isAppUrl(url)) return true
try {
const { hostname } = new URL(url)
return ["diagrams.net", "draw.io"].some(
(domain) =>
hostname === domain || hostname.endsWith(`.${domain}`),
)
} catch {
return false
}
}
const isWebUrl = (url: string): boolean =>
url.startsWith("http://") || url.startsWith("https://")
// Open external links in default browser
app.on("web-contents-created", (_, contents) => {
contents.setWindowOpenHandler(({ url }) => {
// Allow diagrams.net iframe
if (
url.includes("diagrams.net") ||
url.includes("draw.io") ||
url.startsWith("http://localhost") ||
url.startsWith("http://127.0.0.1")
) {
if (isInAppUrl(url)) {
return { action: "allow" }
}
// Open other links in external browser
if (url.startsWith("http://") || url.startsWith("https://")) {
if (isWebUrl(url)) {
shell.openExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
// Clicking a plain link would otherwise replace the app page with
// an external site that keeps the preload API
contents.on("will-navigate", (event) => {
if (isInAppUrl(event.url)) {
return
}
event.preventDefault()
if (isWebUrl(event.url)) {
shell.openExternal(event.url)
}
})
})
}
+80 -100
View File
@@ -1,7 +1,6 @@
import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { rebuildAppMenu } from "./app-menu"
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
import { rebuildAppMenu, switchPreset } from "./app-menu"
import {
applyPresetToEnv,
type ConfigPreset,
createPreset,
deletePreset,
@@ -20,6 +19,7 @@ import {
type ProxyConfig,
saveProxyConfig,
} from "./proxy-manager"
import { isAppUrl } from "./window-manager"
/**
* Allowed configuration keys for presets
@@ -48,13 +48,32 @@ function sanitizePresetConfig(
return sanitized
}
/**
* Register an IPC handler that only answers the app's own pages
* (the main window on the app server, or the local settings page).
* A main window that somehow ends up on an external site still gets the
* preload API, so its calls must be rejected here.
*/
function handle<Args extends unknown[]>(
channel: string,
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
): void {
ipcMain.handle(channel, (event, ...args) => {
const url = event.senderFrame?.url
if (!isAppUrl(url) && !url?.startsWith("file://")) {
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
}
return listener(event, ...(args as Args))
})
}
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
// ==================== App Info ====================
ipcMain.handle("get-version", () => {
handle("get-version", () => {
return app.getVersion()
})
@@ -79,84 +98,30 @@ export function registerIpcHandlers(): void {
win?.close()
})
// ==================== File Dialogs ====================
ipcMain.handle("dialog-open-file", async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ["openFile"],
filters: [
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
{ name: "All Files", extensions: ["*"] },
],
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
// Read the file content
const fs = await import("node:fs/promises")
try {
const content = await fs.readFile(result.filePaths[0], "utf-8")
return content
} catch (error) {
console.error("Failed to read file:", error)
return null
}
})
ipcMain.handle("dialog-save-file", async (event, data: string) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return false
const result = await dialog.showSaveDialog(win, {
filters: [
{ name: "Draw.io Files", extensions: ["drawio"] },
{ name: "XML Files", extensions: ["xml"] },
],
})
if (result.canceled || !result.filePath) {
return false
}
const fs = await import("node:fs/promises")
try {
await fs.writeFile(result.filePath, data, "utf-8")
return true
} catch (error) {
console.error("Failed to save file:", error)
return false
}
})
// ==================== Config Presets ====================
ipcMain.handle("config-presets:get-all", () => {
handle("config-presets:get-all", () => {
return getAllPresets()
})
ipcMain.handle("config-presets:get-current", () => {
handle("config-presets:get-current", () => {
return getCurrentPreset()
})
ipcMain.handle("config-presets:get-current-id", () => {
handle("config-presets:get-current-id", () => {
return getCurrentPresetId()
})
ipcMain.handle(
handle(
"config-presets:save",
(
async (
_event,
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
id?: string
},
) => {
// Validate preset name
if (typeof preset.name !== "string" || !preset.name.trim()) {
if (typeof preset?.name !== "string" || !preset.name.trim()) {
throw new Error("Invalid preset name")
}
@@ -165,42 +130,48 @@ export function registerIpcHandlers(): void {
if (preset.id) {
// Update existing preset
return updatePreset(preset.id, {
const updated = updatePreset(preset.id, {
name: preset.name.trim(),
config: sanitizedConfig,
})
// Re-apply the active preset so the edit takes effect
if (updated && updated.id === getCurrentPresetId()) {
await switchPreset(updated.id)
} else {
rebuildAppMenu()
}
return updated
}
// Create new preset
return createPreset({
const created = createPreset({
name: preset.name.trim(),
config: sanitizedConfig,
})
rebuildAppMenu()
return created
},
)
ipcMain.handle("config-presets:delete", (_event, id: string) => {
return deletePreset(id)
handle("config-presets:delete", async (_event, id: string) => {
const wasCurrent = id === getCurrentPresetId()
// Deleting the active preset also clears its env vars
const deleted = deletePreset(id)
rebuildAppMenu()
// Restart so the server stops using the deleted preset
if (deleted && wasCurrent && app.isPackaged) {
await restartNextServer()
}
return deleted
})
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
const env = applyPresetToEnv(id)
if (!env) {
return { success: false, error: "Preset not found" }
}
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development mode, the config file change will trigger
// the file watcher in electron-dev.mjs to restart Next.js
// We just need to save the preset (already done in applyPresetToEnv)
return { success: true, env, devMode: true }
}
// Production mode: restart the Next.js server to apply new environment variables
handle("config-presets:apply", async (_event, id: string) => {
try {
await restartNextServer()
return { success: true, env }
const env = await switchPreset(id)
// In development mode, electron-dev.mjs restarts Next.js
return app.isPackaged
? { success: true, env }
: { success: true, env, devMode: true }
} catch (error) {
return {
success: false,
@@ -212,30 +183,39 @@ export function registerIpcHandlers(): void {
}
})
ipcMain.handle(
"config-presets:set-current",
(_event, id: string | null) => {
return setCurrentPreset(id)
},
)
handle("config-presets:set-current", (_event, id: string | null) => {
return setCurrentPreset(id)
})
// ==================== Proxy Settings ====================
ipcMain.handle("get-proxy", () => {
handle("get-proxy", () => {
return getProxyConfig()
})
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
handle("set-proxy", async (_event, config: ProxyConfig) => {
const isOptionalString = (value: unknown) =>
value === undefined || typeof value === "string"
if (
typeof config !== "object" ||
config === null ||
!isOptionalString(config.httpProxy) ||
!isOptionalString(config.httpsProxy)
) {
return { success: false, error: "Invalid proxy settings" }
}
try {
// Save config to file
saveProxyConfig(config)
saveProxyConfig({
httpProxy: config.httpProxy,
httpsProxy: config.httpsProxy,
})
// Apply to current process environment
applyProxyToEnv()
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
if (!app.isPackaged) {
// In development, env vars are already applied
// Next.js dev server may need manual restart
return { success: true, devMode: true }
@@ -257,11 +237,11 @@ export function registerIpcHandlers(): void {
// ==================== User Locale ====================
ipcMain.handle("get-user-locale", () => {
handle("get-user-locale", () => {
return getUserLocale()
})
ipcMain.handle("set-user-locale", (_event, locale: string) => {
handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" }
+71 -42
View File
@@ -6,10 +6,22 @@ import {
getAllocatedPort,
getServerUrl,
isPortAvailable,
saveServerPort,
} from "./port-manager"
import { setAppUrl } from "./window-manager"
let serverProcess: UtilityProcess | null = null
// Start and restart run one at a time, so overlapping calls (e.g. two quick
// preset switches) can't leave two servers running
let serverQueue: Promise<unknown> = Promise.resolve()
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
const result = serverQueue.then(task)
serverQueue = result.catch(() => {})
return result
}
/**
* Get the path to the standalone server resources
* In packaged app: resources/standalone
@@ -45,7 +57,11 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
* Start the Next.js standalone server using Electron's utilityProcess
* This API is designed for running Node.js code in the background
*/
export async function startNextServer(): Promise<string> {
export function startNextServer(): Promise<string> {
return runExclusive(startServer)
}
async function startServer(): Promise<string> {
const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js")
@@ -71,6 +87,13 @@ export async function startNextServer(): Promise<string> {
HOSTNAME: "127.0.0.1",
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1",
// The preset keys are the user's own, not a server's
NEXT_AI_DRAWIO_DESKTOP: "1",
}
// Keep requests to local model servers (e.g. Ollama) off the proxy
if (!process.env.NO_PROXY && !process.env.no_proxy) {
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
}
// Set cache directory to a writable location (user's app data folder)
@@ -96,28 +119,33 @@ export async function startNextServer(): Promise<string> {
// Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron
serverProcess = utilityProcess.fork(serverPath, [], {
const proc = utilityProcess.fork(serverPath, [], {
cwd: resourcePath,
env,
stdio: "pipe",
})
serverProcess = proc
serverProcess.stdout?.on("data", (data) => {
proc.stdout?.on("data", (data) => {
console.log(`[Next.js] ${data.toString().trim()}`)
})
serverProcess.stderr?.on("data", (data) => {
proc.stderr?.on("data", (data) => {
console.error(`[Next.js Error] ${data.toString().trim()}`)
})
serverProcess.on("exit", (code) => {
proc.on("exit", (code) => {
console.log(`Next.js server exited with code ${code}`)
serverProcess = null
// An old server can exit after a new one started; keep the new one
if (serverProcess === proc) {
serverProcess = null
}
})
const url = getServerUrl()
await waitForServer(url)
console.log(`Next.js server started at ${url}`)
saveServerPort(port)
return url
}
@@ -126,39 +154,36 @@ export async function startNextServer(): Promise<string> {
* Stop the Next.js server process and wait for it to exit
*/
export async function stopNextServer(): Promise<void> {
if (serverProcess) {
console.log("Stopping Next.js server...")
const proc = serverProcess
if (!proc) {
return
}
console.log("Stopping Next.js server...")
serverProcess = null
// Create a promise that resolves when the process exits
const exitPromise = new Promise<void>((resolve) => {
const proc = serverProcess
if (!proc) {
resolve()
return
}
const onExit = () => {
resolve()
}
proc.once("exit", onExit)
// Timeout after 5 seconds
setTimeout(() => {
proc.removeListener("exit", onExit)
resolve()
}, 5000)
// Resolves true when the process exits, false after the timeout
const waitForExit = (ms: number) =>
new Promise<boolean>((resolve) => {
proc.once("exit", () => resolve(true))
setTimeout(() => resolve(false), ms)
})
serverProcess.kill()
serverProcess = null
proc.kill()
// Wait for process to exit
await exitPromise
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
// Next.js waits for open requests (e.g. a streaming reply) before it
// exits, so force kill it if it is still running after 5 seconds
if (!(await waitForExit(5000)) && proc.pid) {
console.warn("Next.js server did not exit in time, force killing it")
try {
process.kill(proc.pid, "SIGKILL")
} catch (error) {
console.error("Failed to force kill Next.js server:", error)
}
await waitForExit(2000)
}
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
}
/**
@@ -184,15 +209,19 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
/**
* Restart the Next.js server with new environment variables
*/
export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...")
export function restartNextServer(): Promise<string> {
return runExclusive(async () => {
console.log("Restarting Next.js server...")
// Stop the current server and wait for it to exit
await stopNextServer()
// Stop the current server and wait for it to exit
await stopNextServer()
// Wait for the port to be released
await waitForServerStop()
// Wait for the port to be released
await waitForServerStop()
// Start the server again
return startNextServer()
// Start the server again, and follow it if it moved to another port
const url = await startServer()
setAppUrl(url)
return url
})
}
+52 -1
View File
@@ -1,4 +1,6 @@
import { readFileSync, writeFileSync } from "node:fs"
import net from "node:net"
import path from "node:path"
import { app } from "electron"
/**
@@ -23,6 +25,40 @@ const PORT_CONFIG = {
*/
let allocatedPort: number | null = null
/**
* File that remembers the production port from the last launch, so the app
* keeps the same origin (and its localStorage) instead of switching between
* the legacy and new port depending on which one is free at startup
*/
function getSavedPortPath(): string {
return path.join(app.getPath("userData"), "server-port.json")
}
function loadSavedPort(): number | null {
try {
const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8"))
return Number.isInteger(port) ? port : null
} catch {
return null
}
}
/**
* Remember the port of the first production launch. A later launch that
* found it taken keeps it remembered: the user's data lives under that
* origin, and the next launch goes back to it once it is free.
*/
export function saveServerPort(port: number): void {
if (!app.isPackaged || loadSavedPort() !== null) {
return
}
try {
writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8")
} catch (error) {
console.error("Failed to save server port:", error)
}
}
/**
* Check if a specific port is available
*/
@@ -44,7 +80,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: uses fixed port (13370) to preserve localStorage
* - In production: uses the port from the last launch, then the legacy
* port (61337), then 13370, to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
*
@@ -69,6 +106,20 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// In production, use the port from the last launch first
if (!isDev) {
const savedPort = loadSavedPort()
if (savedPort !== null) {
if (await isPortAvailable(savedPort)) {
allocatedPort = savedPort
return savedPort
}
console.warn(
`Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`,
)
}
}
// In production, try legacy port first to preserve existing users' localStorage
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
+18 -5
View File
@@ -13,18 +13,22 @@ function getConfigPath(): string {
/**
* Load proxy configuration from JSON file
* Returns null if the user never saved proxy settings (or the file is invalid)
*/
export function loadProxyConfig(): ProxyConfig {
export function loadProxyConfig(): ProxyConfig | null {
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = fs.readFileSync(configPath, "utf-8")
return JSON.parse(data) as ProxyConfig
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
if (data && typeof data === "object" && !Array.isArray(data)) {
return data as ProxyConfig
}
console.error("Ignoring invalid proxy config:", data)
}
} catch (error) {
console.error("Failed to load proxy config:", error)
}
return {}
return null
}
/**
@@ -33,7 +37,11 @@ export function loadProxyConfig(): ProxyConfig {
export function saveProxyConfig(config: ProxyConfig): void {
try {
const configPath = getConfigPath()
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
// Write a temp file and rename it, so a crash mid-write can't leave
// a truncated file
const tempPath = `${configPath}.tmp`
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
fs.renameSync(tempPath, configPath)
} catch (error) {
console.error("Failed to save proxy config:", error)
throw error
@@ -47,6 +55,11 @@ export function saveProxyConfig(config: ProxyConfig): void {
export function applyProxyToEnv(): void {
const config = loadProxyConfig()
// No saved settings: keep proxy vars inherited from the system or .env
if (!config) {
return
}
if (config.httpProxy) {
process.env.HTTP_PROXY = config.httpProxy
process.env.http_proxy = config.httpProxy
+38 -1
View File
@@ -3,6 +3,9 @@ import { app, BrowserWindow, screen } from "electron"
let mainWindow: BrowserWindow | null = null
// URL of the app server the main window loads
let appUrl: string | null = null
/**
* Get the icon path based on platform
* Note: electron-builder converts icon.png during packaging,
@@ -28,6 +31,7 @@ function getIconPath(): string | undefined {
* Create the main application window
*/
export function createWindow(serverUrl: string): BrowserWindow {
appUrl = serverUrl
const { width, height } = screen.getPrimaryDisplay().workAreaSize
mainWindow = new BrowserWindow({
@@ -56,7 +60,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
})
// Open DevTools in development
if (process.env.NODE_ENV === "development") {
if (!app.isPackaged) {
mainWindow.webContents.openDevTools()
}
@@ -93,3 +97,36 @@ export function createWindow(serverUrl: string): BrowserWindow {
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}
/**
* Get the app server URL the main window loads
*/
export function getAppUrl(): string | null {
return appUrl
}
/**
* Point the main window at a new app server URL
* (the restarted server can come up on a different port)
*/
export function setAppUrl(url: string): void {
if (url === appUrl) {
return
}
appUrl = url
mainWindow?.loadURL(url)
}
/**
* Check if a URL belongs to the app server (same origin)
*/
export function isAppUrl(url: string | undefined): boolean {
if (!url || !appUrl) {
return false
}
try {
return new URL(url).origin === new URL(appUrl).origin
} catch {
return false
}
}
-4
View File
@@ -18,10 +18,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
// Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
+7 -6
View File
@@ -213,6 +213,9 @@ async function savePreset() {
}
})
// closeModal() clears editingPresetId, so remember it for the toast
const isEdit = Boolean(editingPresetId)
try {
saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>'
@@ -220,10 +223,7 @@ async function savePreset() {
await window.settingsAPI.savePreset(preset)
await loadPresets()
closeModal()
showToast(
editingPresetId ? "Preset updated" : "Preset created",
"success",
)
showToast(isEdit ? "Preset updated" : "Preset created", "success")
} catch (error) {
console.error("Failed to save preset:", error)
showToast("Failed to save preset", "error")
@@ -265,8 +265,6 @@ async function applyPreset(id) {
const result = await window.settingsAPI.applyPreset(id)
if (result.success) {
currentPresetId = id
renderPresets()
showToast("Preset applied, server restarting...", "success")
} else {
showToast(result.error || "Failed to apply preset", "error")
@@ -274,6 +272,9 @@ async function applyPreset(id) {
} catch (error) {
console.error("Failed to apply preset:", error)
showToast("Failed to apply preset", "error")
} finally {
// Reload to show the active preset and reset the Apply button
await loadPresets()
}
}
+8 -3
View File
@@ -12,7 +12,8 @@ AI_PROVIDER=bedrock
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.
# so a thinking model can spend it all before the tool call. Users can lower it in Settings,
# and raise it only when they use their own API key, so this also caps cost on server keys.
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
# MAX_OUTPUT_TOKENS=64000
@@ -43,7 +44,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Google Generative AI Configuration
# GOOGLE_GENERATIVE_AI_API_KEY=...
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
@@ -123,9 +123,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models).
# Claude 4.7 and later reject it; the request is then retried without it.
# TEMPERATURE=0
# Debug Logging (Optional)
# Log the structure of the messages each chat request sends to the model
# DEBUG_LLM_PAYLOAD=true
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code
+66 -79
View File
@@ -1,5 +1,4 @@
import type { MutableRefObject } from "react"
import { useRef } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
@@ -7,10 +6,15 @@ import type {
} from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { isMxCellXmlComplete } from "@/lib/utils"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
const DEBUG = process.env.NODE_ENV === "development"
// display_diagram replaces the document with this one page
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
interface ToolCall {
toolCallId: string
toolName: string
@@ -48,6 +52,8 @@ type ValidateDiagramFn = (
interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
// Failed VLM validations in the current user turn (reset on each user message)
validationRetryCountRef: MutableRefObject<number>
chartXMLRef: MutableRefObject<string>
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string>
@@ -72,6 +78,7 @@ interface UseDiagramToolHandlersParams {
export function useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
@@ -82,9 +89,6 @@ export function useDiagramToolHandlers({
sessionId,
onValidationStateChange,
}: UseDiagramToolHandlersParams) {
// Track validation retry count per tool call
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
// Helper to update validation state
const updateValidationState = (
toolCallId: string,
@@ -174,11 +178,12 @@ NEXT STEP: Call append_diagram with the continuation XML.
const finalXml = xml
partialXmlRef.current = "" // Reset any partial from previous truncation
// Wrap raw XML with full mxfile structure for draw.io
const fullXml = wrapWithMxFile(finalXml)
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(fullXml)
// Wrap, validate and auto-fix the model's XML like the MCP server's
// create_new_diagram, then load it
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
: prepared.error
if (validationError) {
console.warn("[display_diagram] Validation error:", validationError)
@@ -232,17 +237,15 @@ ${finalXml}
)
}
const retryCount =
validationRetryCountRef.current.get(
toolCall.toolCallId,
) || 0
// Each retry is a new tool call, so count attempts per user turn
const attempt = validationRetryCountRef.current + 1
// Notify UI that we're validating (include the image)
updateValidationState(
toolCall.toolCallId,
"validating",
{
attempt: retryCount + 1,
attempt,
maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData,
},
@@ -254,17 +257,14 @@ ${finalXml}
)
if (!result.valid) {
if (retryCount < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current.set(
toolCall.toolCallId,
retryCount + 1,
)
if (attempt < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current = attempt
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
@@ -274,7 +274,7 @@ ${finalXml}
toolCall.toolCallId,
"failed",
{
attempt: retryCount + 1,
attempt,
maxAttempts: MAX_VALIDATION_RETRIES,
result,
imageData: capturedPngData,
@@ -285,19 +285,17 @@ ${finalXml}
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
})
return
} else {
// Max retries reached - accept the diagram with warning
// Last attempt - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
validationRetryCountRef.current = 0
// Notify UI that we're accepting with issues (include the image)
updateValidationState(
@@ -314,10 +312,8 @@ ${finalXml}
return
}
} else {
// Validation passed - clean up retry count
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
// Validation passed - reset retry count
validationRetryCountRef.current = 0
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
@@ -382,12 +378,17 @@ ${finalXml}
}
let currentXml = ""
// Use the original XML captured during streaming (shared with chat-message-display)
// This ensures we apply operations to the same base XML that streaming used
const originalXml = editDiagramOriginalXmlRef.current.get(
toolCall.toolCallId,
)
// On failure, undo the streaming preview so the canvas matches the XML
// reported back to the model
const restoreOriginal = () => {
if (originalXml) onDisplayChart(originalXml, true)
}
try {
// Use the original XML captured during streaming (shared with chat-message-display)
// This ensures we apply operations to the same base XML that streaming used
const originalXml = editDiagramOriginalXmlRef.current.get(
toolCall.toolCallId,
)
if (originalXml) {
currentXml = originalXml
} else {
@@ -401,26 +402,19 @@ ${finalXml}
}
}
const { applyDiagramOperations } = await import("@/lib/utils")
const { result: editedXml, errors } = applyDiagramOperations(
currentXml,
operations,
)
// Check for operation errors
if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
// All or nothing, checked like the MCP server's edit_diagram.
// The model sees the first page, so edits target it.
const outcome = editDiagram(currentXml, operations, {})
if (!outcome.ok) {
const reason = outcome.pageError
? outcome.errors[0]
: `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`
restoreOriginal()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Some operations failed:\n${errorMessages}
errorText: `${reason}
Current diagram XML:
\`\`\`xml
@@ -434,35 +428,12 @@ Please check the cell IDs and retry.`,
return
}
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(editedXml)
if (validationError) {
console.warn(
"[edit_diagram] Validation error:",
validationError,
)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit produced invalid XML: ${validationError}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please fix the operations to avoid structural issues.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
return
}
onDisplayChart(outcome.xml, true)
onExport()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
@@ -472,6 +443,7 @@ Please fix the operations to avoid structural issues.`,
const errorMessage =
error instanceof Error ? error.message : String(error)
restoreOriginal()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
@@ -496,6 +468,19 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
) => {
const { xml } = toolCall.input as { xml: string }
// Nothing to continue: loading the fragment alone would replace the whole diagram
if (!partialXmlRef.current) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
})
return
}
// Detect if LLM incorrectly started fresh instead of continuing
// LLM should only output bare mxCells now, so wrapper tags indicate error
const trimmed = xml.trim()
@@ -534,8 +519,10 @@ Start your continuation with the NEXT character after where it stopped.`,
const finalXml = partialXmlRef.current
partialXmlRef.current = "" // Reset
const fullXml = wrapWithMxFile(finalXml)
const validationError = onDisplayChart(fullXml)
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
: prepared.error
if (validationError) {
addToolOutput({
+57 -29
View File
@@ -101,6 +101,15 @@ function saveConfig(config: MultiModelConfig): void {
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
/**
* Server model to fall back to: the one marked default, else the first one
*/
function defaultServerModelId(
serverModels: FlattenedServerModel[],
): string | undefined {
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
}
export interface UseModelConfigReturn {
// State
config: MultiModelConfig
@@ -144,6 +153,16 @@ export function useModelConfig(): UseModelConfigReturn {
setIsLoaded(true)
}, [])
// Pick up config changes saved by other tabs, so this tab neither shows a
// stale model nor overwrites their changes on its next save
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
}
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
}, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
@@ -165,17 +184,18 @@ export function useModelConfig(): UseModelConfigReturn {
setServerModels(raw)
setServerLoaded(true)
// Auto-select default server model if no model is currently selected
// Auto-select the default server model if no model is selected,
// or if the saved server model is gone (renamed or removed)
setConfig((prev) => {
if (!prev.selectedModelId && raw.length > 0) {
const defaultModel = raw.find((m) => m.isDefault)
if (defaultModel) {
return { ...prev, selectedModelId: defaultModel.id }
}
// If no default marked, use first server model
return { ...prev, selectedModelId: raw[0].id }
}
return prev
const id = prev.selectedModelId
const isStale =
id?.startsWith("server:") &&
!raw.some((m) => m.id === id)
if (id && !isStale) return prev
const fallback = defaultServerModelId(raw)
return fallback === id
? prev
: { ...prev, selectedModelId: fallback }
})
})
.catch((error) => {
@@ -260,24 +280,31 @@ export function useModelConfig(): UseModelConfigReturn {
[],
)
const deleteProvider = useCallback((providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
const deleteProvider = useCallback(
(providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
// Clear selected model if it belongs to deleted provider
const newSelectedId =
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
? undefined
: prev.selectedModelId
// Fall back to the default server model if the selected model
// belongs to the deleted provider
const newSelectedId =
prev.selectedModelId &&
modelIds.includes(prev.selectedModelId)
? defaultServerModelId(serverModels)
: prev.selectedModelId
return {
...prev,
providers: prev.providers.filter((p) => p.id !== providerId),
selectedModelId: newSelectedId,
}
})
}, [])
return {
...prev,
providers: prev.providers.filter(
(p) => p.id !== providerId,
),
selectedModelId: newSelectedId,
}
})
},
[serverModels],
)
const addModel = useCallback(
(providerId: string, modelId: string): ModelConfig => {
@@ -334,14 +361,15 @@ export function useModelConfig(): UseModelConfigReturn {
}
: p,
),
// Clear selected model if it was deleted
// Fall back to the default server model if the selected model
// was deleted
selectedModelId:
prev.selectedModelId === modelConfigId
? undefined
? defaultServerModelId(serverModels)
: prev.selectedModelId,
}))
},
[],
[serverModels],
)
const resetConfig = useCallback(() => {
+32 -4
View File
@@ -1,6 +1,8 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type ChatSession,
createEmptySession,
@@ -44,6 +46,15 @@ export interface UseSessionManagerReturn {
clearCurrentSession: () => void
}
// Reading the session list loads every stored session in full, and window
// focus also fires each time the user clicks back from the draw.io iframe
const FOCUS_REFRESH_INTERVAL_MS = 30_000
function notifySaveFailed(message: string) {
// Same id, so repeated failures update one toast instead of stacking
toast.error(message, { id: "session-save-failed", duration: 8000 })
}
interface UseSessionManagerOptions {
/** Session ID from URL param - if provided, load this session; if null, start blank */
initialSessionId?: string | null
@@ -53,6 +64,7 @@ export function useSessionManager(
options: UseSessionManagerOptions = {},
): UseSessionManagerReturn {
const { initialSessionId } = options
const dict = useDictionary()
const [sessions, setSessions] = useState<SessionMetadata[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
@@ -163,9 +175,15 @@ export function useSessionManager(
handleSessionIdChange()
}, [initialSessionId, isAvailable])
// Refresh sessions on window focus (multi-tab sync)
// Refresh sessions on window focus (multi-tab sync), at most once per interval
const lastFocusRefreshRef = useRef(0)
useEffect(() => {
const handleFocus = () => {
const now = Date.now()
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
return
}
lastFocusRefreshRef.current = now
refreshSessions()
}
window.addEventListener("focus", handleFocus)
@@ -238,6 +256,8 @@ export function useSessionManager(
) {
return
}
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return
if (!currentSession) {
// Create a new session if none exists
@@ -250,7 +270,12 @@ export function useSessionManager(
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
await saveSession(newSession)
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
@@ -277,7 +302,10 @@ export function useSessionManager(
: currentSession.title,
}
await saveSession(updatedSession)
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
setCurrentSession(updatedSession)
// Update sessions list metadata
@@ -298,7 +326,7 @@ export function useSessionManager(
),
)
},
[currentSession, currentSessionId, refreshSessions],
[currentSession, currentSessionId, refreshSessions, dict],
)
// Clear current session state (for starting fresh without loading another session)
+3
View File
@@ -6,6 +6,7 @@
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react"
import { getSelectedAIConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import {
type ValidationResult,
@@ -39,6 +40,8 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
// Resolved per request so a changed access code is picked up
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
onFinish: ({
object,
error: finishError,
+22
View File
@@ -0,0 +1,22 @@
/**
* Check the x-access-code header against ACCESS_CODE_LIST.
* Returns a 401 response to send back when the check fails, or null when the
* request may continue (including when no access codes are configured).
*/
export function checkAccessCode(req: Request): Response | null {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
if (accessCodes.length === 0) return null
const accessCodeHeader = req.headers.get("x-access-code")
if (accessCodeHeader && accessCodes.includes(accessCodeHeader)) return null
return Response.json(
{
error: "Invalid or missing access code. Please configure it in Settings.",
},
{ status: 401 },
)
}
+19 -7
View File
@@ -2,6 +2,7 @@ import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
slugify,
} from "@/lib/server-model-config"
import {
FIXED_CRED_PROVIDERS,
@@ -182,12 +183,15 @@ export function validateAdminProviders(
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
}
}
// Server model ids are built from the slugified name, so names must
// stay distinct after slugifying ("OpenAI" and "openai" would collide)
const names = list.map((p) => displayName(p))
if (new Set(names).size !== names.length) {
return "Provider display names must be unique."
const slugs = names.map(slugify)
if (new Set(slugs).size !== slugs.length) {
return "Provider display names must be unique (ignoring case and punctuation)."
}
const envNames = new Set(envProviders.map((p) => p.name))
const clash = names.find((n) => envNames.has(n))
const envSlugs = new Set(envProviders.map((p) => slugify(p.name)))
const clash = names.find((_, i) => envSlugs.has(slugs[i]))
if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
}
@@ -240,10 +244,14 @@ export function deriveEnvUpdates(
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
// ADMIN_ names keep the standard AWS_* vars untouched, so other
// AWS clients (e.g. the DynamoDB quota table) keep their own
// credentials instead of picking up the panel's Bedrock keys
if (p.awsAccessKeyId)
updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId
if (p.awsSecretAccessKey)
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
@@ -284,6 +292,10 @@ function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
keys.add("ADMIN_AWS_ACCESS_KEY_ID")
keys.add("ADMIN_AWS_SECRET_ACCESS_KEY")
keys.add("ADMIN_AWS_REGION")
// Written by older versions; listed so the next save clears them
keys.add("AWS_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION")
+34 -19
View File
@@ -10,13 +10,27 @@ interface SettingsFile {
values: Record<string, string>
}
// Original env values snapshotted before the first overlay, so removing a
// key from the settings file restores the env default. null = was unset.
const originalEnv: Record<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
interface SettingsState {
// Original env values snapshotted before the first overlay, so removing
// a key from the settings file restores the env default. null = was unset.
originalEnv: Record<string, string | null>
// Keys currently overlaid, so we can restore ones removed from the file.
overlaidKeys: Set<string>
cachedSettings: Record<string, string> | null
}
let cachedSettings: Record<string, string> | null = null
// Kept on globalThis because the build can load this module more than once
// (instrumentation.ts and the API routes get separate copies); per-module
// state would make a route forget what instrumentation overlaid at startup.
const globalState = globalThis as typeof globalThis & {
__adminSettingsState?: SettingsState
}
globalState.__adminSettingsState ??= {
originalEnv: {},
overlaidKeys: new Set(),
cachedSettings: null,
}
const state = globalState.__adminSettingsState
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
@@ -25,7 +39,7 @@ export function getSettingsPath(): string {
}
export function loadSettings(): Record<string, string> {
if (cachedSettings) return cachedSettings
if (state.cachedSettings) return state.cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
@@ -43,21 +57,22 @@ export function loadSettings(): Record<string, string> {
for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value
}
cachedSettings = values
state.cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
cachedSettings = {}
state.cachedSettings = {}
}
return cachedSettings
return state.cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
const { originalEnv } = state
// Restore env for keys that were overlaid before but are now gone
for (const key of overlaidKeys) {
for (const key of state.overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
@@ -72,12 +87,12 @@ export function applyToEnv(): void {
process.env[key] = value
}
overlaidKeys = new Set(Object.keys(values))
state.overlaidKeys = new Set(Object.keys(values))
}
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null
return process.env[key] ?? null
}
@@ -101,7 +116,7 @@ export function saveSettings(updates: Record<string, string | null>): void {
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
cachedSettings = current
state.cachedSettings = current
applyToEnv()
}
@@ -122,13 +137,13 @@ export function isSettingsWritable(): boolean {
// Test-only: reset module state
export function _resetForTests(): void {
cachedSettings = null
state.cachedSettings = null
writableCache = null
for (const key of overlaidKeys) {
const original = originalEnv[key]
for (const key of state.overlaidKeys) {
const original = state.originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
overlaidKeys = new Set()
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
state.overlaidKeys = new Set()
state.originalEnv = {}
}
+372 -710
View File
File diff suppressed because it is too large Load Diff
-79
View File
@@ -1,79 +0,0 @@
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
}
+10
View File
@@ -1,6 +1,8 @@
export interface CachedResponse {
promptText: string
hasImage: boolean
// Name of the bundled example file the prompt is sent with
fileName?: string
xml: string
}
@@ -254,6 +256,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText: "Replicate this in aws style",
hasImage: true,
fileName: "architecture.png",
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
</mxCell>
@@ -318,6 +321,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText: "Replicate this flowchart.",
hasImage: true,
fileName: "example.png",
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
</mxCell>
@@ -379,6 +383,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText: "Summarize this paper as a diagram",
hasImage: true,
fileName: "chain-of-thought.txt",
xml: `<mxCell id="title_bg" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
value="" vertex="1">
@@ -879,14 +884,19 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
},
]
// Examples that come with a file only match when that exact example file is
// attached, so a user's own file with the same prompt still goes to the model.
// Callers that can't tell file names (the server) only get text-only examples.
export function findCachedResponse(
promptText: string,
hasImage: boolean,
fileName?: string,
): CachedResponse | undefined {
return CACHED_EXAMPLE_RESPONSES.find(
(c) =>
c.promptText === promptText &&
c.hasImage === hasImage &&
(!c.fileName || c.fileName === fileName) &&
c.xml !== "",
)
}
+91 -45
View File
@@ -6,25 +6,37 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export const MAX_FILES = 5
// Helper function to validate file parts in messages
// Checks every message, since history is sent to the model too
export function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
for (const message of messages) {
const fileParts =
message?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
for (const filePart of fileParts) {
// The client sends files inline. Any other URL would be downloaded
// by the server (AI SDK does that for models without URL support).
if (
typeof filePart.url !== "string" ||
!filePart.url.startsWith("data:")
) {
return {
valid: false,
error: "Files must be uploaded inline as data URLs.",
}
}
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
@@ -41,49 +53,83 @@ export function validateFileParts(messages: any[]): {
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
export function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
// A tool-call input providers accept: a non-empty JSON object
function isValidToolInput(input: unknown): boolean {
return !!input && typeof input === "object" && Object.keys(input).length > 0
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
const replacedContent = msg.content.map((part: any) => {
if (
part.type === "tool-call" &&
isValidToolInput(part.input) &&
(part.toolName === "display_diagram" ||
part.toolName === "edit_diagram")
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
}
return part
})
return { ...msg, content: replacedContent }
})
}
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
// together with their tool-results: providers reject a result whose call is missing.
// Messages left empty are removed too (Bedrock rejects empty content arrays).
export function dropInvalidToolCalls(messages: any[]): any[] {
const droppedIds = new Set<string>()
return messages
.map((msg) => {
if (!Array.isArray(msg.content)) return msg
const content = msg.content.filter((part: any) => {
if (
msg.role === "assistant" &&
part.type === "tool-call" &&
!isValidToolInput(part.input)
) {
console.warn(
`[chat-helpers] Dropping tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
droppedIds.add(part.toolCallId)
return false
}
// Results always come after their call, so the id is known by now
return !(
part.type === "tool-result" &&
droppedIds.has(part.toolCallId)
)
})
return { ...msg, content }
})
.filter((msg) => !Array.isArray(msg.content) || msg.content.length > 0)
}
// Fix common LLM JSON mistakes in tool-call input before jsonrepair runs
export function fixToolInputJson(input: string): string {
return (
input
// Inconsistent quote escaping in XML attributes inside JSON strings:
// y="-20\" (opening quote unescaped, closing escaped) becomes y=\"-20\".
// Must run before the key fix below, which would rewrite the `="`.
.replace(/(\w+)="([^"]*?)\\"/g, '$1=\\"$2\\"')
// `:=` instead of `: `
.replace(/:=/g, ": ")
// `"key"= "` instead of `"key": "`, only for JSON keys
.replace(/"(\w+)"\s*=\s*"/g, '"$1": "')
)
}
+94
View File
@@ -0,0 +1,94 @@
import { wrapLanguageModel } from "ai"
import { rejectionText } from "@/lib/output-token-limit"
type WrappedModel = ReturnType<typeof wrapLanguageModel>
/**
* Claude 4.7 and later answer a non-default temperature, top_p or top_k,
* and the extended thinking budget (thinking type "enabled"), with a 400.
* TEMPERATURE and the *_THINKING_BUDGET_TOKENS settings send exactly these.
*/
const DEPRECATED_PARAM =
/`?(?:temperature|top_p|top_k)`? is deprecated for this model|"?thinking\.type\.enabled"? is not supported/i
interface CallParams {
temperature?: number
topP?: number
topK?: number
providerOptions?: Record<string, Record<string, unknown> | undefined>
}
// What these models take instead of a budget. Without display "summarized"
// they think but send no thinking text to show.
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
/** Turn a thinking config of type "enabled" stored under key into adaptive */
function adaptiveThinking(
options: Record<string, unknown> | undefined,
key: string,
): Record<string, unknown> | undefined {
const config = options?.[key] as { type?: string } | undefined
if (config?.type !== "enabled") return options
return { ...options, [key]: ADAPTIVE_THINKING }
}
/**
* The params without the settings newer Claude models reject, or null when
* the error is about something else or there is nothing to change. The
* model then runs with its default sampling, and a thinking budget becomes
* adaptive thinking.
*/
export function withoutDeprecatedParams<T extends CallParams>(
error: unknown,
params: T,
): T | null {
const text = rejectionText(error)
if (!text || !DEPRECATED_PARAM.test(text)) return null
const { temperature, topP, topK, ...rest } = params
const options = params.providerOptions
const anthropic = adaptiveThinking(options?.anthropic, "thinking")
const bedrock = adaptiveThinking(options?.bedrock, "reasoningConfig")
const changed =
temperature !== undefined ||
topP !== undefined ||
topK !== undefined ||
anthropic !== options?.anthropic ||
bedrock !== options?.bedrock
if (!changed) return null
return {
...rest,
...(options && {
providerOptions: {
...options,
...(anthropic && { anthropic }),
...(bedrock && { bedrock }),
},
}),
} as T
}
/** Retry the stream once without the settings newer Claude models reject. */
export function withDeprecatedParamsFallback(
model: WrappedModel,
): WrappedModel {
return wrapLanguageModel({
model,
middleware: {
specificationVersion: "v3",
async wrapStream({ doStream, params, model: inner }) {
try {
return await doStream()
} catch (error) {
const retry = withoutDeprecatedParams(error, params)
if (!retry) throw error
console.warn(
"[model params] Rejected sampling or thinking settings, retrying with default sampling and adaptive thinking",
)
return await inner.doStream(retry)
}
},
},
})
}
+23 -4
View File
@@ -178,9 +178,6 @@
"networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"validationFailed": "Diagram validation failed. Please try regenerating.",
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
"failedToProcess": "Failed to process diagram. Please try regenerating.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
@@ -188,7 +185,23 @@
"failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
"insufficient_quota": "The provider account has no credit or quota left.",
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
"context_too_long": "The conversation is too long for this model. Start a new chat or pick a model with a larger context.",
"images_unsupported": "This model doesn't support image input.",
"tools_unsupported": "This model doesn't support tool calls, which drawing needs. Pick another model.",
"output_truncated": "The output was cut off before the diagram was complete. Try a simpler request or raise the output limit in settings.",
"provider_unavailable": "The provider is having problems. Try again later.",
"cannot_connect": "Could not reach the provider. Check the base URL and your network.",
"timeout": "The provider did not answer in time.",
"openModelSettings": "Open model settings"
}
},
"quota": {
"dailyLimit": "Daily Quota Reached",
@@ -235,6 +248,7 @@
"reasoning": {
"thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds",
"thoughtForOne": "Thought for 1 second",
"thoughtBrief": "Thought for a few seconds"
},
"dev": {
@@ -371,6 +385,11 @@
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"getApiKey": "Get API key",
"fetchModels": "Fetch models from the provider",
"noTools": "no tool calls",
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
"requestUrl": "Requests go to {url}",
"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",
+23 -4
View File
@@ -178,9 +178,6 @@
"networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
@@ -188,7 +185,23 @@
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
"context_too_long": "会話がこのモデルで扱える長さを超えています。新しいチャットを始めるか、より長いコンテキストに対応したモデルを選んでください。",
"images_unsupported": "このモデルは画像入力に対応していません。",
"tools_unsupported": "このモデルはツール呼び出しに対応していません。作図にはツール呼び出しが必要です。別のモデルを選んでください。",
"output_truncated": "ダイアグラムが完成する前に出力が途中で切れました。リクエストを簡単にするか、設定で出力上限を上げてください。",
"provider_unavailable": "プロバイダーで問題が発生しています。しばらくしてから再試行してください。",
"cannot_connect": "プロバイダーに接続できません。Base URL とネットワークを確認してください。",
"timeout": "プロバイダーから時間内に応答がありませんでした。",
"openModelSettings": "モデル設定を開く"
}
},
"quota": {
"dailyLimit": "1日の割当量に達しました",
@@ -235,6 +248,7 @@
"reasoning": {
"thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました",
"thoughtForOne": "1 秒考えました",
"thoughtBrief": "数秒考えました"
},
"dev": {
@@ -325,6 +339,11 @@
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"getApiKey": "API キーを取得",
"fetchModels": "プロバイダーからモデル一覧を取得",
"noTools": "ツール呼び出し非対応",
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
"customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
+23 -4
View File
@@ -178,9 +178,6 @@
"networkError": "網路錯誤。請檢查您的連線。",
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
@@ -188,7 +185,23 @@
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
"context_too_long": "對話內容超過了這個模型能處理的長度。請開啟新的對話,或換一個上下文更長的模型。",
"images_unsupported": "這個模型不支援圖片輸入。",
"tools_unsupported": "這個模型不支援工具呼叫,而繪圖需要工具呼叫。請換一個模型。",
"output_truncated": "輸出在圖表完成之前就被截斷了。請簡化請求,或在設定中調高輸出上限。",
"provider_unavailable": "服務商發生問題,請稍後再試。",
"cannot_connect": "無法連線到服務商,請檢查 Base URL 和網路。",
"timeout": "服務商沒有及時回應。",
"openModelSettings": "開啟模型設定"
}
},
"quota": {
"dailyLimit": "已達每日配額",
@@ -235,6 +248,7 @@
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
@@ -371,6 +385,11 @@
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"getApiKey": "取得 API Key",
"fetchModels": "從服務商取得模型清單",
"noTools": "不支援工具呼叫",
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
"requestUrl": "請求將傳送至 {url}",
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
"customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
+23 -4
View File
@@ -178,9 +178,6 @@
"networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
"validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。",
"sessionCorrupted": "会话数据已损坏。重新开始。",
"failedToSave": "无法保存消息到 localStorage",
"failedToRestore": "无法从 localStorage 恢复",
@@ -188,7 +185,23 @@
"failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
"insufficient_quota": "服务商账户的余额或额度已经用完。",
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
"context_too_long": "对话内容超过了这个模型能处理的长度。请新开一个对话,或换一个上下文更长的模型。",
"images_unsupported": "这个模型不支持图片输入。",
"tools_unsupported": "这个模型不支持工具调用,而画图需要工具调用。请换一个模型。",
"output_truncated": "输出在图画完之前就被截断了。请简化请求,或在设置里调高输出上限。",
"provider_unavailable": "服务商出了问题,请稍后再试。",
"cannot_connect": "连接不上服务商,请检查 Base URL 和网络。",
"timeout": "服务商没有及时响应。",
"openModelSettings": "打开模型设置"
}
},
"quota": {
"dailyLimit": "已达每日配额",
@@ -235,6 +248,7 @@
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了几秒钟"
},
"dev": {
@@ -371,6 +385,11 @@
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"getApiKey": "获取 API Key",
"fetchModels": "从服务商获取模型列表",
"noTools": "不支持工具调用",
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
"requestUrl": "请求将发往 {url}",
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
"customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
+8 -1
View File
@@ -51,8 +51,15 @@ export function setTraceOutput(output: string) {
if (!isLangfuseEnabled()) return
updateActiveTrace({ output })
endTrace()
}
// End the observe() wrapper span (AI SDK creates its own child spans with usage).
// It uses endOnExit: false, so every request path has to end it, or the trace
// is never exported: stream finish, stream error/abort, and early returns.
export function endTrace() {
if (!isLangfuseEnabled()) return
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
const activeSpan = api.trace.getActiveSpan()
if (activeSpan) {
activeSpan.end()
+178
View File
@@ -0,0 +1,178 @@
import {
APICallError,
InvalidToolInputError,
LoadAPIKeyError,
NoSuchToolError,
RetryError,
ToolCallRepairError,
} from "ai"
/**
* What went wrong with a model call, for a hint the user can act on. The
* provider's own message always goes along, because a guess can be wrong.
*/
export type LLMErrorCode =
| "invalid_api_key"
| "forbidden"
| "model_not_found"
| "insufficient_quota"
| "rate_limited"
| "context_too_long"
| "images_unsupported"
| "tools_unsupported"
| "output_truncated"
| "provider_unavailable"
| "cannot_connect"
| "timeout"
| "unknown"
export interface LLMError {
type: "provider"
code: LLMErrorCode
message: string
}
// Texts that name the cause more precisely than the status code: a quota
// error can come as 403 or 429, a context or image error as a plain 400
const SPECIFIC_TEXTS: Array<[RegExp, LLMErrorCode]> = [
[
// Not "too many tokens": that is Bedrock's throttling message
/context length|context window|maximum context|prompt is too long|input is too long|too many input tokens/i,
"context_too_long",
],
[
/image content block|image_url|does not support image|image input is not supported/i,
"images_unsupported",
],
[
/does not support tools|tool use is not supported|tools? (?:are|is) not supported|function calling is not supported/i,
"tools_unsupported",
],
// Bedrock, when the output limit cut the tool call's JSON short
[/toolUse\.input is invalid/i, "output_truncated"],
// Bedrock, for a model id without the inference profile prefix
[/on-demand throughput isn.t supported/i, "model_not_found"],
[
/insufficient[_ ]quota|insufficient balance|exceeded your current quota|credit balance is too low|余额不足/i,
"insufficient_quota",
],
]
const STATUS_CODES: Record<number, LLMErrorCode> = {
401: "invalid_api_key",
402: "insufficient_quota",
// Not "invalid key": a valid key can lack access to a model or region
403: "forbidden",
404: "model_not_found",
408: "timeout",
// A retired model
410: "model_not_found",
413: "context_too_long",
429: "rate_limited",
}
const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [
[
/model[_ ]not[_ ]found|model .*does not exist|unknown model|no such model/i,
"model_not_found",
],
[
/invalid[_ ]api[_ ]key|incorrect api key|unauthorized/i,
"invalid_api_key",
],
[/rate limit|too many requests/i, "rate_limited"],
[
/Cannot connect to API|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|fetch failed/i,
"cannot_connect",
],
]
/** Secrets a provider may echo back: API keys, Bearer tokens, key=value */
function redact(text: string): string {
return text
.replace(/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{8,}/g, "$1-[redacted]")
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
.replace(
/\b(api[_-]?key|access[_-]?key|secret|token|password|signature)(["']?\s*[:=]\s*["']?)[^\s"',&}]+/gi,
"$1$2[redacted]",
)
}
function problemDetail(body: string): string | undefined {
try {
const detail = JSON.parse(body)?.detail
return typeof detail === "string" ? detail : undefined
} catch {
return undefined
}
}
/**
* The error text for the chat stream: what went wrong with the provider as
* JSON for the hint, or the text the model must read to fix a tool call.
* On the server's keys the provider's own text stays in the server log:
* it can name the server's account, role or internal hosts.
*/
export function streamErrorText(error: unknown, hideDetails = false): string {
// The SDK passes an invalid tool call's error as a plain string
if (typeof error === "string") return error
if (isToolCallError(error)) return (error as Error).message
const classified = classifyLLMError(error)
if (hideDetails) {
console.error("[chat] Provider error:", error)
classified.message = "The provider returned an error."
}
return JSON.stringify(classified)
}
/**
* Model and tool errors the SDK sends back to the model as the tool result,
* so it can fix its call. Their text has to stay as it is.
*/
export function isToolCallError(error: unknown): boolean {
return (
InvalidToolInputError.isInstance(error) ||
NoSuchToolError.isInstance(error) ||
ToolCallRepairError.isInstance(error)
)
}
export function classifyLLMError(error: unknown): LLMError {
// After the SDK's retries, the last attempt says what happened
const e = RetryError.isInstance(error) ? error.lastError : error
// Errors sent inside the stream can be plain objects like OpenRouter's
// { code: 503, message }
const plain = e as {
message?: unknown
code?: unknown
statusCode?: number
}
const raw =
e instanceof Error
? e.message
: typeof plain?.message === "string"
? plain.message
: String(e)
const body = APICallError.isInstance(e) ? (e.responseBody ?? "") : ""
// A problem+json body names the reason the SDK left out (NVIDIA: "Gone")
const detail = problemDetail(body)
const message = redact(detail ? `${raw}: ${detail}` : raw).slice(0, 500)
const text = `${raw} ${body}`
const status = APICallError.isInstance(e)
? e.statusCode
: (plain?.statusCode ??
(typeof plain?.code === "number" ? plain.code : undefined))
const find = (rules: Array<[RegExp, LLMErrorCode]>) =>
rules.find(([pattern]) => pattern.test(text))?.[1]
const code =
(e instanceof Error && e.name === "TimeoutError" && "timeout") ||
(LoadAPIKeyError.isInstance(e) && "invalid_api_key") ||
find(SPECIFIC_TEXTS) ||
(status && STATUS_CODES[status]) ||
(status && status >= 500 && "provider_unavailable") ||
find(GENERAL_TEXTS) ||
"unknown"
return { type: "provider", code, message }
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
import catalog from "@/lib/model-catalog.json"
import type { ProviderName } from "@/lib/types/model-config"
/**
* What models.dev knows about a model (scripts/update-model-catalog.mjs).
* Only used for hints: requests are sent the same way either way, since
* the data can be wrong or out of date.
*/
export interface ModelInfo {
tools: boolean
images: boolean
reasoning: boolean
context?: number
output?: number
}
const CATALOG = catalog as Record<string, Record<string, ModelInfo>>
/**
* The entry for a model: an exact match ignoring case, else the longest id
* the model id starts with, followed by "-", ":" or ".". So
* claude-sonnet-4-5-20250929 finds claude-sonnet-4-5, but gpt-4 does not
* find gpt-4o.
*/
export function getModelInfo(
provider: ProviderName,
modelId: string,
): ModelInfo | undefined {
const models = CATALOG[provider]
if (!models) return undefined
const wanted = modelId.trim().toLowerCase()
let best: string | undefined
for (const id of Object.keys(models)) {
const lower = id.toLowerCase()
if (lower === wanted) return models[id]
if (
wanted.startsWith(lower) &&
"-:.".includes(wanted[lower.length]) &&
lower.length > (best?.length ?? 0)
) {
best = id
}
}
return best ? models[best] : undefined
}
+117 -38
View File
@@ -22,6 +22,12 @@ export const MAX_OUTPUT_TOKENS_LIMIT = 200000
*/
const MIN_USABLE_OUTPUT_TOKENS = 1024
/**
* Retry budget when a rejection names the budget parameter but no number we can
* read. It is the default from before 64000, which these providers ran with.
*/
const FALLBACK_OUTPUT_TOKENS = 16000
/** Status codes that can carry a complaint about the requested budget. */
const BUDGET_REJECTION_STATUSES = new Set([400, 422])
@@ -29,24 +35,8 @@ 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 {
/** Message and body of an error that may be about the budget, or null. */
export function rejectionText(error: unknown): string | null {
const err = error as {
message?: unknown
responseBody?: unknown
@@ -66,24 +56,109 @@ export function parseOutputTokenLimit(error: unknown): number | null {
typeof err?.responseBody === "string" ? err.responseBody : "",
].join(" ")
if (!text) return null
return text.trim() ? text : 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.
* vLLM and SGLang send the same kind of ceiling, with the input written as
* "6000 in the messages", "has 6000 input tokens" or "6000 tokens from the input".
* - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..."
* - OpenAI: "This model supports at most 16384 completion tokens"
* - Volcengine Ark: "The parameter `max_tokens` specified in the request are not
* valid: integer above maximum value, expected a value <= 32768, but got 64000"
* - DashScope: "Range of max_tokens should be [1, 8192]"
*
* 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.
*/
function readCeiling(text: string): number | 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)
const context = text.match(/maximum context length (?:is|of) (\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 input =
text.match(/(\d+) of text input/i) ||
text.match(/(\d+) in the messages/i) ||
text.match(/(\d+) tokens from the input/i) ||
text.match(/(\d+) input tokens/i)
return 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)
text.match(/at most (\d+) completion tokens/i) ||
text.match(/max_\w*tokens.*?expected a value (?:<=|\\u003c=) (\d+)/i) ||
text.match(/Range of max_tokens should be \[1,\s*(\d+)\]/i)
return output ? usableLimit(Number(output[1])) : null
return output ? Number(output[1]) : null
}
/** The usable output ceiling named in a rejection, or null. */
export function parseOutputTokenLimit(error: unknown): number | null {
const text = rejectionText(error)
const ceiling = text ? readCeiling(text) : null
return ceiling === null ? null : usableLimit(ceiling)
}
/**
* Thinking budget the provider adds on top of maxOutputTokens. Bedrock and
* Anthropic send maxOutputTokens + budgetTokens as max_tokens, so a ceiling in
* their rejection covers both.
*/
function thinkingBudget(providerOptions: unknown): number {
const options = providerOptions as
| {
bedrock?: {
reasoningConfig?: { type?: string; budgetTokens?: unknown }
}
anthropic?: {
thinking?: { type?: string; budgetTokens?: unknown }
}
}
| undefined
const config =
options?.bedrock?.reasoningConfig ?? options?.anthropic?.thinking
return config?.type === "enabled" && typeof config.budgetTokens === "number"
? config.budgetTokens
: 0
}
/**
* The budget to retry with after a rejection, or null to surface the error.
*/
export function retryOutputTokens(
error: unknown,
params: { maxOutputTokens?: number; providerOptions?: unknown },
): number | null {
const requested = params.maxOutputTokens
const text = rejectionText(error)
if (!requested || !text) return null
const ceiling = readCeiling(text)
if (ceiling !== null) {
// The ceiling applies to what was actually sent, thinking included,
// so the retry has to leave room for the thinking too.
const thinking = thinkingBudget(params.providerOptions)
if (ceiling >= requested + thinking) return null
return usableLimit(ceiling - thinking)
}
// Names the budget parameter, but in a format we cannot read a number from
if (/max_\w*tokens/i.test(text) && requested > FALLBACK_OUTPUT_TOKENS) {
return FALLBACK_OUTPUT_TOKENS
}
return null
}
/**
@@ -103,17 +178,15 @@ export function withOutputTokenLimitFallback(
try {
return await doStream()
} catch (error) {
const limit = parseOutputTokenLimit(error)
const requested = params.maxOutputTokens
if (!limit || !requested || limit >= requested) throw error
const retry = retryOutputTokens(error, params)
if (!retry) throw error
console.warn(
`[maxOutputTokens] ${requested} rejected, retrying with ${limit}`,
`[maxOutputTokens] ${params.maxOutputTokens} rejected, retrying with ${retry}`,
)
return await inner.doStream({
...params,
maxOutputTokens: limit,
maxOutputTokens: retry,
})
}
},
@@ -135,11 +208,17 @@ function validBudget(value: string | null | undefined): number | null {
* 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.
*
* On the server's credentials the user setting can only lower the server value,
* so MAX_OUTPUT_TOKENS keeps capping what the server pays for.
*/
export function resolveMaxOutputTokens(headerValue: string | null): number {
return (
validBudget(headerValue) ??
validBudget(process.env.MAX_OUTPUT_TOKENS) ??
DEFAULT_MAX_OUTPUT_TOKENS
)
export function resolveMaxOutputTokens(
headerValue: string | null,
usesServerCredentials: boolean,
): number {
const header = validBudget(headerValue)
const server =
validBudget(process.env.MAX_OUTPUT_TOKENS) ?? DEFAULT_MAX_OUTPUT_TOKENS
if (header === null) return server
return usesServerCredentials ? Math.min(header, server) : header
}
+6 -3
View File
@@ -1,4 +1,4 @@
import { extractText, getDocumentProxy } from "unpdf"
import { extractText } from "unpdf"
// Maximum characters allowed for extracted text (configurable via env)
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
@@ -14,6 +14,7 @@ const TEXT_EXTENSIONS = [
".json",
".csv",
".xml",
".svg",
".html",
".css",
".js",
@@ -43,8 +44,10 @@ const TEXT_EXTENSIONS = [
*/
export async function extractPdfText(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
const pdf = await getDocumentProxy(new Uint8Array(buffer))
const { text } = await extractText(pdf, { mergePages: true })
// Pass raw bytes so unpdf destroys the PDF document when it is done
const { text } = await extractText(new Uint8Array(buffer), {
mergePages: true,
})
return text as string
}
+200
View File
@@ -0,0 +1,200 @@
import { createGateway } from "ai"
import { getModelInfo } from "@/lib/model-catalog"
import {
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
/** A model a provider offers. tools is false when it cannot call tools. */
export interface ListedModel {
id: string
tools?: boolean
}
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
export function canListModels(provider: ProviderName): boolean {
return (
Object.hasOwn(PROVIDER_INFO, provider) &&
!!PROVIDER_INFO[provider].modelList
)
}
// Models in OpenAI-style lists that are not for chat
const NON_CHAT =
/(?:^|[-/_])(?:embed(?:ding)?s?|whisper|tts|transcribe|dall-e|moderation|rerank|realtime|sora)(?:$|[-/_])|gpt-image/i
const NON_CHAT_AIHUBMIX_TYPES = new Set([
"embedding",
"image_generation",
"rerank",
"transcription",
"tts",
"video",
])
/** Chat model ids from AIHubMix's public model list */
export function extractAihubmixModelIds(payload: unknown): string[] {
const data = (payload as { data?: unknown })?.data
if (!Array.isArray(data)) return []
const ids = new Set<string>()
for (const item of data) {
const record = item as { model_id?: unknown; types?: unknown }
if (typeof record?.model_id !== "string" || !record.model_id.trim()) {
continue
}
const types = new Set(
typeof record.types === "string"
? record.types.split(",").map((t) => t.trim())
: [],
)
if (!types.has("llm")) continue
if ([...NON_CHAT_AIHUBMIX_TYPES].some((t) => types.has(t))) continue
ids.add(record.model_id.trim())
}
return [...ids]
}
/** GET a JSON list; a failed request carries its status for the error hint */
async function getJson(
url: string,
headers: Record<string, string>,
fetchFn: typeof fetch,
): Promise<any> {
const response = await fetchFn(url, {
headers,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
throw Object.assign(
new Error(`The model list request failed (${response.status})`),
{ statusCode: response.status },
)
}
return response.json()
}
/**
* Where to list from without the user's base URL: where chat goes then. For
* Ollama that is the server's Ollama, else the SDK's local default; a local
* default in PROVIDER_INFO (SGLang's) only fills the settings form.
*/
function listFallbackUrl(provider: ProviderName): string {
if (provider === "ollama") {
return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api"
}
const url = PROVIDER_INFO[provider].defaultBaseUrl
return url?.startsWith("https://") ? url : ""
}
/**
* The provider's chat models, with tool support from the provider's own
* data or else models.dev. Only the client's key is used, so the server's
* keys never go to a URL the client chose.
*/
export async function listProviderModels(
provider: ProviderName,
{ apiKey, baseUrl }: { apiKey?: string; baseUrl?: string },
fetchFn: typeof fetch = fetch,
): Promise<ListedModel[]> {
const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider))
const bearer: Record<string, string> = apiKey
? { Authorization: `Bearer ${apiKey}` }
: {}
let models: ListedModel[]
// AIHubMix has a public list, unless the user points to another
// endpoint, which is OpenAI-compatible
const style =
provider === "aihubmix" &&
baseUrl &&
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
? "openai"
: PROVIDER_INFO[provider].modelList
switch (style) {
case "anthropic": {
const data = await getJson(
`${base}/models?limit=1000`,
{
"x-api-key": apiKey ?? "",
"anthropic-version": "2023-06-01",
},
fetchFn,
)
models = (data.data ?? []).map((m: { id: string }) => ({
id: m.id,
}))
break
}
case "google": {
// The key goes in a header: in the URL it would end up in logs
const data = await getJson(
`${base}/models?pageSize=1000`,
{ "x-goog-api-key": apiKey ?? "" },
fetchFn,
)
models = (data.models ?? [])
.filter((m: { supportedGenerationMethods?: string[] }) =>
m.supportedGenerationMethods?.includes("generateContent"),
)
.map((m: { name: string }) => ({
id: m.name.replace(/^models\//, ""),
}))
break
}
case "ollama": {
const api = base.endsWith("/api") ? base : `${base}/api`
const data = await getJson(`${api}/tags`, bearer, fetchFn)
models = (data.models ?? []).map((m: { name: string }) => ({
id: m.name,
}))
break
}
case "openrouter": {
const data = await getJson(`${base}/models`, bearer, fetchFn)
models = (data.data ?? []).map(
(m: { id: string; supported_parameters?: string[] }) => ({
id: m.id,
...(m.supported_parameters && {
tools: m.supported_parameters.includes("tools"),
}),
}),
)
break
}
case "gateway": {
const { models: entries } = await createGateway({
...(apiKey && { apiKey }),
...(baseUrl && { baseURL: base }),
fetch: fetchFn,
}).getAvailableModels()
models = entries
.filter((m) => !m.modelType || m.modelType === "language")
.map((m) => ({ id: m.id }))
break
}
case "aihubmix": {
const data = await getJson(AIHUBMIX_MODELS_ENDPOINT, {}, fetchFn)
models = extractAihubmixModelIds(data).map((id) => ({ id }))
break
}
default: {
if (!base) {
throw new Error(
`${PROVIDER_INFO[provider].label} needs a base URL to list its models.`,
)
}
const data = await getJson(`${base}/models`, bearer, fetchFn)
models = (data.data ?? [])
.map((m: { id: string }) => ({ id: m.id }))
.filter((m: ListedModel) => !NON_CHAT.test(m.id))
}
}
return models.map((m) => ({
...m,
tools: m.tools ?? getModelInfo(provider, m.id)?.tools,
}))
}
+16 -2
View File
@@ -47,11 +47,14 @@ export interface FlattenedServerModel {
/**
* Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production"
* e.g., "OpenAI Production" → "openai-production", "主力" → "4e3b-529b"
* Non-ASCII characters become their hex code point so CJK names stay
* distinct; the id is sent in HTTP headers, which must be ASCII.
*/
function slugify(name: string): string {
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^\p{ASCII}]/gu, (c) => `-${c.codePointAt(0)?.toString(16)}-`)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
@@ -189,6 +192,7 @@ export async function loadFlattenedServerModels(): Promise<
const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = []
const seenIds = new Set<string>()
for (const p of cfg.providers) {
const providerLabel =
@@ -199,6 +203,16 @@ export async function loadFlattenedServerModels(): Promise<
for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}`
// Names that differ only in case or punctuation share a slug.
// A repeated id would always resolve to the first provider's
// credentials, so drop it instead.
if (seenIds.has(id)) {
console.warn(
`[server-model-config] Skipping duplicate model id "${id}". Provider names must differ in letters or digits.`,
)
continue
}
seenIds.add(id)
// Default model priority:
// 1. From ai-models.json: first model of provider with default: true
+31 -23
View File
@@ -1,5 +1,6 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
import { toast } from "sonner"
import type { Template } from "./template-storage"
// Constants
@@ -61,6 +62,7 @@ let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
// A failed or lost connection is not cached: the next call reopens it
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
@@ -88,6 +90,28 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
}
}
},
blocked() {
// An older tab keeps the DB open, so the upgrade has to wait
toast.warning(
"Please close other tabs of this app to finish updating chat storage.",
{ id: "idb-upgrade-blocked", duration: 10000 },
)
},
blocking(_currentVersion, _blockedVersion, event) {
// Another tab needs to upgrade the DB: close our connection so
// it is not stuck, and reopen on the next call
const db = event.target as IDBDatabase
db.close()
dbPromise = null
},
terminated() {
// The browser closed the connection (e.g. Safari after a long
// time in the background)
dbPromise = null
},
}).catch((error) => {
dbPromise = null
throw error
})
}
return dbPromise
@@ -145,6 +169,8 @@ export async function getSession(id: string): Promise<ChatSession | null> {
}
}
// Returns false on failure (e.g. storage quota exceeded). Other sessions are
// never deleted automatically; the caller tells the user instead.
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
@@ -152,29 +178,11 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
await db.put(STORE_NAME, session)
return true
} catch (error) {
// Handle quota exceeded
if (
error instanceof DOMException &&
error.name === "QuotaExceededError"
) {
console.warn("Storage quota exceeded, deleting oldest session...")
await deleteOldestSession()
// Retry once
try {
const db = await getDB()
await db.put(STORE_NAME, session)
return true
} catch (retryError) {
console.error(
"Failed to save session after cleanup:",
retryError,
)
return false
}
} else {
console.error("Failed to save session:", error)
return false
}
console.error("Failed to save session:", error)
// Reopen the connection next time in case it was lost (Safari reports
// "Connection to Indexed Database server lost" without closing it)
dbPromise = null
return false
}
}
+16
View File
@@ -115,3 +115,19 @@ export async function isPrivateUrl(urlString: string): Promise<boolean> {
export function allowPrivateUrls(): boolean {
return process.env.ALLOW_PRIVATE_URLS !== "false"
}
/**
* A fetch for requests to a base URL the client chose. With private URLs
* blocked, a public URL could still redirect the request to an internal
* host, so redirects are refused. Undefined when private URLs are allowed.
*/
export function redirectGuardedFetch(): typeof fetch | undefined {
if (allowPrivateUrls()) return undefined
return async (input, init) => {
const response = await fetch(input, { ...init, redirect: "manual" })
if (response.status >= 300 && response.status < 400) {
throw new Error("Redirects are not allowed for custom base URLs")
}
return response
}
}
+10 -33
View File
@@ -1,11 +1,14 @@
/**
* System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*
* Token counting utilities are in a separate file (token-counter.ts) to avoid
* WebAssembly issues with Next.js server-side rendering.
*/
import {
SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
// Default system prompt (~1900 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
@@ -41,7 +44,7 @@ parameters: {
tool name: edit_diagram
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
parameters: {
edits: Array<{search: string, replace: string}>
operations: Array<{operation: "update" | "add" | "delete", cell_id: string, new_xml?: string}>
}
---Tool3---
tool name: append_diagram
@@ -241,21 +244,7 @@ const EXTENDED_ADDITIONS = `
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
${SWIMLANE_EXAMPLE}
\`\`\`
### append_diagram Details
@@ -318,12 +307,7 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d
### Two edges between same nodes (CORRECT - no overlap):
\`\`\`xml
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
${TWO_EDGES_EXAMPLE}
\`\`\`
### Edge with single waypoint (simple detour):
@@ -342,14 +326,7 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d
**WRONG:** Direct diagonal line crosses over Develop
**CORRECT:** Route around the OUTSIDE (go right first, then up)
\`\`\`xml
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="750" y="80"/>
<mxPoint x="750" y="150"/>
</Array>
</mxGeometry>
</mxCell>
${WAYPOINT_EXAMPLE}
\`\`\`
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
+136 -21
View File
@@ -32,6 +32,8 @@ export interface ModelConfig {
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
validated?: boolean // Has this model been validated
validationError?: string // Error message if validation failed
validationWarning?: string // Passed, but e.g. did not call a tool
responseTime?: number // Milliseconds the last test took
}
// Provider configuration
@@ -120,22 +122,45 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
atlascloud: "openai",
}
// Provider metadata
/** How a provider lists its models (see lib/provider-models.ts) */
export type ModelListStyle =
| "openai"
| "anthropic"
| "google"
| "ollama"
| "openrouter"
| "aihubmix"
| "gateway"
// Provider metadata. apiKeyUrl is the page where users create a key.
// modelList is missing where a key alone cannot list the models (Bedrock,
// Vertex, Azure) or the list is not reliable (Doubao, MiniMax).
export const PROVIDER_INFO: Record<
ProviderName,
{ label: string; defaultBaseUrl?: string }
{
label: string
defaultBaseUrl?: string
apiKeyUrl?: string
modelList?: ModelListStyle
}
> = {
openai: {
label: "OpenAI",
defaultBaseUrl: "https://api.openai.com/v1",
apiKeyUrl: "https://platform.openai.com/api-keys",
modelList: "openai",
},
anthropic: {
label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1",
apiKeyUrl: "https://platform.claude.com/settings/keys",
modelList: "anthropic",
},
google: {
label: "Google",
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyUrl: "https://aistudio.google.com/apikey",
modelList: "google",
},
vertexai: { label: "Google Vertex AI" },
azure: {
@@ -146,77 +171,114 @@ export const PROVIDER_INFO: Record<
ollama: {
label: "Ollama",
defaultBaseUrl: "https://ollama.com/api",
apiKeyUrl: "https://ollama.com/settings/keys",
modelList: "ollama",
},
openrouter: {
label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
apiKeyUrl: "https://openrouter.ai/keys",
modelList: "openrouter",
},
aihubmix: {
label: "AIHubMix",
defaultBaseUrl: "https://aihubmix.com/v1",
apiKeyUrl: "https://aihubmix.com/token",
modelList: "aihubmix",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
apiKeyUrl: "https://platform.deepseek.com/api_keys",
modelList: "openai",
},
siliconflow: {
label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1",
apiKeyUrl: "https://cloud.siliconflow.cn/account/ak",
modelList: "openai",
},
sglang: {
label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1",
modelList: "openai",
},
gateway: {
label: "AI Gateway",
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
apiKeyUrl: "https://vercel.com/ai-gateway",
modelList: "gateway",
},
edgeone: { label: "EdgeOne Pages" },
doubao: {
label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
apiKeyUrl:
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
},
modelscope: {
label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
modelList: "openai",
},
glm: {
label: "GLM (Zhipu)",
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
modelList: "openai",
},
qwen: {
label: "Qwen (Alibaba)",
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
modelList: "openai",
},
qiniu: {
label: "Qiniu",
defaultBaseUrl: "https://api.qnaigc.com/v1",
apiKeyUrl: "https://www.qiniu.com/ai/models",
modelList: "openai",
},
kimi: {
label: "Kimi (Moonshot)",
defaultBaseUrl: "https://api.moonshot.cn/v1",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
modelList: "openai",
},
minimax: {
label: "MiniMax",
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
apiKeyUrl:
"https://platform.minimaxi.com/user-center/basic-information/interface-key",
},
novita: {
label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai",
apiKeyUrl: "https://novita.ai/dashboard/key",
modelList: "openai",
},
mimo: {
label: "MiMo (Xiaomi)",
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
modelList: "openai",
},
atlascloud: {
label: "Atlas Cloud",
defaultBaseUrl: "https://api.atlascloud.ai/v1",
apiKeyUrl: "https://www.atlascloud.ai/console/api-keys",
modelList: "openai",
},
}
// Suggested models per provider for quick add
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
openai: [
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6-astra",
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5.4-pro",
@@ -230,7 +292,13 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"gpt-4o-mini",
],
anthropic: [
// Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
// Claude 5 series (latest)
"claude-opus-5-5",
"claude-sonnet-5-5",
"claude-fable-5-1",
"claude-opus-5",
"claude-sonnet-5",
// Claude 4.8 / 4.7 / 4.6 series (dateless pinned IDs)
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-haiku-4-5",
@@ -279,39 +347,47 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"o3",
"o4-mini",
],
// Newer models only answer through an inference profile id (the region
// prefix). Each id here was called once on 2026-10-04.
bedrock: [
// Anthropic Claude
"anthropic.claude-opus-4-8",
"anthropic.claude-opus-4-7",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-3-5-haiku-20241022-v1:0",
// Anthropic Claude ("global." works from any region)
"global.anthropic.claude-opus-5-5",
"global.anthropic.claude-sonnet-5-5",
"global.anthropic.claude-fable-5-1",
"global.anthropic.claude-opus-5",
"global.anthropic.claude-sonnet-5",
"global.anthropic.claude-opus-4-8",
"global.anthropic.claude-opus-4-7",
"global.anthropic.claude-sonnet-4-6",
"global.anthropic.claude-opus-4-6-v1",
"global.anthropic.claude-opus-4-5-20251101-v1:0",
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
"global.anthropic.claude-sonnet-4-20250514-v1:0",
// Amazon Nova
"amazon.nova-2-lite-v1:0",
"amazon.nova-premier-v1:0",
"us.amazon.nova-2-lite-v1:0",
"amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0",
// Meta Llama
"meta.llama4-maverick-17b-instruct-v1:0",
"meta.llama4-scout-17b-instruct-v1:0",
"meta.llama3-3-70b-instruct-v1:0",
"us.meta.llama4-maverick-17b-instruct-v1:0",
"us.meta.llama4-scout-17b-instruct-v1:0",
"us.meta.llama3-3-70b-instruct-v1:0",
// Mistral
"mistral.mistral-large-3-675b-instruct",
"mistral.pixtral-large-2502-v1:0",
"us.mistral.pixtral-large-2502-v1:0",
],
openrouter: [
// Anthropic
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"anthropic/claude-fable-5.1",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
// OpenAI
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"openai/gpt-5.5",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
@@ -400,6 +476,10 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"default",
],
gateway: [
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"openai/gpt-5.5",
"anthropic/claude-opus-4.7",
"google/gemini-3.1-pro-preview",
@@ -529,3 +609,38 @@ export function findModelById(
): FlattenedModel | undefined {
return flattenModels(config).find((m) => m.id === modelId)
}
/**
* A base URL the way the SDKs expect it: no spaces, no trailing slash, and
* no endpoint path users often paste along (".../v1/chat/completions"),
* which the SDK would append a second time.
*/
export function normalizeBaseUrl(url: string): string {
return url
.trim()
.replace(/\/+$/, "")
.replace(/\/(?:chat\/completions|completions|messages|responses)$/, "")
}
/** Where a chat request goes for a base URL, or null when the SDK decides */
export function chatRequestUrl(
provider: ProviderName,
baseUrl: string,
): string | null {
const url = normalizeBaseUrl(baseUrl)
if (!url) return null
if (provider === "anthropic") return `${url}/messages`
// These SDKs build their own paths (or, for MiniMax, pick the protocol
// from the URL)
const ownPaths: ProviderName[] = [
"google",
"vertexai",
"azure",
"bedrock",
"ollama",
"gateway",
"minimax",
"edgeone",
]
return ownPaths.includes(provider) ? null : `${url}/chat/completions`
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { z } from "zod"
import { getApiEndpoint } from "@/lib/base-path"
import { STORAGE_KEYS } from "@/lib/storage"
export interface UrlData {
url: string
@@ -18,7 +19,11 @@ const UrlResponseSchema = z.object({
export async function extractUrlContent(url: string): Promise<UrlData> {
const response = await fetch(getApiEndpoint("/api/parse-url"), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({ url }),
})
+55 -61
View File
@@ -27,78 +27,72 @@ export function useFileProcessor() {
const handleFileChange = async (newFiles: File[]) => {
setFiles(newFiles)
// Extract text immediately for new PDF/text files
for (const file of newFiles) {
const needsExtraction =
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file)
if (needsExtraction) {
// Mark as extracting
setPdfData((prev) => {
const next = new Map(prev)
next.set(file, {
text: "",
charCount: 0,
isExtracting: true,
})
return next
})
const pending = newFiles.filter(
(file) =>
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file),
)
// Extract text asynchronously
try {
let text: string
if (isPdfFile(file)) {
text = await extractPdfText(file)
} else {
text = await extractTextFileContent(file)
}
// Before any await: drop data for removed files and mark every new
// file as extracting, so queued files also block sending
setPdfData((prev) => {
const next = new Map<File, FileData>()
for (const file of newFiles) {
const existing = prev.get(file)
if (existing) next.set(file, existing)
}
for (const file of pending) {
next.set(file, { text: "", charCount: 0, isExtracting: true })
}
return next
})
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
// Remove the file from the list
setFiles((prev) => prev.filter((f) => f !== file))
continue
}
// Extract one file at a time
for (const file of pending) {
try {
let text: string
if (isPdfFile(file)) {
text = await extractPdfText(file)
} else {
text = await extractTextFileContent(file)
}
setPdfData((prev) => {
const next = new Map(prev)
next.set(file, {
text,
charCount: text.length,
isExtracting: false,
})
return next
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(`Failed to read file: ${file.name}`)
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
// Remove the file from the list
setFiles((prev) => prev.filter((f) => f !== file))
continue
}
setPdfData((prev) => {
// The file was removed while extracting
if (!prev.has(file)) return prev
const next = new Map(prev)
next.set(file, {
text,
charCount: text.length,
isExtracting: false,
})
return next
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(`Failed to read file: ${file.name}`)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
}
}
// Clean up pdfData for removed files
setPdfData((prev) => {
const next = new Map(prev)
for (const key of prev.keys()) {
if (!newFiles.includes(key)) {
next.delete(key)
}
}
return next
})
}
return {
+19 -1375
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -10,9 +10,11 @@ const nextConfig: NextConfig = {
env: {
APP_VERSION: packageJson.version,
},
// Include instrumentation.ts in standalone build for Langfuse telemetry
// Include instrumentation.ts in standalone build for Langfuse telemetry,
// and the shape library docs get_shape_library reads at run time
outputFileTracingIncludes: {
"*": ["./instrumentation.ts"],
"/api/chat": ["./docs/shape-libraries/*.md"],
},
}
+252 -270
View File
@@ -9,16 +9,16 @@
"version": "0.4.16",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/azure": "^3.0.0",
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@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",
"@ai-sdk/amazon-bedrock": "^4.0.191",
"@ai-sdk/anthropic": "^3.0.127",
"@ai-sdk/azure": "^3.0.133",
"@ai-sdk/deepseek": "^2.0.71",
"@ai-sdk/google": "^3.0.130",
"@ai-sdk/google-vertex": "^4.0.210",
"@ai-sdk/openai": "^3.0.124",
"@ai-sdk/openai-compatible": "^2.0.81",
"@ai-sdk/react": "^3.0.303",
"@aihubmix/ai-sdk-provider": "^2.2.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
@@ -28,7 +28,7 @@
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^2.0.0",
"@openrouter/ai-sdk-provider": "^2.10.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
@@ -44,7 +44,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.1",
"ai": "^6.0.300",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -56,7 +56,7 @@
"nanoid": "^5.0.0",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^3.0.0",
"ollama-ai-provider-v2": "^3.6.0",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.2",
@@ -66,6 +66,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"saxes": "^6.0.0",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
@@ -93,7 +94,7 @@
"@vitest/coverage-v8": "^4.0.16",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^41.0.0",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.28.0",
"eslint": "9.39.5",
@@ -123,15 +124,15 @@
"license": "MIT"
},
"node_modules/@ai-sdk/amazon-bedrock": {
"version": "4.0.113",
"resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.113.tgz",
"integrity": "sha512-qoeF2ghkYqHY4u68rasZopYsRuGnRwgqSfe6rhC/jM6W73Z7TU5v9QcfDYKt9dgp19YHY9EqiX3SXVC+uPGkRQ==",
"version": "4.0.191",
"resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.191.tgz",
"integrity": "sha512-7wgtq8On8oqmofGIGtYl8EOEbawnfiPbIbWpu2ksGNF065J8mbpE6DhkHNv0dpPbvnn4txL4sflUwUPwHw7/SQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.81",
"@ai-sdk/openai": "3.0.68",
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27",
"@ai-sdk/anthropic": "3.0.127",
"@ai-sdk/openai": "3.0.124",
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57",
"@smithy/eventstream-codec": "^4.0.1",
"@smithy/util-utf8": "^4.0.0",
"aws4fetch": "^1.0.20"
@@ -143,75 +144,14 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/anthropic": {
"version": "3.0.81",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.81.tgz",
"integrity": "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/openai": {
"version": "3.0.68",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.68.tgz",
"integrity": "sha512-FCs/DPr4M95UyZ/ABHJmTmCEYRCka/4J0Bna0nsd78QCdGIS0X/zhn+fVzB7mZJo7464uOWYUjROx9PGNGOb0w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.27",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.47",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.47.tgz",
"integrity": "sha512-E6Z3i/xvxGDxRskMMbuX9+xDK4l5LesrP2O7YQ0CcbAkYP25qTo/kYGf/AsJrLkNIY23HeO/kheUWtG1XZllDA==",
"version": "3.0.127",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.127.tgz",
"integrity": "sha512-Inff1DmPRVWi6QGr9YL2wiA4maILZuve9I8id/O4keQEoq6fJv6EfvkrSFw/nNvm2jiTcIUfDrpczeHTuhl9mg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57"
},
"engines": {
"node": ">=18"
@@ -221,14 +161,15 @@
}
},
"node_modules/@ai-sdk/azure": {
"version": "3.0.34",
"resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.34.tgz",
"integrity": "sha512-nnOFtgvZYOa6XIeAm18i56NX77Yu4Bd+Tnbt85LGUEqwJFR54kFTlR1nm3BAJCphHrmQteJd1P3QErtyoXig8A==",
"version": "3.0.133",
"resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.133.tgz",
"integrity": "sha512-iVCT1q6bRPWjzisu7Xy1bc1yWtegd3WSZzmi4+jkiFTKTxir703F5jH8mWPG0hPnqW/OjdkOrSzcQ063j7T/ng==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/openai": "3.0.33",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
"@ai-sdk/deepseek": "2.0.71",
"@ai-sdk/openai": "3.0.124",
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57"
},
"engines": {
"node": ">=18"
@@ -238,13 +179,13 @@
}
},
"node_modules/@ai-sdk/deepseek": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.20.tgz",
"integrity": "sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA==",
"version": "2.0.71",
"resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.71.tgz",
"integrity": "sha512-2uLtZBgONfzEP7tZOg23sBzi33n1/gcW1zLNNw96/PLt9vdqDYIkGQnKRd6F+/0Wwy77jVulU+gzpiudk3f0UQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57"
},
"engines": {
"node": ">=18"
@@ -254,14 +195,14 @@
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.55",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.55.tgz",
"integrity": "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw==",
"version": "3.0.209",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.209.tgz",
"integrity": "sha512-CjCBzC35lRZ0LnUYLDfTB92p0Fr1Fu9Wrivc+LI0u3ox/DyZ0wPOHjt8DKBKHD/R4aZ86+JCFFuD9f8MFjAQFg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15",
"@vercel/oidc": "3.1.0"
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57",
"@vercel/oidc": "3.2.0"
},
"engines": {
"node": ">=18"
@@ -271,13 +212,13 @@
}
},
"node_modules/@ai-sdk/google": {
"version": "3.0.31",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.31.tgz",
"integrity": "sha512-RVNz8WFSIRbXbYDBE6JvlE2escWPJimBCs22LzKEYH7DNfl/X7cHNa1LFho4PsY6Ib0JmbzB8s2+i0wHs/wNCg==",
"version": "3.0.130",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.130.tgz",
"integrity": "sha512-DOhGfFT667LbopxMZU7kIiT4K8F2RNN8ziwmOxAfMbp3xTvAaxQ4ynPKNWBVXcCkGYDmzPb7QiazN1IRX8y3lw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57"
},
"engines": {
"node": ">=18"
@@ -287,15 +228,16 @@
}
},
"node_modules/@ai-sdk/google-vertex": {
"version": "4.0.63",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.63.tgz",
"integrity": "sha512-/RNi6KSB4162DDYeXHUKQc5jLPmiJMkhTswLwbfPUEPyyFjbxpBWgeAk/vS/u8jxT4IPyYQ8cD/uyvJibmtmww==",
"version": "4.0.210",
"resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.210.tgz",
"integrity": "sha512-7Jj41iWsTFUcv6pzji5AlW2QaMJt/PEMU/zwqji2H7U52WvZE345kyo+nKRJMFtHgSVqFmzmMUIM9gyeaXKRtw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.47",
"@ai-sdk/google": "3.0.31",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15",
"@ai-sdk/anthropic": "3.0.127",
"@ai-sdk/google": "3.0.130",
"@ai-sdk/openai-compatible": "2.0.81",
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57",
"google-auth-library": "^10.5.0"
},
"engines": {
@@ -306,13 +248,13 @@
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.33",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.33.tgz",
"integrity": "sha512-O/8SVKAiwFHkGAUfBnrLb7L2IjbpP9ySWbmOktOfa0KtzutZkmKNrJ5CtB5dj+lwuENbOuZeRsnsZdOjar7hig==",
"version": "3.0.124",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.124.tgz",
"integrity": "sha512-7DpRUPXzJ+S6XasEJBojzFCs04J3rNPs0FZFF3PEeix49IggZAwsMrqscUwB/+05+87r01SS/jeopQGOOL4AEQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57"
},
"engines": {
"node": ">=18"
@@ -322,42 +264,13 @@
}
},
"node_modules/@ai-sdk/openai-compatible": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.48.tgz",
"integrity": "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==",
"version": "2.0.81",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.81.tgz",
"integrity": "sha512-L14Jd0lAFKNM42l9TxlpwkNiyMlSvSvGySLc9GN4FB0qOo4/WmYOeLyn/CPhghIr0K4O6vH1y0nChxGljq2ccg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz",
"integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.27",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz",
"integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.8"
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57"
},
"engines": {
"node": ">=18"
@@ -367,9 +280,9 @@
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.18.tgz",
"integrity": "sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
@@ -379,30 +292,40 @@
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
"integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
"version": "4.0.57",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.57.tgz",
"integrity": "sha512-89a7sPvZqXsP3PigcAQLWntu/duIVEcN/RyChAB+j6p3C7NwW767sVttvL8r0+BetTg63uAXQoYHCZZueQKBSw==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider": "3.0.18",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
"eventsource-parser": "^3.0.8",
"undici": "^6.28.0"
},
"engines": {
"node": ">=18"
"node": ">=18.17"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider-utils/node_modules/undici": {
"version": "6.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
"integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@ai-sdk/react": {
"version": "3.0.102",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.102.tgz",
"integrity": "sha512-WPSYJxk/HM3SWhhxE+WrLhIMbaRLpDHWWYzUlrptXowwEoyliYBYZAkzip/gV8hybT2NyrWnsUh5KRO5iBdsQA==",
"version": "3.0.303",
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.303.tgz",
"integrity": "sha512-PeTBn25x8QxfIIpdUDOpWHfiIZxMRJCw04PhWNHI9hl+gtu+zGwAxWU+sy3PH/f/oEBBAp7eDDUDDjiuqwcw6g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "4.0.15",
"ai": "6.0.100",
"@ai-sdk/provider-utils": "4.0.57",
"ai": "6.0.300",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
@@ -414,9 +337,9 @@
}
},
"node_modules/@aihubmix/ai-sdk-provider": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.1.0.tgz",
"integrity": "sha512-AqK10PV5B4zWFBav5PRUhrWGYTHjC0s6cIbd4v9hBo6D9SqKv5o41B+dkl6IeIPrnZrMGLEE9Mn5R+VaEkhbdg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.2.1.tgz",
"integrity": "sha512-VUkSYFbrigs5ZF44LRSHEI/q1fZyLkExWvrgBG4w0ysGEE/XXt7fi9NgMG9UH1PQVBgnc/PP03CUhTM1nhmGxQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.0",
@@ -2660,16 +2583,6 @@
"@noble/ciphers": "^1.0.0"
}
},
"node_modules/@electron-internal/extract-zip": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
@@ -2852,50 +2765,25 @@
}
},
"node_modules/@electron/get": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^3.0.0",
"graceful-fs": "^4.2.11",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"progress": "^2.0.3",
"semver": "^7.6.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=22.12.0"
"node": ">=12"
},
"optionalDependencies": {
"undici": "^7.24.4"
}
},
"node_modules/@electron/get/node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@electron/get/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
"global-agent": "^3.0.0"
}
},
"node_modules/@electron/notarize": {
@@ -5925,9 +5813,9 @@
}
},
"node_modules/@openrouter/ai-sdk-provider": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.8.1.tgz",
"integrity": "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.10.0.tgz",
"integrity": "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
@@ -8818,6 +8706,17 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -9350,9 +9249,9 @@
]
},
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
@@ -9618,15 +9517,15 @@
}
},
"node_modules/ai": {
"version": "6.0.100",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.100.tgz",
"integrity": "sha512-BIxhG7M7wvcWCF+IEnZi7WpkRLOM3jR2vJ0mMuohl2UB2i1R/ZUa1cHFel1xI8nWvyUpOoQXKqsM0BAH50EYSQ==",
"version": "6.0.300",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.300.tgz",
"integrity": "sha512-ZBT30eQTy6exP+EWJJxm3DMb34m9td8STy+hfnvKdjhyEQoM8vQN5YfFLTRVVTCONHoM+y5K+Tudb6PTFrpikA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.55",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15",
"@opentelemetry/api": "1.9.0"
"@ai-sdk/gateway": "3.0.209",
"@ai-sdk/provider": "3.0.18",
"@ai-sdk/provider-utils": "4.0.57",
"@opentelemetry/api": "^1.9.0"
},
"engines": {
"node": ">=18"
@@ -9635,15 +9534,6 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ai/node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -10496,6 +10386,16 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -12322,22 +12222,22 @@
}
},
"node_modules/electron": {
"version": "41.10.7",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.10.7.tgz",
"integrity": "sha512-AqIiefddlf5i+HYCGatr8VlBuEbWJpad4/yloBFYcMB2r57j7xhW1ogiJ+BfA5kxQyfmSs5yjwy54Me+wJn1jw==",
"version": "39.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron-internal/extract-zip": "^1.0.1",
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0"
"@electron/get": "^2.0.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js"
},
"engines": {
"node": ">= 22.12.0"
"node": ">= 12.20.55"
}
},
"node_modules/electron-builder": {
@@ -12671,6 +12571,23 @@
"node": ">=6 <7 || >=8"
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -13816,6 +13733,43 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"debug": "^4.1.1",
"get-stream": "^5.1.0",
"yauzl": "^2.10.0"
},
"bin": {
"extract-zip": "cli.js"
},
"engines": {
"node": ">= 10.17.0"
},
"optionalDependencies": {
"@types/yauzl": "^2.9.1"
}
},
"node_modules/extract-zip/node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pump": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
@@ -13888,6 +13842,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -14213,6 +14177,21 @@
"node": ">= 0.8"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-minipass": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
@@ -19524,39 +19503,26 @@
"license": "MIT"
},
"node_modules/ollama-ai-provider-v2": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-3.5.0.tgz",
"integrity": "sha512-+s/aYIYa91z2Vk3AkGAz3BaPAQ0flS2eFZD3BN2mD/N6W6YQbcookyu6pc2cbc8SP5VGpNB857WJ0eHDjKXsXw==",
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-3.6.0.tgz",
"integrity": "sha512-1Om3FVJYhBwkAr5kQ+BX1s/tdVdtVdoFQWrX4PBQHDHPISyGt24CjhtggEjUYpy5ait0YeVfZwEpIYjgD8Ih7Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "^3.0.8",
"@ai-sdk/provider-utils": "^4.0.19"
"@ai-sdk/provider": "^3.0.10",
"@ai-sdk/provider-utils": "^4.0.27"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"funding": {
"type": "Buy Me a Coffee",
"url": "https://buymeacoffee.com/nordwestt"
},
"peerDependencies": {
"ai": "^5.0.0 || ^6.0.0",
"zod": "^4.0.16"
}
},
"node_modules/ollama-ai-provider-v2/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz",
"integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -19980,6 +19946,13 @@
"url": "https://github.com/sponsors/jet2jet"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -21406,7 +21379,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
@@ -25189,7 +25161,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
@@ -25299,6 +25270,17 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+15 -14
View File
@@ -31,16 +31,16 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
"@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/azure": "^3.0.0",
"@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google": "^3.0.0",
"@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",
"@ai-sdk/amazon-bedrock": "^4.0.191",
"@ai-sdk/anthropic": "^3.0.127",
"@ai-sdk/azure": "^3.0.133",
"@ai-sdk/deepseek": "^2.0.71",
"@ai-sdk/google": "^3.0.130",
"@ai-sdk/google-vertex": "^4.0.210",
"@ai-sdk/openai": "^3.0.124",
"@ai-sdk/openai-compatible": "^2.0.81",
"@ai-sdk/react": "^3.0.303",
"@aihubmix/ai-sdk-provider": "^2.2.1",
"@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18",
@@ -50,7 +50,7 @@
"@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^2.0.0",
"@openrouter/ai-sdk-provider": "^2.10.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
@@ -66,7 +66,7 @@
"@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.1",
"ai": "^6.0.300",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -78,7 +78,7 @@
"nanoid": "^5.0.0",
"negotiator": "^1.0.0",
"next": "^16.0.7",
"ollama-ai-provider-v2": "^3.0.0",
"ollama-ai-provider-v2": "^3.6.0",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.2",
@@ -88,6 +88,7 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1",
"saxes": "^6.0.0",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
@@ -126,7 +127,7 @@
"@vitest/coverage-v8": "^4.0.16",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^41.0.0",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.28.0",
"eslint": "9.39.5",
+16 -6
View File
@@ -25,10 +25,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
- **Version History**: Restore previous diagram versions with visual thumbnails
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
- **Self-contained**: Embedded server, no external dependencies required
## Use Case Examples
@@ -72,11 +75,16 @@ between user, client app, auth server, and resource server
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
| `get_drawing_guide` | Return the drawing rules again |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
| `create_new_diagram` | Create a new diagram from XML |
| `edit_diagram` | Edit diagram by ID-based operations |
| `load_diagram` | Load a `.drawio` file from disk |
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
| `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file |
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
## How It Works
@@ -95,6 +103,8 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams; `off` turns auto-save off |
| `DEBUG` | unset | Set to `true` to log debug messages |
## Links
+30 -10
View File
@@ -104,27 +104,41 @@ Use the standard MCP configuration with:
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing
- **Self-check**: The AI can take a screenshot of the rendered diagram and fix overlapping shapes or edges that cross shapes
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram`
- **Multi-page**: List, add, rename, and delete pages, and edit any page
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, so it survives a restart of the MCP client
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
| `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`, `.png`, or `.svg` file |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
| `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) |
## Continue a Diagram Later
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself.
The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
## How It Works
```
@@ -154,6 +168,8 @@ Use the standard MCP configuration with:
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files. Set to `off` to turn auto-save off. |
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
### Private Deployment (Self-hosted draw.io)
@@ -179,7 +195,7 @@ You can deploy your own draw.io instance using the official Docker image:
docker run -d -p 8080:8080 jgraph/drawio
```
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works fully offline.
## Troubleshooting
@@ -208,6 +224,10 @@ Call `start_session` first to open the browser window.
Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server.
### Screenshot or PNG/SVG export times out
PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again.
## License
Apache-2.0
+47 -3
View File
@@ -1,17 +1,19 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.3",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.3",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@modelcontextprotocol/sdk": "^1.31.0",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"pako": "^2.2.0",
"saxes": "^6.0.0",
"zod": "^4.0.0"
},
"bin": {
@@ -19,6 +21,7 @@
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pako": "^2.0.4",
"tsx": "^4.19.0",
"typescript": "^5",
"vitest": "^4.1.8"
@@ -908,6 +911,13 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -2600,6 +2610,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2834,6 +2860,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -3379,6 +3417,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@next-ai-drawio/mcp-server",
"version": "0.2.3",
"version": "0.3.0",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module",
"main": "dist/index.js",
@@ -8,7 +8,8 @@
"next-ai-drawio-mcp": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"build": "tsc && node scripts/copy-assets.mjs",
"check-package": "node scripts/check-package.mjs",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
@@ -38,13 +39,16 @@
"access": "public"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4",
"@modelcontextprotocol/sdk": "^1.31.0",
"linkedom": "^0.18.0",
"open": "^11.0.0",
"pako": "^2.2.0",
"saxes": "^6.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/pako": "^2.0.4",
"tsx": "^4.19.0",
"typescript": "^5",
"vitest": "^4.1.8"
@@ -0,0 +1,23 @@
// Fail if the npm package would miss files the server reads at runtime.
// Tests run from src/ (tsx) and cannot notice a broken dist/ copy step.
// Run after `npm run build`.
import { execSync } from "node:child_process"
const REQUIRED = [
"dist/index.js",
"dist/shape-libraries/aws4.md",
"dist/preview/index.html",
"dist/preview/preview.css",
"dist/preview/preview.js",
]
const [pack] = JSON.parse(
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
)
const files = new Set(pack.files.map((f) => f.path))
const missing = REQUIRED.filter((f) => !files.has(f))
if (missing.length > 0) {
console.error(`npm package is missing: ${missing.join(", ")}`)
process.exit(1)
}
console.log(`npm package OK (${files.size} files)`)
@@ -0,0 +1,22 @@
// Copy non-TypeScript assets into dist/ after tsc, so they ship in the npm
// package ("files": ["dist"]).
import { cpSync, mkdirSync, readdirSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
// Shape library docs live at the repository root, shared with the web app
const libSrc = join(pkg, "../../docs/shape-libraries")
const libDest = join(pkg, "dist/shape-libraries")
mkdirSync(libDest, { recursive: true })
for (const file of readdirSync(libSrc)) {
if (file.endsWith(".md") && file !== "README.md") {
cpSync(join(libSrc, file), join(libDest, file))
}
}
// Browser preview page (HTML, CSS and script)
cpSync(join(pkg, "src/preview"), join(pkg, "dist/preview"), {
recursive: true,
})
+100 -35
View File
@@ -7,7 +7,10 @@
* first page is targeted (the "active page by convention" — see pages.ts).
*/
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
import { decompressPageContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface DiagramOperation {
operation: "update" | "add" | "delete"
@@ -26,6 +29,56 @@ export interface ApplyOperationsResult {
errors: OperationError[]
}
// Cells with links, tooltips or custom data are stored as
// <UserObject id="..."><mxCell .../></UserObject> (or <object>): the id sits
// on the wrapper, so the wrapper is treated as the cell.
const CELL_SELECTOR = "mxCell, UserObject, object"
/**
* Return the <root> of a <diagram> page, creating it when missing. An empty
* page gets a blank model with the "0" and "1" root cells; a page whose text
* is draw.io's compressed format is decompressed in place. Returns null if
* the text is neither empty nor decompressible.
*/
function ensurePageRoot(doc: Document, page: Element): Element | null {
const existing = page.querySelector("root")
if (existing) return existing
let model = page.querySelector("mxGraphModel")
if (!model) {
const text = page.textContent?.trim() ?? ""
if (text) {
const xml = decompressPageContent(text)
if (!xml || getXmlSyntaxError(xml)) return null
const parsed = new DOMParser().parseFromString(xml, "text/xml")
if (parsed.documentElement?.tagName !== "mxGraphModel") return null
page.textContent = ""
model = page.appendChild(
doc.importNode(parsed.documentElement, true),
) as Element
const decompressedRoot = model.querySelector("root")
if (decompressedRoot) return decompressedRoot
} else {
model = page.appendChild(doc.createElement("mxGraphModel"))
}
}
const blank = new DOMParser().parseFromString(
`<root><mxCell id="0"/><mxCell id="1" parent="0"/></root>`,
"text/xml",
)
return model.appendChild(
doc.importNode(blank.documentElement, true),
) as Element
}
/** Read parent/source/target, which a wrapped cell keeps on its inner mxCell. */
function cellAttr(cell: Element, name: string): string | null {
const inner =
cell.tagName === "mxCell" ? cell : cell.querySelector("mxCell")
return inner?.getAttribute(name) ?? null
}
/**
* Apply diagram operations (update/add/delete) using ID-based lookup.
*
@@ -43,12 +96,8 @@ export function applyDiagramOperations(
): ApplyOperationsResult {
const errors: OperationError[] = []
// Parse the XML
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
// Check for parse errors
const parseError = doc.querySelector("parsererror")
// Check for syntax errors, then parse the XML
const parseError = getXmlSyntaxError(xmlContent)
if (parseError) {
return {
result: xmlContent,
@@ -56,11 +105,13 @@ export function applyDiagramOperations(
{
type: "update",
cellId: "",
message: `XML parse error: ${parseError.textContent}`,
message: `XML parse error: ${parseError}`,
},
],
}
}
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
// Locate the <root> element to operate on.
//
@@ -86,7 +137,7 @@ export function applyDiagramOperations(
],
}
}
root = found.element.querySelector("root")
root = ensurePageRoot(doc as unknown as Document, found.element)
if (!root) {
const pageId =
found.element.getAttribute("id") || `(index ${found.index})`
@@ -96,7 +147,7 @@ export function applyDiagramOperations(
{
type: "update",
cellId: "",
message: `Page "${pageId}" has no <root> element`,
message: `Page "${pageId}" has no <root> element and its content could not be decompressed`,
},
],
}
@@ -132,10 +183,16 @@ export function applyDiagramOperations(
// Build a map of cell IDs to elements (scoped to the resolved page).
const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => {
root.querySelectorAll(CELL_SELECTOR).forEach((cell) => {
const id = cell.getAttribute("id")
if (id) cellMap.set(id, cell)
// A wrapped mxCell may repeat its wrapper's id; the wrapper is the cell
const wrapped =
cell.tagName === "mxCell" &&
/^(UserObject|object)$/.test(cell.parentElement?.tagName ?? "")
if (id && !wrapped) cellMap.set(id, cell)
})
// Ids deleted so far in this batch; deleting one again is a no-op
const deletedIds = new Set<string>()
// Process each operation
for (const op of operations) {
@@ -164,7 +221,7 @@ export function applyDiagramOperations(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
const newCell = newDoc.querySelector(CELL_SELECTOR)
if (!newCell) {
errors.push({
type: "update",
@@ -216,7 +273,7 @@ export function applyDiagramOperations(
`<wrapper>${op.new_xml}</wrapper>`,
"text/xml",
)
const newCell = newDoc.querySelector("mxCell")
const newCell = newDoc.querySelector(CELL_SELECTOR)
if (!newCell) {
errors.push({
type: "add",
@@ -256,8 +313,15 @@ export function applyDiagramOperations(
const existingCell = cellMap.get(op.cell_id)
if (!existingCell) {
// Cell not found - might have been cascade-deleted by a previous operation
// Skip silently instead of erroring (AI may redundantly list children/edges)
// Skip cells already cascade-deleted by a previous operation
// (AI may redundantly list children/edges); warn otherwise
if (!deletedIds.has(op.cell_id)) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
}
continue
}
@@ -270,17 +334,17 @@ export function applyDiagramOperations(
cellsToDelete.add(cellId)
// Find children (cells where parent === cellId)
// 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) => {
const childId = child.getAttribute("id")
if (childId && childId !== "0" && childId !== "1") {
// cellMap only holds this page's cells, so other pages' cells
// with the same parent id (notably "1") are never touched.
for (const [childId, child] of cellMap) {
if (
childId !== "0" &&
childId !== "1" &&
cellAttr(child, "parent") === cellId
) {
collectDescendants(childId)
}
})
}
}
// Collect the target cell and all its descendants
@@ -289,23 +353,23 @@ export function applyDiagramOperations(
// Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) {
const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
for (const [edgeId, edge] of cellMap) {
// Protect root cells from being added via edge references
if (edgeId && edgeId !== "0" && edgeId !== "1") {
if (edgeId === "0" || edgeId === "1") continue
if (
cellAttr(edge, "source") === cellId ||
cellAttr(edge, "target") === cellId
) {
// Recurse to collect edge's children (like labels)
collectDescendants(edgeId)
}
})
}
}
// Log what will be deleted
// Log what will be deleted (stderr: stdout carries JSON-RPC)
if (cellsToDelete.size > 1) {
console.log(
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
log.debug(
`Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
)
}
@@ -316,6 +380,7 @@ export function applyDiagramOperations(
cell.parentNode?.removeChild(cell)
cellMap.delete(cellId)
}
deletedIds.add(cellId)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* DOM setup for Node. The XML helpers use the global DOMParser and
* XMLSerializer, which the browser has and Node gets from here.
*
* linkedom gives us a DOM with querySelector, but it is lenient: it never
* reports syntax errors (no <parsererror>; xml-syntax.ts checks them), and
* its serializer writes raw newlines inside attribute values, which the
* browser reads back as spaces. serializeXml writes attribute values safely.
*/
import { DOMParser } from "linkedom"
const ESCAPES: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"\t": "&#9;",
"\n": "&#xa;",
"\r": "&#xd;",
}
const escapeChars = (text: string, chars: RegExp) =>
text.replace(chars, (c) => ESCAPES[c])
/**
* Serialize a linkedom node as XML. Attribute values escape tabs and line
* breaks too, so multi-line labels (value="a&#xa;b") survive a round trip.
*/
export function serializeXml(node: Node): string {
switch (node.nodeType) {
case 9: {
// Document
const root = (node as Document).documentElement
return root ? serializeXml(root) : ""
}
case 1: {
// Element
const el = node as Element
let out = `<${el.tagName}`
for (const attr of Array.from(el.attributes)) {
out += ` ${attr.name}="${escapeChars(attr.value, /[&<>"\t\n\r]/g)}"`
}
if (el.childNodes.length === 0) return `${out}/>`
out += ">"
for (const child of Array.from(el.childNodes)) {
out += serializeXml(child)
}
return `${out}</${el.tagName}>`
}
case 3:
// Text
return escapeChars(node.textContent ?? "", /[&<>]/g)
case 4:
// CDATA
return `<![CDATA[${node.textContent ?? ""}]]>`
case 8:
// Comment
return `<!--${node.textContent ?? ""}-->`
default:
return ""
}
}
class XMLSerializerPolyfill {
serializeToString(node: Node): string {
return serializeXml(node)
}
}
/** Install the DOMParser and XMLSerializer globals the XML helpers use. */
export function installDomPolyfill(): void {
;(globalThis as any).DOMParser = DOMParser
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Drawing guide for the model, returned by start_session, get_drawing_guide
* and the diagram-workflow prompt.
*
* Adapted from the web app's system prompt (lib/system-prompts.ts) and its
* tool descriptions (app/api/chat/route.ts). When drawing rules change there,
* update this file too.
*/
import {
indent,
SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE,
} from "./xml-examples.ts"
export const DRAWING_GUIDE = `# Draw.io drawing guide
## Workflow
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
- After drawing or heavily editing a complex diagram, call screenshot_diagram once to see the result, and fix overlapping shapes and edges that cross shapes.
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes.
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
## The XML you send
Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1".
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
Rules (XML that breaks them is rejected):
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
3. parent="1" for top-level shapes, parent="<container id>" for shapes inside a container.
4. Edge source and target must reference existing cell ids.
5. Escape special characters in attribute values: &lt; for <, &gt; for >, &amp; for &, &quot; for ".
6. NEVER include XML comments (<!-- -->). draw.io strips them.
7. In tool arguments (JSON), every " inside the XML must be escaped as \\".
Containers and swimlanes: children use the container id as parent and coordinates relative to the container.
${indent(SWIMLANE_EXAMPLE)}
## Layout
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
- Start near x=40, y=40 and keep elements grouped closely.
- For large diagrams, stack vertically or use a grid instead of spreading wide.
## Edge routing rules
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;".
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
Rule 7: Use natural connection points. Never connect at corners (both X and Y 0 or 1). Top-to-bottom flow: exitY=1 into entryY=0. Left-to-right flow: exitX=1 into entryX=0. Diagonal: the side closest to the target.
Before sending XML, check:
1. Does any edge cross a shape that is not its source or target? Add waypoints.
2. Do two edges share a path? Change their exit or entry points.
3. Is any connection point at a corner? Use the middle of a side.
4. Could moving shapes remove edge crossings? Revise the layout.
Two edges between the same nodes:
${indent(TWO_EDGES_EXAMPLE)}
Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (right, bottom) connects to Main (center, top) while Develop (center, middle) is in between, so the edge goes right to x=750 first, then up, and enters Main from the right:
${indent(WAYPOINT_EXAMPLE)}
## Styles
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1;
- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right
- Animated connectors: add flowAnimation=1 to the edge style.
## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;".
- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children.
- Keep at least 50px between elements, and follow all edge routing rules strictly.
## Editing with edit_diagram
- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id.
- add inserts a new cell with a new id. One cell per operation.
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
- If the diagram is large, change it with edit_diagram instead of redrawing it.
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"40\\" y=\\"40\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
`
+109
View File
@@ -0,0 +1,109 @@
/**
* Core of the edit_diagram tool, kept free of session state so it can be
* unit tested.
*
* All-or-nothing, like the web app (hooks/use-diagram-tool-handlers.ts):
* if any operation fails, nothing is written and every failure is reported,
* so the model never builds on a half-applied edit.
*/
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.ts"
import { type PageSelector, projectPage } from "./pages.ts"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
export type EditOutcome =
| { ok: true; xml: string; applied: number; fixes: string[] }
| { ok: false; errors: string[]; pageError: boolean }
/** Number of top-level elements in an XML fragment. */
function countTopLevelElements(fragment: string): number {
const doc = new DOMParser().parseFromString(
`<wrapper>${fragment}</wrapper>`,
"text/xml",
)
// On a syntax error the browser adds a <parsererror> element (Chrome
// next to the cells, Firefox as the root); the syntax is checked later
const root = doc.documentElement
if (!root || root.tagName === "parsererror") return 1
return Array.from(root.children).filter(
(el) => el.tagName !== "parsererror",
).length
}
/** The target page as a one-page <mxfile>, or the whole document. */
export function targetPageXml(xml: string, selector: PageSelector): string {
const projection = projectPage(xml, selector)
return projection.ok ? projection.xml : xml
}
export function editDiagram(
xml: string,
operations: DiagramOperation[],
selector: PageSelector,
): EditOutcome {
const errors: string[] = []
const fixes: string[] = []
const prepared: DiagramOperation[] = []
for (const op of operations) {
if (op.operation === "delete" || !op.new_xml) {
prepared.push(op)
continue
}
// Checked before validation: several cells fail the strict parser
// with a misleading "only one root" syntax error.
if (countTopLevelElements(op.new_xml) > 1) {
errors.push(
`${op.operation} ${op.cell_id}: new_xml must contain exactly one cell; use one add operation per cell`,
)
continue
}
const check = validateAndFixXml(op.new_xml)
if (!check.valid) {
errors.push(
`${op.operation} ${op.cell_id}: invalid new_xml: ${check.error}`,
)
continue
}
if (check.fixed) {
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
}
prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml })
}
if (errors.length > 0) return { ok: false, errors, pageError: false }
const { result, errors: opErrors } = applyDiagramOperations(
xml,
prepared,
selector,
)
// An empty cellId means the page itself could not be edited
const pageLevel = opErrors.find((e) => e.cellId === "")
if (pageLevel) {
return { ok: false, errors: [pageLevel.message], pageError: true }
}
if (opErrors.length > 0) {
return {
ok: false,
errors: opErrors.map((e) => `${e.type} ${e.cellId}: ${e.message}`),
pageError: false,
}
}
// Validate only the target page, and reject only errors this edit
// introduced: problems already in other pages or in a loaded file must
// not block every edit.
const after = validateMxCellStructure(targetPageXml(result, selector))
if (after && !validateMxCellStructure(targetPageXml(xml, selector))) {
return {
ok: false,
errors: [`the edit would make the page invalid: ${after}`],
pageError: false,
}
}
return { ok: true, xml: result, applied: operations.length, fixes }
}
+1 -1
View File
@@ -17,7 +17,7 @@
* 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"
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.ts"
export type EditGateResult =
| { ok: true }
+16 -10
View File
@@ -3,10 +3,18 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { log } from "./logger.js"
import { log } from "./logger.ts"
const MAX_HISTORY = 20
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
interface HistoryEntry {
id: number // Stable across shifts of the circular buffer
xml: string
svg: string
}
let nextEntryId = 0
const historyStore = new Map<string, HistoryEntry[]>()
export function addHistory(sessionId: string, xml: string, svg = ""): number {
let history = historyStore.get(sessionId)
@@ -21,7 +29,7 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
return history.length - 1
}
history.push({ xml, svg })
history.push({ id: nextEntryId++, xml, svg })
// Circular buffer
if (history.length > MAX_HISTORY) {
@@ -32,18 +40,16 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
return history.length - 1
}
export function getHistory(
sessionId: string,
): Array<{ xml: string; svg: string }> {
export function getHistory(sessionId: string): HistoryEntry[] {
return historyStore.get(sessionId) || []
}
/** Look up an entry by its id; the array index shifts as old entries drop. */
export function getHistoryEntry(
sessionId: string,
index: number,
): { xml: string; svg: string } | undefined {
const history = historyStore.get(sessionId)
return history?.[index]
id: number,
): HistoryEntry | undefined {
return historyStore.get(sessionId)?.find((entry) => entry.id === id)
}
export function clearHistory(sessionId: string): void {
+215 -598
View File
@@ -3,7 +3,10 @@
* Serves draw.io embed with state sync and history UI
*/
import { readFileSync } from "node:fs"
import http from "node:http"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB
@@ -12,7 +15,9 @@ function readBody(
res: http.ServerResponse,
cb: (body: string) => void,
): void {
let body = ""
// Decode once at the end: a multi-byte UTF-8 character can be split
// across two chunks.
const chunks: Buffer[] = []
let size = 0
req.on("data", (chunk: Buffer) => {
size += chunk.length
@@ -22,9 +27,9 @@ function readBody(
req.destroy()
return
}
body += chunk
chunks.push(chunk)
})
req.on("end", () => cb(body))
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
}
import {
@@ -33,8 +38,9 @@ import {
getHistory,
getHistoryEntry,
updateLastHistorySvg,
} from "./history.js"
import { log } from "./logger.js"
} from "./history.ts"
import { log } from "./logger.ts"
import { BLANK_MXFILE } from "./pages.ts"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
@@ -52,19 +58,17 @@ function getOrigin(url: string): string {
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
// Minimal blank diagram used to bootstrap new sessions.
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Normalize URL for iframe src - ensure no double slashes
function normalizeUrl(url: string): string {
// Remove trailing slash to avoid double slashes
return url.replace(/\/$/, "")
}
function isLikelyMcpSessionId(sessionId: string): boolean {
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
return sessionId.startsWith("mcp-") && sessionId.length <= 128
// Session ids look like "mcp-<base36 time>-<base36 random>" (start_session).
// Only this charset is accepted, because ids are written into the page's
// HTML and script and into the redirect Location header.
function isValidSessionId(sessionId: string): boolean {
return /^mcp-[a-z0-9-]{1,64}$/.test(sessionId)
}
// Find the most recent active session (for auto-redirect when no sessionId provided)
@@ -80,23 +84,44 @@ function getMostRecentSessionId(): string | null {
function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return
if (!isValidSessionId(sessionId)) return
if (stateStore.has(sessionId)) return
setState(sessionId, DEFAULT_DIAGRAM_XML)
// Not a change worth saving: the browser fills it on its next push
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
// forever when no load(xml) is ever sent
setState(sessionId, BLANK_MXFILE, undefined, false, false)
}
interface SessionState {
xml: string
version: number
// Version of the last write the browser did not make itself (AI edit,
// restore). A browser push based on an older version is rejected.
serverVersion?: number
lastUpdated: Date
lastPolled?: number // Last browser poll; an open tab keeps the session alive
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
exportFormat?: ExportFormat // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
exportId?: number // Number of the pending export, echoed with its result
exportData?: string // Base64/SVG data returned by browser after export
}
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
export type ExportFormat = "png" | "svg" | "xmlsvg"
/**
* draw.io's PNG export takes these directly: width caps the image size
* (never upscales), pageId renders a page other than the one on screen.
*/
export interface ExportOptions {
width?: number
pageId?: string
}
export const stateStore = new Map<string, SessionState>()
let server: http.Server | null = null
@@ -108,20 +133,40 @@ export function getState(sessionId: string): SessionState | undefined {
return stateStore.get(sessionId)
}
export function setState(sessionId: string, xml: string, svg?: string): number {
// Called after every state change (AI write, browser push, restore)
let stateListener: ((sessionId: string, xml: string) => void) | null = null
export function onStateChange(
listener: (sessionId: string, xml: string) => void,
): void {
stateListener = listener
}
export function setState(
sessionId: string,
xml: string,
svg?: string,
fromBrowser = false,
notify = true,
): number {
const existing = stateStore.get(sessionId)
const newVersion = (existing?.version || 0) + 1
stateStore.set(sessionId, {
xml,
version: newVersion,
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
lastUpdated: new Date(),
lastPolled: existing?.lastPolled,
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
exportOptions: existing?.exportOptions,
exportId: existing?.exportId,
exportData: existing?.exportData, // Preserve export result
})
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
if (notify) stateListener?.(sessionId, xml)
return newVersion
}
@@ -138,17 +183,24 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
*/
export function requestExport(
sessionId: string,
format: "png" | "svg",
format: ExportFormat,
projectionXml?: string,
options?: ExportOptions,
): boolean {
const state = stateStore.get(sessionId)
if (!state) return false
state.exportData = undefined
state.exportXml = projectionXml
state.exportOptions = options
state.exportFormat = format
// The browser sends this back with the result, so a late result of an
// export that timed out is not taken for this one
state.exportId = ++lastExportId
return true
}
let lastExportId = 0
export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId)
if (state) {
@@ -222,7 +274,11 @@ export function stopHttpServer(): void {
function cleanupExpiredSessions(): void {
const now = Date.now()
for (const [sessionId, state] of stateStore) {
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
const lastActive = Math.max(
state.lastUpdated.getTime(),
state.lastPolled ?? 0,
)
if (now - lastActive > SESSION_TTL) {
stateStore.delete(sessionId)
clearHistory(sessionId)
log.info(`Cleaned up expired session: ${sessionId}`)
@@ -245,7 +301,48 @@ function handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
// A bad request must never take down the MCP process
try {
routeRequest(req, res)
} catch (err) {
log.error("HTTP request failed:", err)
if (!res.headersSent) res.writeHead(500)
res.end()
}
}
// Serve only requests addressed to localhost, sent by a localhost page or by
// a non-browser client (no Origin header). This blocks DNS rebinding and
// scripts on other websites.
function isLocalRequest(req: http.IncomingMessage): boolean {
const isLocalHost = (host: string) =>
/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host)
const origin = req.headers.origin
return (
isLocalHost(req.headers.host ?? "") &&
(origin === undefined || isLocalHost(origin.replace(/^http:\/\//, "")))
)
}
function routeRequest(
req: http.IncomingMessage,
res: http.ServerResponse,
): void {
let url: URL
try {
url = new URL(req.url || "/", `http://localhost:${serverPort}`)
} catch {
// e.g. "//" is not a valid URL path
res.writeHead(400)
res.end("Bad Request")
return
}
if (!isLocalRequest(req)) {
res.writeHead(403)
res.end("Forbidden")
return
}
const requestOrigin = req.headers.origin
if (requestOrigin === `http://localhost:${serverPort}`) {
@@ -262,12 +359,19 @@ function handleRequest(
if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
if (sessionId && !isValidSessionId(sessionId)) {
res.writeHead(400)
res.end("Invalid session id")
return
}
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
res.writeHead(302, {
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
})
res.end()
return
}
@@ -305,6 +409,9 @@ function handleStateApi(
}
ensureSessionStateInitialized(sessionId)
const state = stateStore.get(sessionId)
// Polling counts as activity, so a session stays alive while its
// tab is open
if (state) state.lastPolled = Date.now()
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
@@ -313,6 +420,8 @@ function handleStateApi(
syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null,
exportXml: state?.exportXml || null,
exportOptions: state?.exportOptions || null,
exportId: state?.exportId ?? null,
}),
)
} else if (req.method === "POST") {
@@ -320,29 +429,72 @@ function handleStateApi(
try {
const data = JSON.parse(body)
const { sessionId } = data
if (!sessionId) {
if (!sessionId || !isValidSessionId(sessionId)) {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId required" }))
res.end(
JSON.stringify({ error: "valid sessionId required" }),
)
return
}
// Browser is returning export data (png/svg)
if (data.exportData !== undefined) {
const state = stateStore.get(sessionId)
if (state) {
if (state && data.exportId === state.exportId) {
state.exportData = data.exportData
state.exportFormat = undefined
state.exportXml = undefined
state.exportOptions = undefined
state.exportId = undefined
log.debug(
`Export data received for session=${sessionId}`,
)
} else if (state) {
log.debug(
`Ignored a late export result for session=${sessionId}`,
)
}
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true }))
return
}
const version = setState(sessionId, data.xml, data.svg)
// The browser edited a version older than the latest AI write
// (it has not loaded that write yet). Keep the AI write; the
// browser loads it on its next poll.
const current = stateStore.get(sessionId)
if (
typeof data.baseVersion === "number" &&
data.baseVersion < (current?.serverVersion ?? 0)
) {
let savedToHistory = false
if (data.source === "sync") {
// A stale sync reply: the store already holds the
// newer AI write, so the sync is done.
if (current) current.syncRequested = undefined
} else if (typeof data.xml === "string" && data.xml) {
// A user edit lost the race with an AI write. Keep
// it in history so the user can restore it.
addHistory(sessionId, data.xml, data.svg || "")
savedToHistory = true
}
res.writeHead(409, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
error: "Diagram changed on the server",
version: current?.version,
savedToHistory,
}),
)
return
}
if (typeof data.xml !== "string") {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "xml must be a string" }))
return
}
const version = setState(sessionId, data.xml, data.svg, true)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, version }))
} catch {
@@ -378,7 +530,11 @@ function handleHistoryApi(
res.writeHead(200, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
entries: history.map((entry, i) => ({
index: i,
id: entry.id,
svg: entry.svg,
})),
count: history.length,
}),
)
@@ -396,16 +552,14 @@ function handleRestoreApi(
readBody(req, res, (body) => {
try {
const { sessionId, index } = JSON.parse(body)
if (!sessionId || index === undefined) {
const { sessionId, id } = JSON.parse(body)
if (!sessionId || typeof id !== "number") {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(
JSON.stringify({ error: "sessionId and index required" }),
)
res.end(JSON.stringify({ error: "sessionId and id required" }))
return
}
const entry = getHistoryEntry(sessionId, index)
const entry = getHistoryEntry(sessionId, id)
if (!entry) {
res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Entry not found" }))
@@ -415,7 +569,7 @@ function handleRestoreApi(
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to index ${index}`)
log.info(`Restored session ${sessionId} to history entry ${id}`)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, newVersion }))
@@ -455,571 +609,34 @@ function handleHistorySvgApi(
})
}
function getHtmlPage(sessionId: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Next AI Draw.io</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header {
padding: 0 20px; height: 52px;
background: linear-gradient(to bottom, #ffffff, #fafbfc);
border-bottom: 1px solid #e8ecf0;
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative; z-index: 10;
}
#header .brand {
display: flex; align-items: center; gap: 10px;
}
#header .logo {
width: 28px; height: 28px; border-radius: 6px;
background: #18181b;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
#header .title {
font-size: 15px; font-weight: 600; color: #1a1a2e;
letter-spacing: -0.3px;
}
#header .session {
font-size: 11px; color: #8b95a5; font-weight: 400;
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
}
#header .right { display: flex; align-items: center; gap: 12px; }
#save-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: linear-gradient(to bottom, #18181b, #27272a);
color: white; border: none; cursor: pointer;
font-weight: 500; font-family: inherit;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
transition: all 0.15s ease;
}
#save-btn svg { width: 14px; height: 14px; }
#save-btn:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
}
#save-btn:active { transform: translateY(0); }
#save-btn:disabled, #history-btn:disabled {
background: #e5e7eb; color: #9ca3af;
cursor: not-allowed; transform: none; box-shadow: none;
}
#history-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
cursor: pointer; font-weight: 500; font-family: inherit;
transition: all 0.15s ease;
}
#history-btn svg { width: 14px; height: 14px; }
#history-btn:hover {
background: #e4e4e7; border-color: #d4d4d8;
}
#drawio { flex: 1; border: none; }
#history-modal, #save-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
z-index: 2000; align-items: center; justify-content: center;
}
#history-modal.open, #save-modal.open { display: flex; }
.modal-content {
background: white; border-radius: 16px;
width: 90%; max-width: 480px; max-height: 70vh;
display: flex; flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
animation: modalIn 0.2s ease-out;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
}
.modal-header h2 {
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
letter-spacing: -0.3px;
}
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid #f1f3f5;
display: flex; gap: 10px; justify-content: flex-end;
}
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.history-item {
border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
cursor: pointer; text-align: center; transition: all 0.15s ease;
background: #fafafa;
}
.history-item:hover { border-color: #a1a1aa; background: white; }
.history-item.selected {
border-color: #18181b; background: white;
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
}
.history-item .thumb {
aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
margin-bottom: 6px; overflow: hidden;
}
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
.btn {
padding: 9px 18px; border-radius: 8px; font-size: 13px;
cursor: pointer; border: none; font-weight: 500;
font-family: inherit; transition: all 0.15s ease;
}
.btn-primary {
background: linear-gradient(to bottom, #18181b, #27272a);
color: white;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
}
.btn-primary:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
}
.btn-primary:disabled {
background: #e4e4e7; color: #a1a1aa;
cursor: not-allowed; transform: none; box-shadow: none;
}
.btn-secondary {
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
}
.btn-secondary:hover { background: #e4e4e7; }
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
.form-group { margin-bottom: 18px; }
.form-group label {
display: block; font-size: 13px; font-weight: 500;
margin-bottom: 8px; color: #3f3f46;
}
.form-group select, .form-group input {
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
border-radius: 8px; font-size: 14px; outline: none;
font-family: inherit; background: white;
transition: all 0.15s ease;
}
.form-group select:focus, .form-group input:focus {
border-color: #18181b;
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
}
.filename-group { display: flex; }
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
.filename-group .ext {
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
font-family: 'SF Mono', Monaco, monospace;
}
</style>
</head>
<body>
<div id="container">
<div id="header">
<div class="brand">
<div class="logo">
<svg viewBox="0 0 1536 1536" fill="#ffffff">
<g transform="translate(0,1536) scale(0.1,-0.1)">
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
</g>
</svg>
</div>
<span class="title">Next AI Draw.io</span>
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
</div>
<div class="right">
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
History
</button>
<button id="save-btn" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Download
</button>
</div>
</div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
</div>
<div id="history-modal">
<div class="modal-content">
<div class="modal-header"><h2>History</h2></div>
<div class="modal-body">
<div id="history-grid" class="history-grid"></div>
<div id="history-empty" class="empty" style="display:none;">No history yet</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
<button class="btn btn-primary" id="restore-btn" disabled>Restore</button>
</div>
</div>
</div>
<div id="save-modal">
<div class="modal-content">
<div class="modal-header"><h2>Download Diagram</h2></div>
<div class="modal-body">
<div class="form-group">
<label>Format</label>
<select id="save-format">
<option value="drawio">Draw.io (.drawio)</option>
<option value="png">PNG Image (.png)</option>
<option value="svg">SVG Vector (.svg)</option>
</select>
</div>
<div class="form-group">
<label>Filename</label>
<div class="filename-group">
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
<span class="ext" id="save-ext">.drawio</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
</div>
</div>
</div>
<script>
const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
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
// The preview page lives in src/preview (the build copies it to dist/preview)
const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview")
let previewTemplate: string | null = null
window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return;
try {
const msg = JSON.parse(e.data);
if (msg.event === 'init') {
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' }), '*');
// Fallback if export doesn't respond
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) {
// Handle MCP server export request (png/svg)
// Verify the response matches the requested format to avoid capturing
// unrelated exports (autosave SVG, sync XML)
if (pendingMcpExport) {
const d = msg.data;
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
if (isPng || isSvg) {
pendingMcpExport = null;
fetch('/api/state', {
method: 'POST',
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;
}
}
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
const dl = pendingDownload;
pendingDownload = null;
let dataUrl = msg.data;
if (!dataUrl.startsWith('data:')) {
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
}
const a = document.createElement('a');
a.href = dataUrl; a.download = dl.filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
return;
}
// Handle sync export (XML format) - server requested fresh state
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
pendingSyncExport = false;
pushState(msg.data, '');
return;
}
// Handle SVG export
let svg = msg.data;
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
if (pendingSvgExport) {
const xml = pendingSvgExport;
pendingSvgExport = null;
pushState(xml, svg);
} else if (pendingAiSvg) {
pendingAiSvg = false;
fetch('/api/history-svg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, svg })
}).catch(() => {});
}
}
} catch {}
});
function loadDiagram(xml, capturePreview = false) {
if (!isReady) { pendingXml = xml; return; }
lastXml = xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*');
if (capturePreview) {
setTimeout(() => {
pendingAiSvg = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
}, 500);
}
}
// 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 {
const r = await fetch('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml, svg })
});
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
} catch (e) { console.error('Push failed:', e); }
}
let pendingSyncExport = false;
async function poll() {
if (!sessionId) return;
try {
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
// Handle sync request - server needs fresh state
if (s.syncRequested && !pendingSyncExport) {
pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: '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).
//
// 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 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 {}
}
if (sessionId) { poll(); setInterval(poll, 2000); }
// Save modal
const saveBtn = document.getElementById('save-btn');
const saveModal = document.getElementById('save-modal');
const saveFormat = document.getElementById('save-format');
const saveFilename = document.getElementById('save-filename');
const saveExt = document.getElementById('save-ext');
const saveCancelBtn = document.getElementById('save-cancel-btn');
const saveConfirmBtn = document.getElementById('save-confirm-btn');
let pendingDownload = null;
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
saveBtn.onclick = () => {
if (!sessionId || !isReady) return;
saveModal.classList.add('open');
saveFilename.focus();
saveFilename.select();
};
saveFormat.onchange = () => {
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
};
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
saveConfirmBtn.onclick = () => {
const format = saveFormat.value;
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
saveConfirmBtn.disabled = true;
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// 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>';
}
const blob = new Blob([xmlData], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
} else if (format === 'png') {
pendingDownload = { format: 'png', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
} else if (format === 'svg') {
pendingDownload = { format: 'svg', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
}
};
// History UI
const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal');
const historyGrid = document.getElementById('history-grid');
const historyEmpty = document.getElementById('history-empty');
const restoreBtn = document.getElementById('restore-btn');
const cancelBtn = document.getElementById('cancel-btn');
let historyData = [], selectedIdx = null;
historyBtn.onclick = async () => {
if (!sessionId) return;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
historyData = d.entries || [];
renderHistory();
}
} catch {}
historyModal.classList.add('open');
};
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
function renderHistory() {
if (historyData.length === 0) {
historyGrid.style.display = 'none';
historyEmpty.style.display = 'block';
return;
}
historyGrid.style.display = 'grid';
historyEmpty.style.display = 'none';
historyGrid.innerHTML = historyData.map((e, i) => \`
<div class="history-item" data-idx="\${e.index}">
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
<div class="label">#\${e.index}</div>
</div>
\`).join('');
historyGrid.querySelectorAll('.history-item').forEach(item => {
item.onclick = () => {
const idx = parseInt(item.dataset.idx);
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
else { selectedIdx = idx; restoreBtn.disabled = false; }
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
};
});
}
restoreBtn.onclick = async () => {
if (selectedIdx === null) return;
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring...';
try {
const r = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, index: selectedIdx })
});
if (r.ok) { cancelBtn.onclick(); await poll(); }
else { alert('Restore failed'); }
} catch { alert('Restore failed'); }
restoreBtn.textContent = 'Restore';
};
</script>
</body>
</html>`
function loadPreviewTemplate(): string {
if (!previewTemplate) {
const read = (file: string) =>
readFileSync(join(PREVIEW_DIR, file), "utf-8")
previewTemplate = read("index.html")
.replace("{{CSS}}", () => read("preview.css"))
.replace("{{SCRIPT}}", () => read("preview.js"))
}
return previewTemplate
}
/** A JSON string literal that is safe inside a <script> element */
const scriptJson = (value: string) =>
JSON.stringify(value).replace(/</g, "\\u003c")
function getHtmlPage(sessionId: string): string {
return loadPreviewTemplate()
.replace("{{SESSION_BADGE}}", () =>
sessionId
? `<span class="session">${sessionId.slice(-8)}</span>`
: "",
)
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More