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
renovate[bot] a45e5b6796 fix(deps): update minor and patch dependencies (#948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-02 06:42:01 +00:00
renovate[bot] 027cd88c90 fix(deps): update minor and patch dependencies (#932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 12:55:24 +00:00
Dayuan Jiang 155ef4f7ac fix: raise the output budget so reasoning models reach the tool call (#927)
* fix: raise the output budget so reasoning models reach the tool call

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

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

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

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

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

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

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

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

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

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

Signed-off-by: binyangzhu000-sudo <[email protected]>

* docs: add Atlas Cloud logo assets

Signed-off-by: binyangzhu000-sudo <[email protected]>

---------

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

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

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

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

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

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

---------

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

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

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

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

* fix: restore files removed by Atlas provider PR

---------

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

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

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

* chore(mcp): version 0.2.3

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

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

Closes #885

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

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

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

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

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

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

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

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

* chore(mcp): bump version to 0.2.2

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

This patch closes the gap end to end:

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

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

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

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

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

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

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

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

---------

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

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

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

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

* feat: load AIHubMix models dynamically

* feat: polish AIHubMix model setup

* feat: send AIHubMix app code

* docs: remove redundant AIHubMix recommendation

---------

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

Settings saved in the panel are written to data/settings.json and
overlaid onto process.env, taking precedence over environment
variables and applying immediately without restart. Enable by setting
ADMIN_PASSWORD; on serverless platforms without persistent disk the
panel degrades to read-only.

* polish: admin panel UI improvements

- Provider logos in credential rows (shared ProviderLogo component,
  extracted from model-config-dialog)
- Scroll-spy active state in the sidebar nav
- Green success state in the save bar that clears after a few seconds
- Wider content column (max-w-6xl) for less wasted space on desktop

* polish: admin panel section toggles and reorder

- Move Quota & Rate Limits to the end of the settings page
- Add enable switches to Observability and Quota sections; default off
  with fields grayed out, auto-on when any field is already configured

* polish: make section enable switch more visible

Wrap the switch in a labeled pill ('Enabled'/'Disabled') with border
and background so the off state is clearly visible.

* refactor: derive admin registry from PROVIDER_INFO, simplify page state

- Provider options, labels, and base-URL placeholders now come from
  PROVIDER_INFO instead of hand-copied lists (fixes SiliconFlow .com/.cn
  placeholder drift; panel names now match the model-config dialog)
- Replace free-text subgroup strings + SUBGROUP_PROVIDERS reverse map
  with a typed provider field on SettingDef
- Precompute SETTINGS_BY_GROUP and PROVIDER_SUBGROUPS at module level
- Merge justSaved into saveMessage, drop unused mainRef, hoist
  fetchSettings out of the component, dedupe savedText logic
- Serialize from SETTINGS_REGISTRY directly; json validators in a map
  instead of a hardcoded key check
- Make allowPrivateUrls a function so ALLOW_PRIVATE_URLS edits in the
  admin panel apply without restart

* feat: graphical model management in admin panel

Replace the provider credential fields and raw AI_MODELS_CONFIG JSON
textarea with a Models section mirroring the in-app model settings UI:
provider instance list with logos, credential fields per provider type,
model add/remove with suggestions, per-model connectivity test, and a
default-provider star.

On save the server derives everything the runtime needs into
settings.json: credential env vars (with _2 suffixes for multiple
instances of one provider), AI_MODELS_CONFIG, and AI_PROVIDER/AI_MODEL
for the default. Secrets round-trip as masked markers and are never
sent back to the browser. The general settings registry now only
covers non-provider settings (generation, access, features,
observability, quota).

* fix: allow testing unsaved providers in admin panel

The test button previously looked up credentials by providerId in the
saved settings, so testing a newly added (unsaved) provider failed with
'Unknown provider or model'. The test endpoint now accepts the client's
current provider state; newly typed secrets are used as-is and masked
markers are resolved against the stored values, so testing works both
before and after saving.

* fix: merge env AI_MODELS_CONFIG with admin panel providers

Previously, saving in the admin panel wrote a complete AI_MODELS_CONFIG
into settings.json, which (by overlay precedence) replaced any config
from .env or ai-models.json — admins lost their env-configured models.

The panel no longer writes AI_MODELS_CONFIG. Instead its providers are
merged with the env baseline at read time in loadRawServerModelsConfig,
and panel credentials go to ADMIN_-prefixed env vars wired up via
apiKeyEnv/baseUrlEnv so they never shadow standard vars. Env-based
providers now appear read-only in the panel, name clashes are rejected,
and a panel default overrides the env default. data/ is now gitignored.

* fix: block global-credential providers already managed via env

Bedrock, Vertex AI, and Ollama credentials live in fixed env vars with
no apiKeyEnv redirection, so a panel instance of one of these would
silently override the credentials that env-configured models rely on.
The API now rejects saving such a provider when the env config already
uses that type, and the Add Provider dropdown disables it with a
'managed via env' note.

* fix: address admin panel review findings

- Security: test-model no longer resolves a stored secret when the
  request's baseUrl/provider differs from the stored entry, closing a
  path where a tampered baseUrl could exfiltrate a saved key
- Save failures are now visible: the save bar shows the error in red
  (was masked by the persistent 'Unsaved changes' text), and per-field
  validation errors from the settings API are surfaced under each field
- The Observability/Quota enable switch is now real: toggling off stages
  deletion of the group's saved values, and the toggle no longer snaps
  back to Enabled after saving
- Env provider's default star is hidden when a panel provider is the
  active default (no more double star)
- Clearing a credential field reverts to the stored value instead of
  silently deleting it; an explicit X button removes a stored secret
- Form inputs are disabled during an in-flight save

* refactor(admin): split 1549-line admin page into focused modules

Extract admin-shared.ts (types + fetch helper), setting-field.tsx
(registry-driven fields), and models-section.tsx (provider/model
manager) from page.tsx. Pure mechanical move, no behavior change.

* feat(admin): share credential fields with user dialog and localize panel

Extract ProviderCredentialsFields (display name + per-provider
credential inputs) used by both the user ModelConfigDialog and the
admin Models panel; secret input passed via renderSecret (plaintext
vs masked), test button via footer slot. Add full i18n for the admin
panel across en/zh/ja/zh-Hant, reusing modelConfig.* for shared parts.

* fix(admin): address Copilot review findings

- Reflect built-in defaults for boolean settings (ALLOW_PRIVATE_URLS
  defaults on) and allow clearing a saved boolean back to default,
  so the SSRF toggle matches actual runtime behavior.
- Harden JSON loading: filter settings values to strings only, and
  schema-validate stored ADMIN_PROVIDERS entries, dropping malformed
  ones instead of letting them reach runtime code.
- Set beforeunload returnValue so the unsaved-changes prompt shows in
  all browsers; reject non-finite numbers in settings validation.
- Fix README/CN/JA docs that claimed the panel auto-generates
  AI_MODELS_CONFIG (providers are merged at read time, not written).
- Add unit tests for corrupted-file value filtering and provider
  schema validation.

* docs: move admin panel details to dedicated docs/{en,cn,ja}/admin-panel.md

The READMEs now carry a short blurb + link, matching the existing
per-topic docs (docker.md, ai-providers.md, ...). Removes the ~22-line
inline section and the duplicated data/settings.json mentions.

* fix(admin): address follow-up Copilot findings on the prior fixes

- loadAdminProviders now validates against a stored-shape schema where
  secrets are plain strings, so a hand-edited ADMIN_PROVIDERS holding an
  {isSet} marker is dropped instead of later crashing maskSecret().
- loadSettings guards against array values (typeof [] === 'object'),
  which would otherwise overlay numeric keys onto process.env.
- Admin SecretInput uses the bare id so the shared component's
  <Label htmlFor> stays associated (only one ProviderDetail mounts).
- Add tests: marker-secret rejection, array-values guard, bedrock
  multi-secret round-trip.
2026-06-15 00:40:35 +09:00
Dayuan Jiang 54ff8d982c chore: refresh SUGGESTED_MODELS for all providers (#863)
Updates the SUGGESTED_MODELS quick-add list in lib/types/model-config.ts
against each provider's official model docs as of 2026-06-09.

- openai: GPT-5.5 / 5.4 frontier; drop deprecated 5.0-5.2 family
- anthropic: add Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5 (new dateless
  pinned IDs); fix wrong date suffixes on Opus/Sonnet 4.5
- google / vertexai: adopt Gemini 3 family; drop Gemini 2.0 (shut down)
  and 1.5
- azure: GPT-5.x line + o3 / o4-mini; drop gpt-4-turbo / gpt-35-turbo
- bedrock: Opus 4.8/4.7/4.6, Sonnet 4.6, Haiku 4.5, Nova Premier /
  Nova 2 Lite, Llama 4 Maverick / Scout, Mistral Large 3, Pixtral
- openrouter: refreshed against live /api/v1/models
- deepseek: V4 Pro / Flash
- siliconflow / modelscope: DeepSeek V4, Qwen 3.x, drop bogus
  qwen3.5-plus
- gateway: verified against live Vercel AI Gateway endpoint
- doubao: Seed 2.0 / 1.8 / 1.6 in official dash-form IDs
- minimax: + M2.5
- novita: M3, GLM-5.1, Kimi-K2.6, DeepSeek V4
2026-06-09 21:11:02 +09:00
chaochaoweb3anddayuan.jiang 410993a3bf fix: block private IPv6 URLs (#858)
* fix: block private IPv6 URLs

* fix: cover full fe80::/10 link-local range and :: unspecified

- Replace startsWith("fe80:") with a check covering the full fe80::/10
  range (fe80 through febf) per RFC 4291.
- Add :: (unspecified) to the localhost block.
- Drop the dead 0:0:0:0:0:0:0:1 branch (URL parser normalizes it to ::1).
- Add tests for fe9f::1, febf::1, and ::.

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-06-06 00:30:20 +09:00
Dayuan Jiang 77e7766f9a fix(deps): bump @ai-sdk/amazon-bedrock to 4.0.113 to fix tool streaming under Zod v4 (#860)
The installed 4.0.64 declared `input: z.unknown()` on BedrockToolUseSchema,
which Zod v4 treats as non-optional. With the
`fine-grained-tool-streaming-2025-05-14` beta enabled in lib/ai-providers.ts,
Bedrock's contentBlockStart event arrives without an `input` field, causing
type validation to fail with "expected nonoptional, received undefined".

4.0.101 fixed this upstream by marking input optional on the streaming
tool-use schema. The semver range `^4.0.1` already permitted this; only the
lockfile needed refreshing.

Closes #859
2026-06-06 00:15:45 +09:00
Octopusandocto-patch a9ffd6a1de feat: upgrade MiniMax default model to M3 (#857)
- Add MiniMax-M3 to the model selection list (set as new default at top)
- Retain MiniMax-M2.7 and MiniMax-M2.7-highspeed
- Remove deprecated MiniMax-M2.5 / M2.5-highspeed
- Update supportsImageInput: M3 supports image input (M2.x stay text-only)
- Update unit tests to reflect new model lineup
- Update example AI_MODEL in CN/EN/JA docs to MiniMax-M3

Co-authored-by: octo-patch <[email protected]>
2026-06-02 19:38:49 +09:00
277ad83552 fix(anthropic): support ANTHROPIC_AUTH_TOKEN as alternative to ANTHROPIC_API_KEY (#853)
* fix(anthropic): support ANTHROPIC_AUTH_TOKEN as alternative to ANTHROPIC_API_KEY

Anthropic SDK supports two mutually exclusive auth methods: apiKey (sent as
x-api-key header) and authToken (sent as Authorization: Bearer header). Detect
either env var during provider detection and credential validation, and pass
authToken to createAnthropic when only ANTHROPIC_AUTH_TOKEN is set.

* docs(anthropic): document ANTHROPIC_AUTH_TOKEN and refine error message

- Add ANTHROPIC_AUTH_TOKEN to env.example and the en/cn/ja provider docs
- Reword the missing-credential error to "Either ... or ..." for readability

---------

Co-authored-by: duyunjie <[email protected]>
Co-authored-by: dayuan.jiang <[email protected]>
2026-06-02 19:26:27 +09:00
Dayuan Jiang 7b6eb39fa5 fix(parse-url): block SSRF via private/internal URLs (#845)
/api/parse-url accepted any URL the user submitted, fetched it via
@extractus/article-extractor, and returned the body as Markdown. With
ALLOW_PRIVATE_URLS unset (the default after #600) the SSRF guard
short-circuited entirely, so an unauthenticated POST could probe
container ports, read AWS IMDS / GCP metadata, and reach same-VPC
internal services.

- parse-url now always rejects private URLs regardless of
  ALLOW_PRIVATE_URLS. The flag's only legitimate use case is local
  LLM provider baseUrl overrides (validate-model, chat); article
  extraction has no business fetching internal hosts. Local LLM
  setups (Ollama, LM Studio, etc.) are unaffected.
- Strip a trailing dot from the hostname before equality checks so
  the FQDN form "localhost." (which still resolves to 127.0.0.1) is
  caught by the existing string match.

Known follow-ups (not addressed here):
- DNS rebinding: hostnames are matched as strings; a public domain
  resolving to 127.0.0.1 (e.g. localtest.me) is not caught.
- HTTP redirects: @extractus/article-extractor uses cross-fetch with
  default redirect: "follow" and exposes no hook, so a public URL
  302-ing to an internal host still leaks.
2026-05-21 23:54:23 +09:00
Dayuan Jiang 1115b2d2cd chore: bump version to 0.4.16 (#843) 2026-05-21 09:28:55 +09:00
renovate[bot] 08afb6dd34 chore(deps): update dependency next to v16.2.6 [security] (#842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 09:05:06 +09:00
renovate[bot]andgithub-actions[bot] c703159e00 fix(deps): update core framework packages (major) (#724)
* fix(deps): update core framework packages

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-21 09:01:42 +09:00
renovate[bot]andgithub-actions[bot] ee75408136 chore(deps): update dependency electron to v39.8.5 [security] (#789)
* chore(deps): update dependency electron to v39.8.5 [security]

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-21 09:01:03 +09:00
renovate[bot]andgithub-actions[bot] 2ed1a64ae3 fix(deps): update minor and patch dependencies (#829)
* fix(deps): update minor and patch dependencies

* style: auto-format with Biome

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-21 08:59:18 +09:00
Dayuan Jiang 2f2d75961d chore: remove dead code from previous #815 fix attempts (#841)
PR #840 fixed issue #815 in the Electron main process via
will-prevent-unload + preventDefault. The renderer-side workarounds
introduced by previous fix attempts (#642, #648) are no longer needed
and never had effect for their stated purpose.

Removed:
- configuration={ confirmExit: false } in DrawIoEmbed
  confirmExit is not a recognized draw.io config key (zero matches in
  jgraph/drawio source). This was always dead code.

- modified=0 / keepmodified=0 URL parameters
  Per drawio source (app.min.js:14898), these only suppress the
  post-save modified-flag clearing — they do not prevent edits from
  setting editor.modified=true. They were ineffective for blocking
  beforeunload prompts and actually prevented draw.io from clearing
  its modified flag after save.

- canPersist / canPersistChecked state and isIndexedDBUsable() probe
  Their only purpose was gating the dead config above. Removing them
  also removes a startup delay before the iframe renders.

- handleDrawioAutoSave wrapper
  After PR #780 stripped its body, it was a pure passthrough useCallback.
  Now passes handleDiagramAutoSave directly to onAutoSave.

- withDB / isClosingError / resetDBPromise / onversionchange / onclose
  / terminated handlers in lib/session-storage.ts and lib/template-storage.ts
  PR #648 added these to recover from 'IDBDatabase: connection is closing'
  errors that PR #642's first land caused via db.close() on the shared
  singleton. That bug was already fixed in c5de1a1 (re-land of #642),
  three minutes before PR #648 commits started. The retry handlers
  defend against multi-tab / version-change scenarios that cannot occur
  in this single-instance Electron app (requestSingleInstanceLock).
  template-storage.ts copied the same pattern when introduced by #773.

Verified:
- npx tsc --noEmit passes
- Manual test in dev mode: session save/load works, template create works,
  diagram-only persistence works.
2026-05-21 08:37:14 +09:00
Dayuan Jiang 5406778dd6 fix(electron): override draw.io iframe beforeunload to allow window close (fixes #815) (#840)
The draw.io iframe registers a window.onbeforeunload handler that returns
a non-empty string whenever its internal editor.modified flag is true.
After the user edits text in a shape, that flag is set and never cleared.

Per Electron BrowserWindow docs, returning a non-void value from any
beforeunload handler in the page tree silently cancels the window close
without showing a dialog. This is what caused the X button (and Cmd+Q)
to do nothing for users who had typed in a shape.

Calling event.preventDefault() in will-prevent-unload tells Electron to
ignore the iframe's beforeunload return value and proceed with the close.
The host app already persists diagrams via autosave + visibilitychange,
so the prompt was unnecessary.

Verified by reproducing the bug, applying the fix, and re-testing.
2026-05-20 23:33:05 +09:00
果子 bb65a8c07a fix(e2e): resolve iframe toolbar strict mode violation (#837)
* fix(e2e): resolve strict mode violation in iframe test

Use .first() with [title*="Diagram"] selector to avoid matching multiple elements.

Fixes CI failure in E2E Tests job.

* fix(e2e): use .first() to resolve strict mode violation

* style: fix biome formatting in iframe test

* style: fix biome formatting in iframe test

* fix(e2e): use .or().first() to handle both text and title selectors

* fix(e2e): increase timeout for draw.io toolbar visibility check

* fix(e2e): filter visible elements to avoid selecting hidden toolbar
2026-05-19 09:52:02 +09:00
果子anddayuan.jiang 4e223b6237 feat: add all Draw.io themes to settings panel (#835)
* feat: add all Draw.io themes to settings panel

Add all available Draw.io themes (kennedy, atlas, dark, min, sketch, simple)
to the settings panel dropdown. Previously only min and sketch were available
as a toggle button.

Changes:
- Replace the Draw.io style toggle button with a dropdown selector
- Expand theme type from "min" | "sketch" to include all 6 themes
- Update localStorage validation to accept all themes
- Update handler from toggle to direct theme selection

Closes #499

* fix: localize theme labels, tighten DrawioTheme typing, sync dark param

- Move DRAWIO_THEMES + DrawioTheme to lib/drawio-themes.ts; reuse in
  page.tsx, chat-panel.tsx and settings-dialog.tsx instead of `string`
- Localize theme dropdown labels (Dark/Minimal/Sketch/Simple) in
  en/zh/ja/zh-Hant; keep proper-noun themes (Kennedy/Atlas) as-is
- Drop trailing colon from drawioStyleDescription and remove dead
  switchTo/minimal/sketch keys in all 4 dictionaries
- Auto-sync drawio dark URL param when ui="dark" is selected
- Add aria-label to drawio-style SelectTrigger

* fix: use kennedy as default theme and label it "Default"

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-05-15 23:14:20 +09:00
Octopusandocto-patch c60e3930a3 fix: use createDeepSeek for kimi provider to handle reasoning_content in multi-turn conversations (fixes #824) (#825)
Kimi thinking models (e.g. kimi-k2.6) return reasoning_content in their
responses. The previous createOpenAI-based implementation silently ignored
this field, so reasoning was never captured or replayed in subsequent turns.
Switching to createDeepSeek (which natively understands reasoning_content)
ensures that reasoning context is preserved across conversation turns,
resolving the "cannot interact a second time" error with Kimi k2.6.

This mirrors the existing doubao provider pattern, which already uses
createDeepSeek for kimi-based models routed through Doubao.

Co-authored-by: octo-patch <[email protected]>
2026-05-15 14:02:26 +09:00
Octopusandocto-patch 5c8ae4d6d7 fix: always re-fetch access code config when settings dialog opens (#816)
When ACCESS_CODE_LIST is configured on the server, the settings dialog
was not showing the access code input field in two cases:

1. Stale localStorage cache: if a user had previously visited without
   ACCESS_CODE_LIST enabled, the cached value of accessCodeRequired=false
   would be used indefinitely, hiding the password input.

2. Race condition on first visit: the dialog could open triggered
   by an auth error before the async fetch to /api/config completed,
   showing a blank settings dialog with no access code field.

Fix by re-fetching /api/config whenever the dialog opens (on open
change) instead of only once on mount with a cache guard. The cached
value in localStorage is still updated on success, keeping the fast
initial render intact while ensuring the dialog always reflects the
server configuration.

Fixes #811

Co-authored-by: octo-patch <[email protected]>
2026-05-15 10:52:51 +09:00
Dayuan Jiang f965f3fa2e chore: align biome schema version with CLI latest (#832)
Bumps biome.json $schema from 2.4.4 to 2.4.14 so the repo schema
matches the version the 'Auto Format' workflow installs via
@biomejs/biome@latest. Also applies the one auto-fix the newer
version produces (export ordering in electron/electron.d.ts).

Fixes the spurious 'This PR has formatting issues' CI failure that
was blocking fork PRs unrelated to formatting.
2026-05-07 22:43:37 +09:00
LaaraibAhmedandLaaraib Ahmed 73eefc7aa6 fix: CSS/UI issue in model-config-dialog (#818)
Co-authored-by: Laaraib Ahmed <[email protected]>
2026-05-07 22:14:46 +09:00
renovate[bot] a8d27088ef chore(deps): update dependency @xmldom/xmldom to v0.9.10 [security] (#821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 02:57:27 +00:00
Dayuan Jiang d4454beb9a chore: bump version to 0.4.15 (#810) 2026-04-14 23:57:40 +09:00
Octopusandocto-patch 171174378c fix: allow QvQ (Qwen Visual QA) models to use image input (#808)
QvQ models (e.g. qvq-72b-preview, qvq-max) are visual reasoning models
from the Qwen family that support image input. When accessed via providers
that prefix model names with 'qwen/' (e.g., OpenRouter), these models
contain 'qwen' in their ID but lack the 'vl' or 'vision' indicator.

This caused supportsImageInput() to incorrectly return false for model
IDs like 'qwen/qvq-72b-preview', blocking image uploads for vision-capable
models.

Add 'qvq' as an explicit exception in the Qwen text-model check so that
QvQ models are correctly allowed to receive image input regardless of the
provider prefix.

Co-authored-by: octo-patch <[email protected]>
2026-04-13 20:05:57 +09:00
Zhichang YuandClaude Opus 4.6 eadc2c2629 feat: add rpm target to electron-builder for Linux distribution (#806)
Added rpm target (x64 + arm64) alongside existing deb and AppImage
targets. No CI changes required since electron-builder handles both
deb and rpm generation under the --linux flag.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-13 12:18:50 +09:00
Dayuan Jiang c77af86011 fix: improve create_new_diagram tool description to prevent misuse and bump to v0.2.0 (#803) 2026-04-10 17:18:24 +09:00
Dayuan Jiang 0cd1260172 fix: show all enabled panels on chat lobby instead of only templates (#802) 2026-04-10 14:33:56 +09:00
Khanh Thanhanddayuan.jiang 49bfb51b10 feat: Update some tool demos in english about page (#598)
* feat: update about page examples with new diagrams

Replace old cloud architecture and cat examples with ResNet50, RAG,
Authentication, Agile Scrum, and Open Innovation diagrams.
Update all three language versions (EN, CN, JA) for consistency.

* fix: correct ResNet50 prompt text and keep prompts in English

Update the ResNet50 example prompt to match the actual diagram.
Keep all prompt texts in English across CN and JA about pages
since prompts are what users type into the AI.

* docs: update README examples to match about page

Replace GCP, AWS, Azure examples with ResNet50, RAG, Auth,
Agile Scrum, and Open Innovation. Keep Animated Transformer
and Cat Sketch. Keep prompts in English across all versions.

* docs: remove ResNet50 and Agile Scrum examples from READMEs

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-04-10 13:11:34 +09:00
Dayuan Jiang ccd9c1f48e fix: reduce CPU usage during large XML streaming (#801)
* fix: reduce CPU usage during large XML streaming

Skip Prism syntax highlighting while tool call is streaming — use plain
<pre> during streaming and only run Prism once at completion. Also throttle
scrollIntoView to once per 150ms to avoid layout thrashing.

Profiled with Playwright + CDP: for 200-cell diagrams the longest
browser task dropped from 6.7s to ~450ms and total long-task time
fell from ~10.9s to ~1.1s.

* fix: add trailing edge to scroll throttle

Ensures the chat scrolls to the bottom after streaming ends, even if
the last messages update arrives during the 150ms throttle window.
2026-04-10 11:12:50 +09:00
Octopus 622aa8683d fix: remove redundant status(modified:false) call to restore undo/redo (fixes #779) (#780) 2026-04-10 10:30:05 +09:00
Octopusandocto-patch 43ddb7a999 fix: allow Qwen3.5 models to use image input (fixes #799) (#800)
Qwen3.5 models deployed via vLLM natively support image input, but the
supportsImageInput() check was incorrectly blocking them. The function
only exempted qwen3.5-plus and qwen3.5-flash variants, missing the base
qwen3.5 model name.

Simplify the exception to cover all qwen3.5 variants with a single
substring check on "qwen3.5", since it is a common prefix of all three.

Co-authored-by: octo-patch <[email protected]>
2026-04-10 10:23:39 +09:00
Dayuan Jiang b9fdf9538c chore: remove MCP preview labels (#790)
MCP server is no longer in preview. Remove "(Preview)" headings from
READMEs, the purple PREVIEW badge from the UI, and the preview i18n keys.
2026-04-06 10:17:38 +09:00
Dayuan Jiang 1f31692701 chore: always bundle latest draw.io version in Electron builds (#792)
Remove pinned v29.3.5 tag so the build always clones the latest draw.io.
This adds the Animated GIF export and other new features to the Electron app.

Closes #770
2026-04-06 10:14:50 +09:00
Dayuan Jiang 31819f413c fix: add 10MB body size limit to MCP HTTP endpoints (#791)
All three POST handlers (/api/state, /api/restore, /api/history-svg)
now use a shared readBody() helper that enforces a 10MB limit and
returns 413 if exceeded, preventing memory exhaustion from oversized
requests.

Bumps @next-ai-drawio/mcp-server to 0.1.19.
2026-04-06 09:14:20 +09:00
Dayuan Jiang 41c410c2ba fix: bind MCP server HTTP to 127.0.0.1 only (#787)
The embedded HTTP sidecar was using server.listen(port) without a host
argument, which defaults to 0.0.0.0 (all interfaces). This exposed the
server to the local network. Now explicitly binds to 127.0.0.1.

Also excludes release/ from tsconfig to fix pre-existing TS errors.

Bumps @next-ai-drawio/mcp-server to 0.1.18.
2026-04-06 09:04:38 +09:00
Dayuan Jiang f593901fee fix: zoom reset on drag and IndexedDB version conflict (#776)
- Fix zoom resetting when dragging items (#775): removed useEffect that
  called load() on every autosave-triggered chartXML change, which reset
  the viewport. Moved diagram restore logic to onDrawioLoad where it
  only fires on remount.

- Fix IndexedDB VersionError: template-storage.ts shared the same DB
  name as session-storage.ts but at version 2, causing session storage
  to fail with "requested version (1) < existing version (2)". Give
  templates their own DB ("next-ai-drawio-templates").
2026-04-03 16:44:42 +09:00
Octopusanddayuan.jiang 6c6cf98019 fix: merge system messages for custom OpenAI-compatible endpoints (#774)
* fix: merge system messages for custom OpenAI endpoints (fixes #734)

When using the OpenAI provider with a custom base URL (e.g., vLLM, LMStudio),
the app sends two system messages to the API. Open-source model chat templates
(Qwen, Llama, etc.) enforce that system messages must appear at the beginning
and reject multiple system message blocks, causing the error:
'System message must be at the beginning.'

Treat custom OpenAI endpoints (client-provided base URL or OPENAI_BASE_URL env
var) the same as other known single-system providers by merging both system
messages into one before sending.

* fix: also detect custom OpenAI endpoint from serverModelConfig.baseUrlEnv

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-04-03 12:27:29 +09:00
f5ea5a0edd feat: add personal My Templates library alongside Quick Examples (#773)
* 增加了ralph自动化编程梳理

* feat: US-001 - 为模板库建立独立的 IndexedDB 存储层

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-002 - 在空聊天状态用我的模板库替换官方示例

- 将 ChatLobby 中的 Quick Examples 替换为 TemplatePanel
- 当没有历史会话时,展示完整的模板库面板
- 当有历史会话时,展示可折叠的 "My Templates" 区域
- 使用 TemplatePanel 组件展示用户的个人模板库

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-004 - Provide template creation flow

- Create TemplateCreateDialog component with form fields for prompt, title, description, tags, and pinned
- Add i18n translations for template creation UI in en, zh, zh-Hant, ja
- Update TemplatePanel to integrate the create dialog
- Support initialPrompt prop for pre-filling from current input
- Validate required prompt field (empty prompt not allowed)
- Auto-generate default title from first 20 chars of  Pin templates appear at top of list

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-005 - 为模板卡片提供编辑、删除和复制操作

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-006 - Send template directly on click and record usage statistics

- Implement click-to-send template functionality with confirmation dialog
- Add clickCount and runCount increment logic
- Display runCount and lastUsedAt on template card
- Add i18n translations for confirmation dialog (en, zh, zh-Hant, ja)
- Pass onSendTemplate and currentInput props through component hierarchy

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-007 - Support template search, pin and default sorting

- Add search bar to TemplatePanel with real-time filtering by title, description, and tags
- Add pin/unpin toggle button on template cards (uses Bookmark icon with fill indicator)
- Search uses existing searchTemplates function from template-storage
- Sort uses existing sortTemplates function (pinned desc, runCount desc, lastUsedAt desc, updatedAt desc)
- Show empty state with Search icon when search returns no results
- List re-sorts immediately after pin/unpin toggle
- Add i18n keys: searchPlaceholder, searchNoResults, pin, unpin for all 4 languages

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-008 - Support saving current input as template

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-009 - Support saving historical user message as template

- Add "Save as Template" button to user messages
- Pre-fill prompt with original user message text
- Only show on user messages,- Dialog opens TemplateCreateDialog on click
- Template appears in list immediately after saving

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: US-010 - Support template import and export

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore: remove local-only dirs from git tracking (.agents, .cursor, scripts, screenshots)

These directories contain local IDE configs, agent scripts, and
dev tooling that should not be part of the upstream repository.
Added them to .gitignore to prevent future accidental commits.

* chore: remove AGENTS.md from git tracking

* fix: add missing i18n keys for template export/import (en/zh/zh-Hant/ja)

* fix: review fixes for my-templates PR

- Fix fragile querySelector("form") with id-based lookup
- Add objectStoreNames.contains guards for IndexedDB upgrades
- Remove duplicate TemplateSchema, import from template-storage
- Revert contributor-specific .gitignore additions
- Fix broken i18n placeholders and missing translations (zh/ja/zh-Hant)
- Remove unused setFiles prop from ChatLobby
- Remove tags feature (unnecessary complexity)
- Improve template card layout: overlay icons on hover, align stats
- Add break-all and overflow-hidden for long prompt text in dialogs
- Move incrementClickCount into sendTemplate for accurate tracking
- Use Intl.RelativeTimeFormat for locale-aware relative time

* feat: restore Quick Examples panel and add lobby panel visibility settings

Bring back the ExamplePanel as a third collapsible section in ChatLobby
alongside Recent Chats and My Templates. Add toggle switches in Settings
to show/hide each lobby panel, persisted via localStorage.

* fix: template send race condition, import defaults, and empty title bug

- Use flushSync instead of setTimeout(0) in handleSendTemplate to
  ensure React state is flushed before form submission
- Explicitly validate and default all fields in importTemplates to
  prevent undefined counters from malformed import JSON
- Fall back to existing title in edit dialog instead of writing undefined

* fix: address Copilot review comments and remove PRD file

- Fix fallback formatLastUsed returning "Not used yet" for recent usage
- Remove dead mounted flag in TemplatePanel useEffect
- Respect panel visibility settings in no-history lobby state
- Reject empty/whitespace titles in import validation
- Trim title/prompt in importTemplates with default title fallback
- Remove tasks/prd-template-library-replaces-examples.md from repo

* fix: remove double sort, dead code, redundant stats, and break-all CSS

- Remove redundant sortTemplates call in loadTemplates (already sorted by getAllTemplates)
- Remove unused createEmptyTemplateInput and sortTemplates import
- Show "Not used yet" only once for unused templates instead of twice
- Use break-words instead of break-all on prompt textareas

---------

Co-authored-by: 杜雷 <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: dayuan.jiang <[email protected]>
2026-04-03 12:16:01 +09:00
zongxi1115anddayuan.jiang cb8127920c feat: add xmlsvg export option (#761)
* feat: add xmlsvg export option

* fix: avoid atob for xmlsvg export to prevent UTF-8 corruption

Pass the data URL directly (like PNG export) instead of decoding
with atob(). atob() + Blob([string]) double-encodes non-ASCII
characters (Chinese, Japanese, emoji), corrupting the output file.

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-04-03 08:50:57 +09:00
Dayuan Jiang 29538782fc docs: add AI tools guidelines to CONTRIBUTING.md (#771) 2026-04-01 22:05:46 +09:00
renovate[bot] 8d6d33bfc9 fix(deps): update minor and patch dependencies (#767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 04:50:36 +00:00
Dayuan Jiang 0690f05399 chore: bump version to 0.4.14 (#764) 2026-03-30 21:36:51 +09:00
Dayuan Jiang f9c30d95b2 Merge pull request #709 from DayuanJiang/fix/electron-startup-port-issues
fix: resolve Electron startup failures on Windows and Linux
2026-03-30 20:00:45 +09:00
Dayuan Jiang 95e3e97b8b Merge pull request #758 from Alex-wuhu/novita-integration
feat: add Novita AI as LLM provider
2026-03-30 19:44:23 +09:00
Dayuan Jiang 16801f69b4 Merge pull request #743 from Biki-dev/some-ui-fixes
Fix: Keep settings dialog within the viewport and change the scrollbar property from hidden to thin
2026-03-30 19:29:06 +09:00
Dayuan Jiang 4f41cd1f01 Merge pull request #648 from DayuanJiang/fix/idb-closing-retry
fix: recover IDB closing + restore diagram-only sessions
2026-03-30 19:27:58 +09:00
Alex-wuhuandClaude Opus 4.6 3ca46f44c1 fix: add novita case to validate-model route
Adds the missing 'novita' case to the OpenAI-compatible provider
block in the validate-model API route, fixing 400 errors when
users test their Novita API key in the UI.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-29 01:05:40 +08:00
Alex-wuhu a5871ded9b docs: add Novita AI config to env.example
Add NOVITA_API_KEY and NOVITA_BASE_URL to env.example so users
can discover the configuration variables. Also add novita to
the AI_PROVIDER options list.
2026-03-25 15:03:51 +08:00
Biki KalitaandCopilot c3ff41ce72 Apply suggestion from @Copilot
Co-authored-by: Copilot <[email protected]>
2026-03-24 17:43:53 +05:30
Biki Kalita 9efec68cbc change scrollbar-hidden to scrollbar-thin in ModelSelector and AI Model Configuration Right Panel 2026-03-24 12:11:36 +00:00
Biki Kalita 529a3fe2e5 git commit -m "Remove unintended global.css changes from PR" 2026-03-24 12:00:13 +00:00
Alex-wuhu 571167f9dd feat: add Novita AI as LLM provider
Add Novita AI as a new LLM provider with OpenAI-compatible API support.
Users can now select Novita from all entry points (CLI, config, UI).

- Add 'novita' to ProviderName type
- Add Novita AI entry to PROVIDER_INFO with default base URL
- Add Novita suggested models (kimi-k2.5, glm-5, minimax-m2.5)
- Add 'novita' to ALLOWED_CLIENT_PROVIDERS
- Add NOVITA_API_KEY environment variable mapping
- Add novita case to getAIModel() switch using OpenAI-compatible API
- Add novita to SINGLE_SYSTEM_PROVIDERS for proper message handling
2026-03-24 19:48:34 +08:00
Dayuan Jiang 67b0d77fa1 fix(mcp-server): restrict CORS to same-origin only (#757)
Replace wildcard `Access-Control-Allow-Origin: *` with same-origin check,
preventing external websites from accessing MCP server APIs via cross-origin requests.
2026-03-24 11:50:03 +09:00
sbillyanddayuan.jiang 524b77a948 feat: add glm vision model check (#741)
* Implement GLM model identification logic

Add checks for GLM text and visual model naming conventions.

* fix: simplify GLM vision detection and add tests

- Remove redundant includes("v-") check that could cause false positives
  on model names containing "dev-", "csv-", etc.
- Remove unnecessary includes("v") pre-check
- Update comments with real GLM model names
- Add unit tests for GLM text and vision models

* feat: add vision detection for MiniMax, Moonshot, and fix Qwen

- Add MiniMax text model detection (M2.x series are text-only)
- Add Moonshot v1 text model detection (moonshot-v1-* are text-only)
- Add qwen3.5-flash to Qwen vision model exceptions
- Add unit tests for all new model checks

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-03-21 01:22:44 +09:00
Dayuan JiangandClaude Opus 4.6 9bf0c7f23f chore: remove security audit step from CI (#744)
The npm audit check was failing due to vulnerabilities in transitive
dependencies (e.g. wrangler), blocking unrelated PRs.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-21 00:46:04 +09:00
Biki Kalita 9b9a291e2b scrollbar hidden to thin 2026-03-20 12:31:16 +00:00
Biki Kalita ada2c840b9 fix: keep settings dialog within viewport with hidden scrollbar 2026-03-20 11:52:23 +00:00
OctopusandPR Bot 43cc4cb657 feat: upgrade MiniMax default model to M2.7 (#737)
- Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list
- Set MiniMax-M2.7 as default model
- Keep all previous models as alternatives
- Update docs in EN/CN/JA

Co-authored-by: PR Bot <[email protected]>
2026-03-18 21:25:45 +09:00
Dayuan Jiang fd84aa70db chore: update volcengine referral URL and sponsor info (#736)
- Replace newyear-referral URL with new Coding Plan referral URL across all files
- Update model name from K2-thinking to glm-4.7
- Add volcengine invite poster to Chinese README and about page
2026-03-16 20:48:39 +09:00
Dayuan Jiang 8b0beb68c0 fix(electron): use explicit file sets for electron-builder 26.8 compatibility (#732)
Use explicit from/to/filter file sets instead of top-level globs to
avoid a regression in electron-builder 26.8.x where dist-electron
files were not being included in the asar archive.
2026-03-07 22:35:54 +09:00
Dayuan Jiang 7a6a933cf6 fix(electron): explicitly include package.json in asar for electron-builder 26.8 (#731)
electron-builder 26.8.x no longer auto-includes package.json when a
custom files array is specified, causing the main entry file to not be
found in the asar archive.
2026-03-07 21:37:56 +09:00
Dayuan Jiang 05bda50b2a chore: bump version to 0.4.13 (#730) 2026-03-07 19:14:16 +09:00
Dayuan Jiang e7453e86a6 feat: add custom system message setting for AI personalization (#728)
* feat: add custom system message setting for AI personalization

Allow users to enter custom instructions via a textarea in Settings
that get appended to the AI's system prompt. Includes server-side
validation (type check + 5000 char limit), localStorage persistence,
and i18n support for all 4 locales.

* fix: add accessibility htmlFor/id pairing on custom system message textarea
2026-03-07 19:07:54 +09:00
be4bc916fd feat: Add support for Chinese AI providers (GLM, Qwen, Kimi, MiniMax, Qiniu)
* feat: Add support for Chinese AI providers (GLM, Qwen, Kimi, MiniMax, Qiniu)

- Add minimax, glm, qwen, qiniu, kimi to ProviderName type
- Add provider configurations to PROVIDER_INFO with default base URLs
- Add suggested models for MiniMax in SUGGESTED_MODELS
- Add minimax/glm/qwen/qiniu/kimi cases to getAIModel using OpenAI-compatible SDK
- Update ALLOWED_CLIENT_PROVIDERS and error messages
- Add environment variable examples to env.example

Fixes: MiniMax API compatibility issue (invalid chat setting 2013)

* fix: Add missing providers to PROVIDER_ENV_VARS type

* fix: Handle null case in PROVIDER_ENV_VARS for new providers

* fix: Add minimax/glm/qwen/kimi/qiniu support to validate-model API

- Add getDefaultBaseUrl helper function
- Add validation cases for new providers in validate-model route

* fix: Add new providers to buildProviderOptions switch case

* fix: Merge multiple system messages into one for minimax/glm/qwen/kimi/qiniu

MiniMax API doesn't support multiple system messages.
This fix combines them into a single message for Chinese providers.

* fix: Handle null provider in system message check

* debug: Add logging for allMessages count

* fix: Use effective provider (including env var fallback) for isSingleSystemProvider check

* fix: apply biome formatting (line-wrapping)

* docs: add Chinese AI providers documentation (MiniMax, GLM, Qwen, Kimi, Qiniu)

- Add i18n translations for new providers in all language dictionaries
- Add provider configuration documentation in en/cn/ja docs

* fix: 改进 PR #722 的代码审查反馈

1. 删除重复的 getDefaultBaseUrl 函数,改用 model-config.ts 的 PROVIDER_INFO
2. validate-model 路由改用 AI SDK 的 createOpenAI + generateText
3. 修复 resolveBaseURL 回退逻辑,传入 PROVIDER_INFO 的 defaultBaseUrl
4. 删除无用的 .bak 备份文件

Co-authored-by: Shinyi <[email protected]>

* fix: 修正中国 AI provider 端点配置

- qiniu: api.qiniucdn.com → api.qnaigc.com
- qwen: dashscope.aliyun.com → dashscope.aliyuncs.com
- 更新 env.example 文档链接

Co-authored-by: Shinyi <[email protected]>

* feat: MiniMax 使用 Anthropic 兼容 API

- MiniMax 改用 createAnthropic (而非 createOpenAI)
- 支持 api.minimax.io/anthropic 和 api.minimaxi.com/anthropic
- 合并多个 system 消息为单个 (MiniMax/GLM/Qwen/Kimi/Qiniu)
- 更新默认模型为 MiniMax-M2.5 系列
- 支持 MINIMAX_BASE_URL 环境变量配置

Co-authored-by: Shinyi <[email protected]>

* docs: 更新 MiniMax 文档

- 添加 Anthropic 兼容 API 说明
- 更新默认模型为 MiniMax-M2.5
- 添加国际版/中国大陆版配置示例
- 更新 env.example 注释

Co-authored-by: Shinyi <[email protected]>

* fix: 完善 MiniMax 双端点支持及问题修复

- 支持 MiniMax Anthropic 兼容端点和 OpenAI 兼容端点自动切换
- 修正默认端点为 api.minimaxi.com (中国大陆可用)
- 修复端点路径缺少 /v1 的问题
- 添加前端 MiniMax logo 映射
- 移除调试日志
- 修正 env.example 默认配置

* chore: clean backup artifacts and align biome formatting

* fix: resolve effectiveProvider bug, deduplicate MiniMax URL logic, fix docs

- Fix critical bug: effectiveProvider was empty during auto-detection,
  causing multi-system-message to be sent to MiniMax (which rejects it).
  Now uses resolved provider from getAIModel instead of re-deriving it.
- Extract normalizeMiniMaxBaseURL() shared helper to eliminate duplication
  between ai-providers.ts and validate-model/route.ts
- Add guard for undefined MiniMax baseURL to prevent hitting api.anthropic.com
- Fix docs: mark China mainland URL as default (matches code behavior)
- Restructure minimax/glm/qwen/kimi/qiniu validation to use shared pattern

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: document MiniMax dual API formats in docs and UI

- Add hint below Base URL input when MiniMax is selected, explaining
  Anthropic-compatible (/anthropic) vs OpenAI-compatible (/v1) endpoints
- Update all 3 ai-providers docs (en/cn/ja) to list all 4 endpoint options
  (China/International × Anthropic/OpenAI)
- Add i18n translations for the hint in all 4 locales

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: deduplicate PROVIDER_LOGO_MAP, remove unnecessary optional chaining

- Extract PROVIDER_LOGO_MAP to lib/types/model-config.ts (was duplicated
  in model-config-dialog.tsx and model-selector.tsx)
- Remove unnecessary ?. on PROVIDER_INFO.minimax (it's a full Record)

---------

Co-authored-by: msga-oc <[email protected]>
Co-authored-by: Shinyi <[email protected]>
Co-authored-by: dayuan.jiang <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-07 18:53:47 +09:00
renovate[bot] c4ba5d4ea8 fix(deps): update minor and patch dependencies (#723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 00:53:43 +00:00
Bryon Nevisanddayuan.jiang ff07975574 fix: Eliminate spurious biome schema version errors on "biome ci" command (#716)
* fix: Eliminate spurious biome schema version errors on "biome ci" command

Stronger fix for commit dd9d79d2d6

Apply the BKM at https://biomejs.dev/internals/versioning/
that recommends pinning a specific version of biome
(current version of package-lock.json uses 2.4.4,
which was previously installed with ^2.3.10)

Otherwise, if npm is allowed to upgrade at its discretion,
project will have to continually chase the latest schema version.

The specific error that is fixed when "biome ci" is invoked:

```
  ℹ The configuration schema version does not match the CLI version (local installed version)

  > 2 │     "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
      │                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
```

package-lock.json is also updated to reflect the package.json change.

Signed-off-by: Bryon Nevis <[email protected]>

* fix: clean up unrelated package-lock.json changes

Revert unrelated peer/encoding metadata changes that were
artifacts of a different npm version, keeping only the
biome version pin change.

Signed-off-by: Dayuan Jiang <[email protected]>

---------

Signed-off-by: Bryon Nevis <[email protected]>
Signed-off-by: Dayuan Jiang <[email protected]>
Co-authored-by: dayuan.jiang <[email protected]>
2026-02-28 09:47:14 +09:00
Dayuan Jiang ffac74b0ff docs: add guidelines for discussing significant changes and handling Copilot reviews (#714) 2026-02-28 00:41:34 +09:00
Bryon Nevisanddayuan.jiang 69bd13bc93 feat: Turn off certain features of quota popup for self-hosting (#703)
* feat: Turn off certain features of quota popup for self-hosting

This commit introduces a new variable, NEXT_PUBLIC_SELFHOSTED,
that alters the behavior of the quota popup. Downstream
consumers of the application may have their own quota-checking
logic, and the front-end reacts to the 429 error by displaying
the quota popup.  In the case of a self-hosted version of the app,
it is inappropriate to ask for sponsorship or provide a
hyperlink to the public version of the tool to apply for an
increased quota. An alternative string translation is provided
with an empty message for adopter customization.

To use this feature, compile with NEXT_PUBLIC_SELFHOSTED=true
and those parts of the quota popup will be omitted.
The downstream consumer is still expected to customize
the internationalized strings for the popup content
to be appropriate to their organization on their local forks.

Signed-off-by: Bryon Nevis <[email protected]>

* refactor: improve readability and provide sensible selfhosted defaults

- Extract nested ternary expressions into quotaMessage and tipHtml variables
- Combine two separate !isSelfHosted conditional blocks into one
- Replace null tipSelfHosted with meaningful default strings across all locales

---------

Signed-off-by: Bryon Nevis <[email protected]>
Co-authored-by: dayuan.jiang <[email protected]>
2026-02-28 00:34:05 +09:00
Bryon Nevis dd9d79d2d6 fix: Changes to make npm run check GH status check pass (#710)
The Lint & Unit Tests PR status check is failing
at the "Run lint" step over a half-dozen issues.
This is causing all PR's to fail the Lint & Unit tets check.
This fix resolves those issues.

Signed-off-by: Bryon Nevis <[email protected]>
2026-02-28 00:24:24 +09:00
Bryon Nevis f3c3614b53 fix: CVE-2026-26278 and CVE-2026-25896 (#707)
Update "@opennextjs/cloudflare": "^1.17.1"
to resolve critical CVE in fast-xml-parser

Signed-off-by: Bryon Nevis <[email protected]>
2026-02-28 00:17:36 +09:00
Bryon Nevis 6586c53093 fix: Enable build-time variable to tell electron skip binary DL (#701)
By default, the electron package tries to download binaries using
a direct HTTP connection. This fix adds an build-time varaible to
the Dockerfile to skip the download of the electron binary,
which is enabled by default.

Note that if binary download is still wanted for some reason,
and the download is happening behidn a proxy,
one must modify the Dockerfile to use ELECTRON_GET_USE_PROXY
and supply http_proxy, https_proxy, NO_PROXY build args.

Signed-off-by: Bryon Nevis <[email protected]>
2026-02-27 23:53:37 +09:00
dayuan.jiang c083782802 fix: resolve Electron startup failures on Windows and Linux
- Try legacy port (61337) first to preserve existing users' localStorage,
  fall back to 13370 which is below the Windows Hyper-V ephemeral range (#705)
- Bind server and all URL references to 127.0.0.1 instead of localhost
  to fix IPv4/IPv6 mismatch on Linux (#684)
- Add OS-assigned port fallback (port 0) so startup never throws
- Log error codes in port checks for easier debugging
- Update localhost guards in index.ts and window-manager.ts to also
  match 127.0.0.1

Related: #705, #684
2026-02-26 22:36:07 +09:00
Marvelous Ikponmwosaanddayuan.jiang a5d1554c3f Add Ollama Cloud support with Base URL and API Key configuration (#692)
* Add Ollama Cloud support with Base URL and API Key configuration

* implemented feedback

* fix: use OLLAMA_BASE_URL env fallback in validate-model endpoint

* Remove dedicated Ollama configuration block

* security(ollama): prevent API key leak to client-controlled URLs

* added test

* fix: security hardening and Ollama Cloud default URL

- Add server OLLAMA_API_KEY fallback to validate-model endpoint with
  SSRF guard mirroring ai-providers.ts
- Tighten top-level SSRF exemption: only exempt Ollama when no server
  OLLAMA_API_KEY is configured
- Update Electron config to support OLLAMA_API_KEY env var
- Change default Ollama URL from localhost:11434 to ollama.com/api
  (Ollama Cloud) for web UI users
- Add tests for server env combo, API-key-only, and SSRF guard scenarios

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-02-26 21:55:21 +09:00
wandClaude Opus 4.6 e171fbcdd8 feat: add qwen3.5-plus vision model support (#706)
Add qwen3.5-plus to SiliconFlow and ModelScope suggested models.
Mark qwen3.5-plus as a vision-capable model in supportsImageInput check.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-26 20:45:57 +09:00
Elshad Humbatli 89d3968733 Fix: return clear error for PDF URLs in content extraction (#694)
* fix: return clear error for PDF urls

* handle timeout thoroughly + use hoisting for user agent
2026-02-13 18:44:59 +09:00
renovate[bot] ac3570c1b0 fix(deps): update minor and patch dependencies (#671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 18:50:49 +00:00
Dayuan Jiang 41dc0b2b42 feat: add Material Design Icons shape library (#688)
* feat: add Material Design Icons shape library (#685)

Add Google Material Design Icons as a new shape library using Google's
CDN. Includes top 300 most popular icons by usage, and updates system
prompts to guide the AI to call get_shape_library before using any icon
library.

* fix: align get_shape_library guidance for non-cloud icon libraries
2026-02-07 13:57:00 +09:00
Dayuan Jiang 3041dafe2f feat: add PNG/SVG export to MCP server (#687)
* feat: add PNG/SVG export support to MCP server export_diagram tool

Previously export_diagram only supported .drawio XML files. This adds
PNG and SVG export by leveraging the existing browser sync mechanism:
the MCP tool sets an exportFormat flag on the session state, the browser
detects it via polling and triggers an iframe export, then POSTs the
result back as exportData which the tool reads and writes to disk.

* fix: address PR review feedback for export feature

- Validate exportData is a string in POST /api/state
- Update lastUpdated in setExportFormat to prevent session expiry
- Gate export postMessage on isReady to avoid lost messages
- Remove unused fmt variable
- Fix double extension when path has a different supported extension

* fix: resolve high severity npm audit vulnerabilities

Run npm audit fix to update @aws-sdk and @smithy transitive dependencies
that had high severity advisories, which was failing the CI security audit step.

* fix: address second round of PR review feedback

- Add 8s timeout for pendingMcpExport to prevent permanent blocking
- Move export trigger after version update in poll() to export latest diagram
- Return 404 when session not found for exportData POST
- Sync browser state before .drawio export to avoid stale XML
- Handle URL-encoded SVG data URIs in addition to base64

* fix: address third round of PR review feedback

- Sync browser state before PNG/SVG export (not just drawio)
- Add 10MB body size limit on POST /api/state
- Validate export response format matches request to prevent race conditions

* refactor: remove over-engineered defensive code from export feature

Strip unnecessary validation/guards added from Copilot review that
don't make sense for a localhost-only MCP server: body size limit,
type validation, 404 for missing session, lastUpdated refresh,
URL-encoded SVG handling. Also deduplicate requestSync call.

* refactor: keep original drawio export path unchanged

Don't restructure the existing drawio logic - just add png/svg
as a separate branch after it.

* refactor: remove redundant helper functions, inline state access

Remove setExportFormat/getExportData/clearExportData wrappers that
were each called once. Access state fields directly via getState().

* chore: bump mcp-server version to 0.1.16
2026-02-07 12:55:09 +09:00
Khairil Rahman HakikiandCopilot 7fbc857d3a feat(ui): conditional model selector shadow logic (#681)
* feat(ui): conditional model selector shadow logic (#678)

- Update ModelSelectorList to conditionally render shadow based on scroll state
- Update CommandList to forward ref for scroll detection
- Resolves #678

* Update components/ui/command.tsx

Co-authored-by: Copilot <[email protected]>

* fix(ui): update listRef type to match CommandList forwarded ref

---------

Co-authored-by: Copilot <[email protected]>
2026-02-07 12:13:56 +09:00
Vishakha Agrawal d752524851 make show unvalidated models clickable (#680) 2026-02-07 10:36:13 +09:00
Gideon Ayenianddayuan.jiang ecce0a96c7 enhancement: Pin Configure Models button to bottom of model selector … (#665)
* enhancement: Pin Configure Models button to bottom of model selector (#637)

Keep "Configure Models..." and info text fixed at bottom of dropdown when the model list scrolls. Wire button to open Model Config dialog.

* Address PR review: use ModelSelectorItem, z-10 footer, padding on wrapper

* fix: reduce spacing between Configure Models button and info text

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-02-02 20:08:34 +09:00
Dayuan Jiang cd33e131ef feat: add API key load balancing for providers (#676)
Support multiple API keys per provider with random selection for load
balancing. When AI_MODELS_CONFIG has multiple apiKeyEnv values for
a provider, requests will randomly select one available key.

- Update schema to accept apiKeyEnv as string or string array
- Add random key selection in resolveApiKey()
- Update validation to check at least one key exists
- Add tests for array format support
2026-02-02 15:54:35 +09:00
Dayuan Jiang 4624ad40a1 fix: enable image support for Kimi K2.5 model (#670)
* fix: enable image support for Kimi K2.5 model

Kimi K2.5 supports image input but was incorrectly blocked by the
supportsImageInput check that excluded all Kimi models without
"vision" in the name. Updated the condition to only exclude the
older K2 model while allowing K2.5.

* fix: improve Kimi K2.5 image support logic and add tests

- Only block kimi-k2 specifically, not all Kimi models
- Add unit test for kimi-k2.5 image support
2026-02-02 00:14:27 +09:00
renovate[bot] fa1548d3eb chore(deps): update electron packages (#668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 23:45:23 +09:00
renovate[bot] eb834a341f fix(deps): update core framework packages (#667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 23:31:15 +09:00
Dayuan Jiang b84b156c8c chore: update Volcengine referral link to new year campaign (#666)
Update the Volcengine referral URL from console.volcengine.com to the
new year referral campaign URL across all documentation and code files.
2026-01-30 14:05:16 +09:00
Dayuan Jiang c6eab07622 Merge pull request #660 from broBinChen/feat/add-stop-generation-button
feat: add stop button to cancel AI generation
2026-01-30 11:22:53 +09:00
xiaobin c382d3c0f4 fix(i18n): remove dict.chat.sending key from i18n 2026-01-30 09:40:32 +08:00
broBinChen e065d4b727 Merge branch 'DayuanJiang:main' into feat/add-stop-generation-button 2026-01-30 09:34:45 +08:00
Dayuan Jiang b2b9614404 Merge pull request #657 from shibamudi/fix/path-handling-with-basepath
Fix: path handling with basepath
2026-01-30 00:42:25 +09:00
shibamudi 1b96325fea Adds pathname to effect dependencies
Ensures the effect hook updates correctly when the pathname changes,
improving synchronization with route-based state changes.
2026-01-30 00:02:26 +09:00
shibamudi 19dd4e287f Uses dynamic API endpoint for URL parsing
Replaces hardcoded API path with a dynamic endpoint resolver to support flexible deployments and ensure correct API routing in different environments.
2026-01-30 00:02:26 +09:00
shibamudi 699767f392 Uses dynamic API base path for model fetch
Replaces the hardcoded API endpoint with a function that retrieves
the correct base path for server model requests. Improves compatibility
with deployments where the API is not served from the root path.
2026-01-30 00:02:26 +09:00
shibamudi feeb9ec0c5 Refactors image component to support ref forwarding
Enables ref forwarding for improved integration with parent components
and libraries that require direct DOM access. Enhances flexibility and
maintainability by switching to a forwardRef implementation.
2026-01-30 00:02:26 +09:00
十八亩地andCopilot f1a3044224 Update components/image-with-basepath.tsx
Co-authored-by: Copilot <[email protected]>
2026-01-30 00:02:26 +09:00
十八亩地andCopilot 71a1d02e28 Update components/chat-panel.tsx
Co-authored-by: Copilot <[email protected]>
2026-01-30 00:02:26 +09:00
十八亩地andCopilot 2d17963b2a Update components/image-with-basepath.tsx
Co-authored-by: Copilot <[email protected]>
2026-01-30 00:02:26 +09:00
十八亩地andCopilot 3721b16d18 Update components/image-with-basepath.tsx
Co-authored-by: Copilot <[email protected]>
2026-01-30 00:02:26 +09:00
十八亩地 cbde35fb54 Update import path for getAssetUrl function 2026-01-30 00:02:26 +09:00
十八亩地andCopilot f098e557fc Update components/image-with-basepath.tsx
Co-authored-by: Copilot <[email protected]>
2026-01-30 00:02:26 +09:00
十八亩地andCopilot a245dcb150 Update components/image-with-basepath.tsx
Co-authored-by: Copilot <[email protected]>
2026-01-30 00:02:26 +09:00
shibamudi f2f5ac907d fix: update image imports to use custom Image component with base path handling 2026-01-30 00:02:26 +09:00
shibamudi 0a79536002 fix(chat-panel): update path handling with usePathname for URL management
When `basePath` is set, using `window.location.pathname` causes the `basePath` to be duplicated.
2026-01-30 00:02:25 +09:00
Biki Kalita f2c8fea58d i18n: localize stop button aria-label with chat.stopGeneration key across dictionaries 2026-01-29 19:10:25 +05:30
Dayuan Jiang 31fffcc52d Merge pull request #664 from DayuanJiang/fix/ollama-client-base-url
Supports client-provided base URL for Ollama
2026-01-29 21:57:05 +09:00
Dayuan Jiang 0a03ac9f45 Merge pull request #663 from DayuanJiang/fix/ollama-allowed-client-provider
Adds Ollama to allowed client providers list
2026-01-29 21:57:00 +09:00
Dayuan Jiang 6dad6ab147 Merge pull request #662 from DayuanJiang/fix/ollama-ssrf-exception
Adds Ollama to SSRF protection exception list
2026-01-29 21:56:54 +09:00
dayuan.jiang 8781520ebb Supports client-provided base URL for Ollama
Previously, Ollama only used the OLLAMA_BASE_URL environment variable.
Now client-provided base URL from settings takes priority, allowing
users to configure custom Ollama endpoints (e.g., remote servers).

Fixes #652
2026-01-29 21:25:20 +09:00
dayuan.jiang 67196225f0 Adds Ollama to allowed client providers list
Allows users to select Ollama as a provider from client settings.
Previously, Ollama was blocked with "Invalid provider" error even
though the UI supported it.

Fixes #652
2026-01-29 21:24:48 +09:00
dayuan.jiang 09a5774cba Adds Ollama to SSRF protection exception list
Ollama is a local/self-hosted model that doesn't require API keys.
The SSRF protection was incorrectly blocking Ollama connections
when users provided a custom base URL without an API key.

Fixes #652
2026-01-29 21:24:09 +09:00
broBinChenanddayuan.jiang fdd4be6463 fix(i18n): add missing translation keys in zh-Hant.json (#659)
* fix(i18n): add missing translation keys in zh-Hant.json

* fix(security): update next to fix high severity vulnerabilities

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-01-29 20:33:03 +09:00
xiaobin f8a0ebd149 feat: add stop button to cancel AI generation 2026-01-29 12:40:40 +08:00
broBinChen c9dd54dad7 fix(i18n): add missing translation keys in zh-Hant.json 2026-01-29 12:33:15 +08:00
Marvelous Ikponmwosa 1258f98478 feat: Add "API Keys & Models" link in Settings Dialog for better discoverability (#645)
* added api key and model in settings dialog

* added aria-label

* removed configure option from model-selector.tsx
2026-01-28 22:36:54 +09:00
yujinze f6f693ee8d Merge pull request #650 from DayuanJiang/feat/add-zh-hant-locale
feat(i18n): add Traditional Chinese (zh-Hant) locale
2026-01-27 20:02:02 +09:00
dayuan.jiang f19dc919e1 fix: prevent autosave from overwriting pending IDB restore 2026-01-27 13:43:25 +09:00
Jinze Yu 9398117368 feat(i18n): add Traditional Chinese (zh-Hant) locale
Add full Traditional Chinese support for Hong Kong/Taiwan users by
creating a zh-Hant dictionary and registering the locale across the
web app, metadata, and Electron desktop menu system.
2026-01-27 13:22:33 +09:00
dayuan.jiang 8d92474f73 fix: recover IDB on closing and restore diagram 2026-01-27 12:07:35 +09:00
dayuan.jiang cc6f09615d fix: restore diagram when chartXML changes after iframe ready
The previous logic only restored on iframe ready, but chartXML might
be set AFTER the iframe is ready (session loaded after iframe).

Now we track the last restored XML and load whenever chartXML changes
to a new real diagram while iframe is ready.
2026-01-27 10:10:43 +09:00
dayuan.jiang be20c09c89 fix: wait for canPersist check before rendering DrawIoEmbed
Fixes diagram not restoring after refresh. The issue was:
1. DrawIoEmbed rendered with canPersist=false
2. Session restore loaded diagram into iframe
3. canPersist changed to true, causing iframe remount
4. New iframe loaded empty, losing the diagram

Now we wait for isIndexedDBUsable() to complete before rendering,
avoiding the remount entirely.
2026-01-27 09:56:33 +09:00
dayuan.jiang 76487fb7ea fix: include canPersist in DrawIoEmbed key for proper remount
Address review comments: canPersist is set async after mount, so DrawIoEmbed
needs to remount when it resolves to apply correct configuration and URL params.
2026-01-27 09:38:35 +09:00
dayuan.jiang c5de1a16ad fix(electron): prevent beforeunload prompt by using autosave
- Enable draw.io autosave and handle autosave events to update chartXML
- Clear modified state after autosave to avoid beforeunload prompts
- Disable confirmExit in draw.io configuration
- Set modified=false and keepmodified=false URL parameters
- Fix session save condition to also save when only diagram exists
- Fix: Do not close shared IndexedDB connection in isIndexedDBUsable()
2026-01-27 09:23:03 +09:00
Dayuan Jiang cb0c0fbcda Revert "fix(electron): prevent beforeunload prompt by using autosave (#642)" (#646)
This reverts commit e7c29fb410.

The PR introduced an IndexedDB error: 'Failed to execute transaction on IDBDatabase: The database connection is closing.'
2026-01-27 09:20:42 +09:00
Dayuan Jiang e7c29fb410 fix(electron): prevent beforeunload prompt by using autosave (#642)
* fix(electron): prevent beforeunload prompt by using autosave

- Enable draw.io autosave and handle autosave events to update chartXML
- Clear modified state after autosave to avoid beforeunload prompts
- Disable confirmExit in draw.io configuration
- Set modified=false and keepmodified=false URL parameters
- Fix session save condition to also save when only diagram exists

* fix: persist diagram-only saves and ref typing

* fix: harden persistence checks and export timeout
2026-01-26 22:04:41 +09:00
Subhajeetch f0dd199cd1 feat(prompt): add language-aware response rules with english fallback (#641)
* feat(prompt): add language-aware response rules with english fallback

Added language handling rules for user interactions.

* refactor(prompt): simplify language matching instruction
2026-01-26 15:32:07 +09:00
Dayuan Jiang 7656b64018 chore: switch to release signing policy for Windows builds (#639) 2026-01-24 20:47:44 +09:00
Dayuan Jiang c3f88e54fe chore: bump version to 0.4.12 (#638) 2026-01-24 19:25:47 +09:00
Dayuan Jiang a55ef7adf9 feat(electron): bundle draw.io for offline support (#629)
* feat(electron): bundle draw.io for offline support

- Download draw.io static files during CI build (v29.3.5)
- Detect Electron and use local draw.io files instead of CDN
- Add offline=1 parameter to disable external service calls
- Skip /drawio path from i18n middleware redirect
- Add public/drawio/ to .gitignore (downloaded during build)

This allows the Electron app to work completely offline.

* chore: bump version to 0.4.12-beta.3 for offline test

* fix(electron): ad-hoc sign macOS app for bundled draw.io compatibility

* chore: bump version to 0.4.12-beta.4 for ad-hoc sign test

* fix(electron): disable electron-builder signing to use custom ad-hoc signing

* chore: bump version to 0.4.12-beta.5
2026-01-24 12:47:51 +09:00
Dayuan Jiang 984eaae04d feat(mcp): update UI with project logo and modern dialog styles (#636)
* feat(mcp): update UI with project logo and modern dialog styles

- Replace Next.js logo with project favicon-white.svg
- Modernize modal dialogs with backdrop blur, animations, and DM Sans font
- Update button styles to match header design
- Change title to "Next AI Draw.io"
- Rename "Save" to "Download" for clarity
- Bump version to 0.1.15

* chore: remove dead code (unused download helpers and pendingManualSave)
2026-01-24 12:43:39 +09:00
Dayuan Jiang 0ed06360aa fix(mcp): auto-redirect to active session when no sessionId provided (#634)
Fixes #633 - browser showing infinite loading spinner when accessing
the MCP server URL without the session parameter.

When users access http://localhost:6002 directly (without ?mcp=xxx),
the page now auto-redirects to the most recent active session instead
of showing "No session" with an infinite loading spinner.
2026-01-24 11:03:58 +09:00
Biki KalitaandCopilot b0313eb2dc feat: implement collapsible view for extracted URL content in chat (#616)
* feat: implement collapsible view for extracted URL content in chat

* Apply suggestion from @Copilot

Co-authored-by: Copilot <[email protected]>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <[email protected]>

* refactor: simplify iconColor ternary by removing redundant url condition

---------

Co-authored-by: Copilot <[email protected]>
2026-01-24 10:44:11 +09:00
Dayuan Jiang dc37ce7fb1 fix: upgrade wrangler to ^4.60.0 to address CVE-2026-0933 (#628)
Upgrade wrangler from 4.58.0 to ^4.60.0 to fix the command injection
vulnerability (CWE-78) in the --commit-hash parameter of wrangler pages
deploy command.

Fixes #627
2026-01-23 08:38:07 +09:00
Gideon Ayeni 0baa424bc4 enhancement: Add URL format hint to Base URL field (#593) (#603)
* enhancement: Add URL format hint to Base URL field (#593)

Add dynamic provider-specific format examples to Base URL field labels to clarify expected URL format. Includes missing defaultBaseUrl values for providers.

* fix: Use generic example URL instead of OpenAI URL for fallback
2026-01-20 22:39:33 +09:00
yujinzeanddayuan.jiang afddba364b Add VLM-based diagram validation (#602)
* [Feature] Add VLM-based diagram validation

Add automatic VLM (Vision Language Model) validation after display_diagram
tool execution. The system captures a screenshot of the rendered diagram,
sends it to a VLM for visual analysis, and uses feedback to improve
diagram quality through the existing retry mechanism.

Changes:
- Add /api/validate-diagram endpoint for VLM validation
- Add diagram-validator.ts for client-side validation orchestration
- Add validation-prompts.ts for VLM system prompts
- Add ValidationCard component to display validation status in chat
- Add PNG capture functionality to diagram context
- Integrate validation into tool handlers with retry support (max 3)
- Add "Improve with Suggestions" button for manual regeneration
- Add settings toggle to enable/disable VLM validation
- Add getValidationModel() helper in ai-providers.ts

* refactor(validation): use AI SDK structured outputs and address review feedback

- Replace generateText + manual JSON parsing with generateObject and Zod schema
  for type-safe structured validation output
- Use AbortSignal.timeout() instead of Promise.race for cleaner timeout handling
- Add timeout validation with minimum 1000ms to handle malformed env values
- Remove unused xml parameter from validateRenderedDiagram API
- Remove parseValidationResponse function (now handled by schema)
- Clear validationStates on session switch and new chat to prevent memory leak
- Update 100ms render delay comment to clarify best-effort heuristic
- Remove unused useEffect import from ValidationCard
- Fix optional chaining lint warning in ValidationCard
- Add unit tests for formatValidationFeedback function

* refactor(validation): use AI SDK experimental_useObject hook instead of raw fetch

- Change API endpoint from generateObject to streamObject for useObject compatibility
- Create useValidateDiagram hook using AI SDK's experimental_useObject for reactive validation
- Update useDiagramToolHandlers to accept validation function as parameter
- Update chat-panel to use new useValidateDiagram hook
- Remove validateRenderedDiagram function from lib/diagram-validator.ts (now in hook)
- Export ValidationResultSchema from API route for client-side use

* fix(validation): extract schema to shared file for client/server compatibility

Move ValidationResultSchema to lib/validation-schema.ts to avoid importing
server-side modules (ai-providers) into client-side code. This fixes the
Turbopack build error caused by the hook importing from the API route.

* fix(validation): use 'Valid' instead of 'Complete' for validation success

Change ValidationCard success label from 'Complete' to 'Valid' to avoid
conflicting with ToolCallCard's 'Complete' badge in E2E tests. This fixes
the diagram-generation E2E test that expects a specific count of 'Complete'
badges.

* fix(validation): add aria-hidden to icons to prevent duplicate ID warning

* fix: improve VLM validation with bug fixes and i18n

- Fix race condition in pendingValidationRef (reject previous pending validation)
- Fix response format consistency (use streaming for all responses)
- Remove dead code (unused lastRequestRef and ValidationRequest interface)
- Consolidate duplicate types (re-export from validation-schema.ts)
- Add 'success_with_warnings' status for valid diagrams with warnings
- Fix tool card auto-collapse (only collapse once, respect user toggle)
- Set VLM validation default to disabled
- Add i18n support for diagram validation settings (en/zh/ja)
- Mark feature as experimental in settings UI

* fix: resolve TypeScript errors in electron-standalone

- Add forwardRef support to ChatInput component with ChatInputRef type
- Copy electron.d.ts to electron-standalone/electron folder
- Exclude electron-standalone from root tsconfig type checking

* fix: return empty string for valid result with no issues in formatValidationFeedback

* feat(i18n): add validation strings for ValidationCard component

- Add validation section to en.json, zh.json, ja.json dictionaries
- Update ValidationCard to use useDictionary hook
- Replace all hardcoded English strings with i18n keys

---------

Co-authored-by: dayuan.jiang <[email protected]>
2026-01-20 20:52:04 +09:00
Dayuan Jiangandgithub-actions[bot] b386dc45e6 fix(quota): bypass quota for users with Bedrock credentials (#621)
* fix(quota): bypass quota for users with Bedrock credentials

The hasOwnApiKey check only looked for x-ai-api-key header, but Bedrock
users provide AWS credentials via x-aws-access-key-id instead. This
caused Bedrock users with their own credentials to still be subject to
quota limits.

* fix(quota): also bypass quota for Vertex AI users

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-20 20:27:31 +09:00
broBinChen 7b5a3075cf fix: replace ring with border for provider selection state (#617)
* fix: replace ring with border for provider selection state

* fix(settings): use auto width for send shortcut selector to prevent text truncation
2026-01-20 19:14:18 +09:00
225 changed files with 36709 additions and 16641 deletions
+19
View File
@@ -47,6 +47,10 @@ To run tests with UI mode:
npx playwright test --ui
```
## Before You Start
For **significant changes** (new features, architecture changes, large refactors, etc.), please **open an issue first** to discuss your proposal before writing code. This helps avoid wasted effort and ensures alignment with the project direction. Small bug fixes and minor improvements can go straight to a PR.
## Pull Requests
1. Create a feature branch
@@ -57,6 +61,21 @@ npx playwright test --ui
CI will run the full test suite on your PR.
## Using AI Tools
AI-assisted contributions are welcome. But please **review the output before opening a PR**:
1. **Review the code** — understand what was generated, don't just commit blindly
2. **Write a PR description** — explain what changed and why
3. **Rebase on latest `main`** — AI tools often work on stale branches, run `git rebase origin/main` before pushing
4. **Clean up artifacts** — remove IDE configs (`.idea/`, `.kiro/`), env files, scratch notes, and throwaway test scripts that AI tools leave behind
## Code Review
This project uses GitHub Copilot for automated code review. If you receive review comments from Copilot on your PR:
- **Valid suggestions**: Please address them in your code.
- **Invalid or irrelevant suggestions**: Feel free to click "Resolve" to dismiss them.
## Issues
Include steps to reproduce, expected vs actual behavior, and AI provider used.
+5
View File
@@ -33,6 +33,11 @@
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
},
{
"matchPackageNames": ["@biomejs/biome"],
"groupName": "Biome",
"automerge": false
}
],
"vulnerabilityAlerts": {
+3 -1
View File
@@ -23,7 +23,9 @@ jobs:
node-version: '24'
- name: Run Biome format
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
# 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/[email protected] check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
-2
View File
@@ -40,5 +40,3 @@ jobs:
- name: Build
run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev
+2 -1
View File
@@ -58,6 +58,8 @@ jobs:
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -89,4 +91,3 @@ jobs:
docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
+20 -1
View File
@@ -34,6 +34,15 @@ jobs:
node-version: 24
cache: "npm"
- name: Download draw.io static files for offline use
run: |
rm -rf public/drawio
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies
run: npm install
@@ -57,6 +66,16 @@ jobs:
node-version: 24
cache: "npm"
- name: Download draw.io static files for offline use
shell: bash
run: |
rm -rf public/drawio
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies
run: npm install
@@ -80,7 +99,7 @@ jobs:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io'
signing-policy-slug: 'test-signing'
signing-policy-slug: 'release-signing'
artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true
+71
View File
@@ -0,0 +1,71 @@
name: Publish MCP Server
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
# skips silently if the package.json version is already on npm — so a
# release is just "bump the version in a PR and merge".
on:
push:
branches:
- main
paths:
- "packages/mcp-server/**"
workflow_dispatch:
permissions:
contents: read
id-token: write # OIDC token for npm trusted publishing
concurrency:
group: publish-mcp
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/mcp-server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "npm"
cache-dependency-path: packages/mcp-server/package-lock.json
registry-url: "https://registry.npmjs.org"
# Trusted publishing requires npm >= 11.5.1
- name: Update npm
run: npm install -g npm@latest
- name: Check if version is already published
id: version
run: |
LOCAL=$(node -p "require('./package.json').version")
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
echo "Version ${LOCAL} already on npm - nothing to publish"
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "Version ${LOCAL} not on npm - publishing"
echo "publish=true" >> "$GITHUB_OUTPUT"
fi
- name: Install dependencies
if: steps.version.outputs.publish == 'true'
run: npm ci
- name: Test
if: steps.version.outputs.publish == 'true'
run: npm test
- name: 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
+14
View File
@@ -28,6 +28,20 @@ jobs:
- name: Run unit tests
run: npm run test -- --run
# The MCP server package ships its own vitest because its DOM polyfill
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
# for the Next.js app. Install + run its tests separately so CI catches
# multi-page mxfile regressions.
- name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
# 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
+9
View File
@@ -56,6 +56,8 @@ push-via-ec2.sh
/dist-electron/
/release/
/electron-standalone/
# Draw.io static files (downloaded during CI build)
public/drawio/
*.dmg
*.exe
*.AppImage
@@ -70,3 +72,10 @@ CLAUDE.md
.edgeone
opencode.json
ai-models.json
# local backups
*.bak
.gstack/
# admin panel settings (contains secrets)
data/
+9
View File
@@ -9,6 +9,7 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install dependencies
ARG ELECTRON_SKIP_BINARY_DOWNLOAD=1
RUN npm install
# Stage 2: Build application
@@ -34,6 +35,11 @@ ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
# Control sponsorship and self-hosting messaging in quota notifications.
# Set NEXT_PUBLIC_SELFHOSTED="true" in self-hosted deployments to hide sponsorship/self-host links and related text in quota popups.
ARG NEXT_PUBLIC_SELFHOSTED=""
ENV NEXT_PUBLIC_SELFHOSTED="${NEXT_PUBLIC_SELFHOSTED}"
# Build Next.js application (standalone mode)
RUN npm run build
@@ -55,6 +61,9 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Writable dir for admin panel settings (data/settings.json)
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
+46 -18
View File
@@ -19,7 +19,18 @@ English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) sponsorship, the demo site now uses the powerful K2-thinking model!
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
<p align="center">
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
</picture>
</a>
</p>
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -31,7 +42,7 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Table of Contents](#table-of-contents)
- [Examples](#examples)
- [Features](#features)
- [MCP Server (Preview)](#mcp-server-preview)
- [MCP Server](#mcp-server)
- [Claude Code CLI](#claude-code-cli)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
@@ -43,6 +54,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works)
- [Support \& Contact](#support--contact)
- [FAQ](#faq)
@@ -63,24 +76,24 @@ Here are some example prompts and their generated diagrams:
</tr>
<tr>
<td width="50%" valign="top">
<strong>GCP architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a GCP architecture diagram with **GCP icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/gcp_demo.svg" alt="GCP Architecture Diagram" width="480" />
<strong>RAG Technique Diagram</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>AWS architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a AWS architecture diagram with **AWS icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/aws_demo.svg" alt="AWS Architecture Diagram" width="480" />
<strong>Authentication using React and AWS</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Azure architecture diagram</strong><br />
<p><strong>Prompt:</strong> Generate a Azure architecture diagram with **Azure icons**. In this diagram, users connect to a frontend hosted on an instance.</p>
<img src="./public/azure_demo.svg" alt="Azure Architecture Diagram" width="480" />
<strong>Open Innovation</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" />
</td>
<td width="50%" valign="top">
<strong>Cat sketch prompt</strong><br />
<strong>Cat sketch</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" />
</td>
@@ -99,9 +112,7 @@ Here are some example prompts and their generated diagrams:
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## MCP Server (Preview)
> **Preview Feature**: This feature is experimental and may not be stable.
## MCP Server
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol).
@@ -127,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
@@ -202,7 +220,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
## Multi-Provider Support
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- AWS Bedrock (default)
- OpenAI
- Anthropic
@@ -211,11 +229,13 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
All providers except AWS Bedrock and OpenRouter support custom endpoints.
@@ -224,7 +244,13 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file.
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
@@ -244,7 +270,9 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
## Support & Contact
**Special thanks to [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
+91 -60
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "关于 - Next AI Draw.io",
@@ -78,7 +78,7 @@ export default function AboutCN() {
<p>
好消息!感谢{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -87,7 +87,7 @@ export default function AboutCN() {
</a>
的慷慨赞助,演示站点现已接入强大的{" "}
<span className="font-semibold text-amber-700">
K2-thinking
glm-4.7
</span>{" "}
模型,图表生成效果更佳!点击链接注册即可领取{" "}
<span className="font-semibold text-amber-700">
@@ -97,6 +97,23 @@ export default function AboutCN() {
</p>
</div>
{/* Invite Poster */}
<div className="text-center mb-5">
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/volcengine-invite.png"
alt="火山引擎方舟 Coding Plan"
width={300}
height={400}
className="mx-auto rounded-lg"
/>
</a>
</div>
{/* Bring Your Own Key */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
@@ -158,92 +175,106 @@ export default function AboutCN() {
</p>
<div className="space-y-8">
{/* Animated Transformer */}
{/* ResNet50 Architecture */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
动画Transformer连接器
ResNet50模型架构动画
</h3>
<p className="text-gray-600 mb-4">
<strong>提示词:</strong> 给我一个带有
<strong>动画连接器</strong>的Transformer架构图。
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p>
<Image
src="/animated_connectors.svg"
alt="带动画连接器的Transformer架构"
width={480}
height={360}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50模型架构图"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Cloud Architecture Grid */}
{/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP架构图
RAG技术图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>提示词:</strong> 使用
<strong>GCP图标</strong>
生成一个GCP架构图。用户连接到托管在实例上的前端。
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p>
<Image
src="/gcp_demo.svg"
alt="GCP架构图"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
AWS架构图
React和AWS认证流程
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>提示词:</strong> 使用
<strong>AWS图标</strong>
生成一个AWS架构图。用户连接到托管在实例上的前端。
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
</p>
<Image
src="/aws_demo.svg"
alt="AWS架构图"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="认证架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azure架构图
敏捷Scrum流程
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>提示词:</strong> 使用
<strong>Azure图标</strong>
生成一个Azure架构图。用户连接到托管在实例上的前端。
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
</p>
<Image
src="/azure_demo.svg"
alt="Azure架构图"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="敏捷Scrum流程图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
猫咪素描
开放式创新
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>提示词:</strong>{" "}
给我画一只可爱的猫。
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p>
<Image
src="/cat_demo.svg"
alt="猫咪绘图"
width={240}
height={240}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="开放式创新图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
</div>
</div>
@@ -277,7 +308,7 @@ export default function AboutCN() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -312,7 +343,7 @@ export default function AboutCN() {
<p className="text-gray-700 mb-4 font-semibold">
特别感谢{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+74 -61
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "概要 - Next AI Draw.io",
@@ -86,7 +86,7 @@ export default function AboutJA() {
<p>
朗報です!
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -95,7 +95,7 @@ export default function AboutJA() {
</a>
様のご支援により、デモサイトでは強力な{" "}
<span className="font-semibold text-amber-700">
K2-thinking
glm-4.7
</span>{" "}
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
<span className="font-semibold text-amber-700">
@@ -168,93 +168,106 @@ export default function AboutJA() {
</p>
<div className="space-y-8">
{/* Animated Transformer */}
{/* ResNet50 Architecture */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
アニメーションTransformerコネクタ
ResNet50モデルアーキテクチャアニメーション
</h3>
<p className="text-gray-600 mb-4">
<strong>プロンプト:</strong>{" "}
<strong>アニメーションコネクタ</strong>
付きのTransformerアーキテクチャ図を作成してください。
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p>
<Image
src="/animated_connectors.svg"
alt="アニメーションコネクタ付きTransformerアーキテクチャ"
width={480}
height={360}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50モデルアーキテクチャ図"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Cloud Architecture Grid */}
{/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
GCPアーキテクチャ図
RAG技術ダイアグラム
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>プロンプト:</strong>{" "}
<strong>GCPアイコン</strong>
を使用してGCPアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p>
<Image
src="/gcp_demo.svg"
alt="GCPアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAGアーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
AWSアーキテクチャ図
ReactとAWSによる認証
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>プロンプト:</strong>{" "}
<strong>AWSアイコン</strong>
を使用してAWSアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
</p>
<Image
src="/aws_demo.svg"
alt="AWSアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="認証アーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azureアーキテクチャ図
アジャイルスクラムプロセス
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>プロンプト:</strong>{" "}
<strong>Azureアイコン</strong>
を使用してAzureアーキテクチャ図を生成してください。ユーザーがインスタンス上でホストされているフロントエンドに接続します。
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
</p>
<Image
src="/azure_demo.svg"
alt="Azureアーキテクチャ図"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="アジャイルスクラム図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
猫のスケッチ
オープンイノベーション
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>プロンプト:</strong>{" "}
かわいい猫を描いてください。
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p>
<Image
src="/cat_demo.svg"
alt="猫の絵"
width={240}
height={240}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="オープンイノベーション図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
</div>
</div>
@@ -292,7 +305,7 @@ export default function AboutJA() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -327,7 +340,7 @@ export default function AboutJA() {
<p className="text-gray-700 mb-4 font-semibold">
デモサイトのAPIトークン使用を支援してくださった{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+73 -63
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "About - Next AI Draw.io",
@@ -87,7 +87,7 @@ export default function About() {
Great news! Thanks to the generous
sponsorship from{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
@@ -96,7 +96,7 @@ export default function About() {
</a>
, the demo site now uses the powerful{" "}
<span className="font-semibold text-amber-700">
K2-thinking
glm-4.7
</span>{" "}
model for better diagram generation! Sign up
via the link to get{" "}
@@ -182,96 +182,106 @@ export default function About() {
</p>
<div className="space-y-8">
{/* Animated Transformer */}
{/* ResNet50 Architecture */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated Transformer Connectors
Animated ResNet50 Model Architecture
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated connector</strong> diagram of
transformer&apos;s architecture.
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p>
<Image
src="/animated_connectors.svg"
alt="Transformer Architecture with Animated Connectors"
width={480}
height={360}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="Architecture diagram for ResNet50 model"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Cloud Architecture Grid */}
{/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
GCP Architecture Diagram
RAG Technique Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a GCP
architecture diagram with{" "}
<strong>GCP icons</strong>. Users connect to
a frontend hosted on an instance.
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p>
<Image
src="/gcp_demo.svg"
alt="GCP Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
AWS Architecture Diagram
Authentication using React and AWS
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an AWS
architecture diagram with{" "}
<strong>AWS icons</strong>. Users connect to
a frontend hosted on an instance.
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
</p>
<Image
src="/aws_demo.svg"
alt="AWS Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="Authentication Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Azure Architecture Diagram
Agile Scrum Process
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate an Azure
architecture diagram with{" "}
<strong>Azure icons</strong>. Users connect
to a frontend hosted on an instance.
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
</p>
<Image
src="/azure_demo.svg"
alt="Azure Architecture Diagram"
width={400}
height={300}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="Agile Scrum Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Cat Sketch
Open Innovation
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Draw a cute cat for
me.
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p>
<Image
src="/cat_demo.svg"
alt="Cat Drawing"
width={240}
height={240}
className="mx-auto"
/>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="Open Innovation Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
</div>
</div>
@@ -311,7 +321,7 @@ export default function About() {
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@@ -348,7 +358,7 @@ export default function About() {
<p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "}
<a
href="https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project"
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
+65
View File
@@ -0,0 +1,65 @@
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
}
export interface SettingState {
key: string
source: "file" | "env" | "default"
value: string | SecretValue | null
}
export type SettingsMap = Record<string, SettingState>
// Editable text of a saved setting; secrets have none (write-only)
export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
provider: ProviderName
models: string[]
isDefault: boolean
}
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
const res = await fetch(getApiEndpoint(path), {
...init,
headers: {
...init?.headers,
"x-admin-password": pw,
...(init?.body ? { "Content-Type": "application/json" } : {}),
},
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Request failed (${res.status})`)
}
return data
}
+621
View File
@@ -0,0 +1,621 @@
import {
AlertCircle,
Check,
Loader2,
Plus,
Star,
Trash2,
X,
Zap,
} from "lucide-react"
import { useState } from "react"
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
} from "./admin-shared"
import { SecretInput } from "./setting-field"
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
function ProviderDetail({
provider,
disabled,
password,
onUpdate,
onDelete,
}: {
provider: AdminProvider
disabled: boolean
password: string
onUpdate: (patch: Partial<AdminProvider>) => void
onDelete: () => void
}) {
const dict = useDictionary()
const [modelInput, setModelInput] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResults, setTestResults] = useState<
Record<string, { ok: boolean; message: string }>
>({})
const info = PROVIDER_INFO[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
(m) => !provider.models.includes(m),
)
const addModel = (modelId: string) => {
const trimmed = modelId.trim()
if (!trimmed || provider.models.includes(trimmed)) return
onUpdate({ models: [...provider.models, trimmed] })
setModelInput("")
}
const testModel = async (modelId: string) => {
setTesting(modelId)
try {
const data = await adminFetch("/api/admin/test-model", password, {
method: "POST",
body: JSON.stringify({ provider, modelId }),
})
setTestResults((prev) => ({
...prev,
[modelId]: data.valid
? {
ok: true,
message: formatMessage(dict.admin.testOk, {
ms: data.responseTime,
}),
}
: {
ok: false,
message: data.error || dict.admin.testFailed,
},
}))
} catch (err) {
setTestResults((prev) => ({
...prev,
[modelId]: {
ok: false,
message:
err instanceof Error
? err.message
: dict.admin.testFailed,
},
}))
} finally {
setTesting(null)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={provider.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">{info.label}</h3>
<p className="text-xs text-muted-foreground">
{provider.models.length === 0
? dict.admin.noModelsConfigured
: formatMessage(
provider.models.length === 1
? dict.admin.modelCount
: dict.admin.modelCountPlural,
{ count: provider.models.length },
)}
</p>
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Star
className={cn(
"h-3.5 w-3.5",
provider.isDefault &&
"fill-amber-400 text-amber-400",
)}
aria-hidden="true"
/>
{dict.admin.default}
<Switch
checked={!!provider.isDefault}
disabled={disabled}
aria-label={dict.admin.setAsDefault}
onCheckedChange={(checked) =>
onUpdate({ isDefault: checked })
}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
{dict.admin.delete}
</Button>
</div>
{/* Credentials (shared with the user ModelConfigDialog) */}
<ProviderCredentialsFields
provider={provider.provider}
name={provider.name}
baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion}
disabled={disabled}
onChange={(field, value) => onUpdate({ [field]: value })}
renderSecret={({ field, id }) => (
// Bare id keeps the shared component's <Label htmlFor={id}>
// associated; only one ProviderDetail is mounted at a time.
<SecretInput
id={id}
keepOnEmpty
value={provider[field]}
disabled={disabled}
onChange={(v) => onUpdate({ [field]: v })}
/>
)}
/>
{/* Models */}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{dict.admin.models}
</Label>
<div className="flex items-center gap-1.5">
<Input
value={modelInput}
disabled={disabled}
placeholder={dict.admin.modelIdPlaceholder}
spellCheck={false}
className="h-8 w-48 font-mono text-xs"
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addModel(modelInput)
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={disabled || !modelInput.trim()}
aria-label={dict.admin.addModel}
onClick={() => addModel(modelInput)}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
{suggestions.length > 0 && (
<Select
value=""
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
<SelectTrigger className="h-8 w-28 text-xs">
{dict.admin.suggested}
</SelectTrigger>
<SelectContent className="max-h-72">
{suggestions.map((m) => (
<SelectItem
key={m}
value={m}
className="font-mono text-xs"
>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="overflow-hidden rounded-lg border">
{provider.models.length === 0 ? (
<p className="p-5 text-center text-sm text-muted-foreground">
{dict.admin.addProviderToOfferModels}
</p>
) : (
<ul className="divide-y">
{provider.models.map((modelId, index) => {
const result = testResults[modelId]
return (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{provider.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
{result && (
<span
className={cn(
"flex items-center gap-1 text-xs",
result.ok
? "text-green-600 dark:text-green-400"
: "text-destructive",
)}
>
{result.ok ? (
<Check
className="h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<AlertCircle
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="max-w-48 truncate">
{result.message}
</span>
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={
disabled || testing !== null
}
onClick={() =>
void testModel(modelId)
}
>
{testing === modelId ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Zap
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="ml-1">
{dict.admin.test}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={disabled}
aria-label={formatMessage(
dict.admin.removeModel,
{ model: modelId },
)}
onClick={() =>
onUpdate({
models: provider.models.filter(
(m) => m !== modelId,
),
})
}
>
<X
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</Button>
</li>
)
})}
</ul>
)}
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage(dict.admin.deleteProviderTitle, {
name: provider.name || info.label,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.admin.deleteProviderDesc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.admin.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
setDeleteOpen(false)
onDelete()
}}
>
{dict.admin.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export function ModelsSection({
providers,
envProviders,
envHasDefaultModel,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
envHasDefaultModel: boolean
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
}) {
const dict = useDictionary()
const [selectedId, setSelectedId] = useState<string | null>(
providers[0]?.id ?? null,
)
const selected = providers.find((p) => p.id === selectedId)
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
id: generateId(),
provider,
models: [],
// 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)
}
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
onChange(
providers.map((p) => {
if (p.id !== id) {
// Only one default at a time
return patch.isDefault ? { ...p, isDefault: false } : p
}
return { ...p, ...patch }
}),
)
}
const deleteProvider = (id: string) => {
const next = providers.filter((p) => p.id !== id)
onChange(next)
setSelectedId(next[0]?.id ?? null)
}
return (
<div className="flex min-h-72 flex-col sm:flex-row">
{/* Provider list */}
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
<div className="flex-1 space-y-1 p-2">
{providers.length === 0 && envProviders.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{dict.admin.addProviderHint}
</p>
)}
{envProviders.map((p) => (
<button
key={`env:${p.name}`}
type="button"
onClick={() => setSelectedId(`env:${p.name}`)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === `env:${p.name}` &&
"bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{dict.admin.sourceEnv}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === p.id && "bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
</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)}
>
<SelectTrigger className="w-full">
<Plus
className="mr-1 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
{dict.modelConfig.addProvider}
</SelectTrigger>
<SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
(p) => {
// Global-credential providers already in
// the env config can't be added here —
// panel credentials would override theirs
const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) &&
envProviders.some(
(e) => e.provider === p,
)
return (
<SelectItem
key={p}
value={p}
disabled={envBlocked}
>
<div className="flex items-center gap-2">
<ProviderLogo provider={p} />
{PROVIDER_INFO[p].label}
{envBlocked && (
<span className="text-xs text-muted-foreground">
{
dict.admin
.managedViaEnv
}
</span>
)}
</div>
</SelectItem>
)
},
)}
</SelectContent>
</Select>
</div>
</div>
{/* Detail */}
<div className="min-w-0 flex-1 p-4">
{selected ? (
<ProviderDetail
key={selected.id}
provider={selected}
disabled={disabled}
password={password}
onUpdate={(patch) => updateProvider(selected.id, patch)}
onDelete={() => deleteProvider(selected.id)}
/>
) : selectedEnv ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={selectedEnv.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">
{selectedEnv.name}
</h3>
<p className="text-xs text-muted-foreground">
{dict.admin.envReadOnly}
</p>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<ul className="divide-y">
{selectedEnv.models.map((modelId, index) => (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{selectedEnv.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{dict.admin.selectProviderHint}
</p>
)}
</div>
</div>
)
}
+645
View File
@@ -0,0 +1,645 @@
"use client"
import {
AlertTriangle,
Check,
Loader2,
LockKeyhole,
ShieldCheck,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import {
SETTING_GROUPS,
SETTINGS_BY_GROUP,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
isSecretValue,
SESSION_PASSWORD_KEY,
type SettingState,
type SettingsMap,
savedTextOf,
} from "./admin-shared"
import { ModelsSection } from "./models-section"
import { SettingField } from "./setting-field"
// ── Page ─────────────────────────────────────────────────────────────
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
const groupText = (id: string) =>
(
dict.admin.groups as Record<
string,
{ title: string; description: string } | undefined
>
)[id]
const navItems = NAV_GROUP_IDS.map((id) => ({
id,
title:
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
}))
const [password, setPassword] = useState("")
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
const [authError, setAuthError] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [writable, setWritable] = useState(true)
// 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
// General settings state
const [settings, setSettings] = useState<SettingsMap>({})
const [pending, setPending] = useState<Record<string, string | null>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
{},
)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
ok: boolean
text: string
} | null>(null)
const [activeGroup, setActiveGroup] = useState("models")
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
const applySettingsResponse = useCallback(
(data: { writable: boolean; settings: SettingState[] }) => {
setWritable(data.writable)
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// 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 = groupsWithValues(map)
for (const id of Object.keys(next)) {
next[id] = next[id] || !!prev[id]
}
return next
})
},
[],
)
const applyProvidersResponse = useCallback(
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
envHasDefaultModel?: boolean
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
setEnvHasDefaultModel(!!data.envHasDefaultModel)
},
[],
)
const login = useCallback(
async (pw: string) => {
setAuthLoading(true)
setAuthError("")
try {
const [settingsData, providersData] = await Promise.all([
adminFetch("/api/admin/settings", pw),
adminFetch("/api/admin/providers", pw),
])
applySettingsResponse(settingsData)
applyProvidersResponse(providersData)
setAuthedPassword(pw)
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
} catch (err) {
setAuthError(
err instanceof Error ? err.message : dict.admin.loginFailed,
)
} finally {
setAuthLoading(false)
}
},
[applySettingsResponse, applyProvidersResponse, dict],
)
// Restore session on mount
useEffect(() => {
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
if (stored) void login(stored)
}, [login])
// Warn before leaving with unsaved changes
const hasDirty = dirtyCount > 0
useEffect(() => {
if (!hasDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
// Some browsers only show the prompt when returnValue is set
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [hasDirty])
// Highlight the section currently in view in the sidebar
useEffect(() => {
if (!authedPassword) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
)
if (visible[0]) setActiveGroup(visible[0].target.id)
},
{ rootMargin: "-10% 0px -50% 0px" },
)
for (const id of NAV_GROUP_IDS) {
const el = document.getElementById(id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [authedPassword])
// value undefined drops the pending change (back to the saved value)
const handleChange = useCallback(
(key: string, value: string | null | undefined) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
})
setPending((prev) => {
const state = settings[key]
const isRevert =
value !== null &&
state?.source === "file" &&
!isSecretValue(state?.value) &&
value === savedTextOf(state)
const isNoop =
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (value === undefined || isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
}
return { ...prev, [key]: value === "" ? null : value }
})
},
[settings],
)
// Toggling a group off stages deletion of its saved values so the
// feature actually turns off on save; toggling on drops those deletions.
const handleGroupToggle = useCallback(
(groupId: string, enabled: boolean) => {
setSaveMessage(null)
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
(d) => d.key,
)
setPending((prev) => {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// 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]
}
}
return next
})
},
[settings],
)
const handleSave = useCallback(async () => {
if (!authedPassword || dirtyCount === 0) return
setSaving(true)
setSaveMessage(null)
setErrors({})
try {
if (providersDirty) {
const data = await adminFetch(
"/api/admin/providers",
authedPassword,
{ method: "PUT", body: JSON.stringify({ providers }) },
)
applyProvidersResponse(data)
}
if (Object.keys(pending).length > 0) {
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-admin-password": authedPassword,
},
body: JSON.stringify({ values: pending }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
// Per-field validation errors come back as {errors: {...}}
if (data.errors) {
setErrors(data.errors)
const firstKey = Object.keys(data.errors)[0]
document.getElementById(`setting-${firstKey}`)?.focus()
throw new Error(dict.admin.invalidSettings)
}
throw new Error(
data.error || `Request failed (${res.status})`,
)
}
applySettingsResponse(data)
setPending({})
}
setSaveMessage({
ok: true,
text: dict.admin.saved,
})
setTimeout(() => setSaveMessage(null), 4000)
} catch (err) {
setSaveMessage({
ok: false,
text:
err instanceof Error ? err.message : dict.admin.saveFailed,
})
} finally {
setSaving(false)
}
}, [
authedPassword,
pending,
providers,
providersDirty,
dirtyCount,
applySettingsResponse,
applyProvidersResponse,
dict,
])
// ── Login screen ─────────────────────────────────────────────────
if (!authedPassword) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<form
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
onSubmit={(e) => {
e.preventDefault()
void login(password)
}}
>
<div className="flex items-center gap-2">
<LockKeyhole
className="h-5 w-5 text-muted-foreground"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-sm text-muted-foreground">
{dict.admin.loginPrompt}
</p>
<div className="space-y-1.5">
<Label htmlFor="admin-password">
{dict.admin.password}
</Label>
<Input
id="admin-password"
name="admin-password"
type="password"
value={password}
autoComplete="current-password"
spellCheck={false}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<p
className={cn(
"text-sm text-destructive",
!authError && "sr-only",
)}
aria-live="polite"
>
{authError}
</p>
<Button
type="submit"
className="w-full"
disabled={authLoading}
>
{authLoading ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.signingIn}
</>
) : (
dict.admin.signIn
)}
</Button>
</form>
</div>
)
}
// ── Settings screen ──────────────────────────────────────────────
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<ShieldCheck
className="h-5 w-5 text-primary"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-xs text-muted-foreground">
{dict.admin.precedence}
</p>
</div>
</header>
{!writable && (
<div className="border-b bg-amber-500/10">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
{dict.admin.notWritable}
</div>
</div>
)}
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
<nav
aria-label={dict.admin.settingGroups}
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
>
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={
activeGroup === item.id
? "true"
: undefined
}
className={cn(
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeGroup === item.id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground",
)}
>
{item.title}
</a>
</li>
))}
</ul>
</nav>
<main className="min-w-0 flex-1 pb-24">
{/* Models section */}
<section aria-labelledby="models" className="mb-10">
<h2
id="models"
className="scroll-mt-20 text-base font-semibold"
>
{dict.admin.models}
</h2>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{dict.admin.modelsDescription}
</p>
<div className="overflow-hidden rounded-lg border bg-card">
<ModelsSection
providers={providers}
envProviders={envProviders}
envHasDefaultModel={envHasDefaultModel}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
setSaveMessage(null)
setProviders(next)
}}
/>
</div>
</section>
{/* Registry-driven groups */}
{SETTING_GROUPS.map((group) => {
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
return (
<section
key={group.id}
aria-labelledby={group.id}
className="mb-10"
>
<div className="flex items-center justify-between gap-4">
<h2
id={group.id}
className="scroll-mt-20 text-base font-semibold"
>
{title}
</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]
? "border-primary/30 bg-primary/5 text-primary"
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
>
{enabledGroups[group.id]
? dict.admin.enabled
: dict.admin.disabled}
<Switch
checked={
!!enabledGroups[group.id]
}
disabled={
!writable ||
saving ||
envLocked
}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
)}
onCheckedChange={(checked) =>
handleGroupToggle(
group.id,
checked,
)
}
/>
</label>
)}
</div>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{gt?.description ?? group.description}
</p>
<div
className={cn(
"rounded-lg border bg-card px-4",
groupOff &&
"pointer-events-none opacity-50",
)}
>
{defs.map((def) => (
<SettingField
key={def.key}
def={def}
state={settings[def.key]}
pendingValue={pending[def.key]}
error={errors[def.key]}
disabled={fieldsDisabled}
onChange={(v) =>
handleChange(def.key, v)
}
/>
))}
</div>
</section>
)
})}
</main>
</div>
{/* Always-mounted live region so save results are announced */}
<p aria-live="polite" className="sr-only">
{saveMessage?.text ?? ""}
</p>
{(dirtyCount > 0 || saveMessage) && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<p
className={cn(
"flex min-w-0 items-center gap-1.5 truncate text-sm",
saveMessage?.ok
? "text-green-600 dark:text-green-400"
: saveMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{saveMessage?.ok && (
<Check
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
{saveMessage && !saveMessage.ok
? saveMessage.text
: dirtyCount > 0
? dict.admin.unsavedChanges
: saveMessage?.text}
</p>
{dirtyCount > 0 && (
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
setEnabledGroups(
groupsWithValues(settings),
)
}}
>
{dict.admin.discard}
</Button>
<Button
type="button"
disabled={saving || !writable}
onClick={() => void handleSave()}
>
{saving ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.saving}
</>
) : (
dict.admin.saveChanges
)}
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}
+317
View File
@@ -0,0 +1,317 @@
import { Eye, EyeOff, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
isSecretValue,
type SecretValue,
type SettingState,
savedTextOf,
} from "./admin-shared"
// ── Small shared UI bits ─────────────────────────────────────────────
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
const dict = useDictionary()
if (source === "default") return null
return (
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
source === "file"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
title={
source === "file"
? dict.admin.sourceSavedTitle
: dict.admin.sourceEnvTitle
}
>
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
</span>
)
}
export function RestartBadge() {
const dict = useDictionary()
return (
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
{dict.admin.restartRequired}
</span>
)
}
// Secret input: shows masked hint as placeholder, typing replaces.
// With keepOnEmpty, clearing the field reverts to the stored value
// ("keep") instead of deleting it — explicit deletion is via the X button.
export function SecretInput({
id,
value,
disabled,
keepOnEmpty,
onChange,
}: {
id: string
value: string | SecretValue | undefined
disabled?: boolean
keepOnEmpty?: boolean
onChange: (value: string | SecretValue) => void
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// 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)
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
: dict.admin.notSet
const handleText = (t: string) => {
if (t === "" && keepOnEmpty && hadStored && original) {
onChange(original)
} else {
onChange(t)
}
}
return (
<div className="flex items-center gap-1">
<Input
id={id}
type={show ? "text" : "password"}
value={text}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={placeholder}
className="h-9 font-mono text-xs"
onChange={(e) => handleText(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
onClick={() => setShow((s) => !s)}
>
{show ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{keepOnEmpty && (hadStored || text) && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={dict.admin.removeValue}
title={dict.admin.removeValueTitle}
onClick={() => onChange("")}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
)
}
// ── General settings field (registry-driven) ─────────────────────────
export function SettingField({
def,
state,
pendingValue,
error,
disabled,
onChange,
}: {
def: SettingDef
state: SettingState | undefined
pendingValue: string | null | undefined
error?: string
disabled: boolean
// undefined drops the pending change (back to the saved value)
onChange: (value: string | null | undefined) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
const secretState = state && isSecretValue(state.value) ? state.value : null
// Localized label/description keyed by env var name, falling back to the
// registry's English (the registry stays canonical for the server).
const t = (
dict.admin.settings as Record<
string,
{ label?: string; description?: string } | undefined
>
)[def.key]
const label = t?.label ?? def.label
const description = t?.description ?? def.description
const inputId = `setting-${def.key}`
const errorId = `${inputId}-error`
let control: React.ReactNode
switch (def.type) {
case "boolean": {
// When unset, reflect the built-in runtime default so the toggle
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
const effective =
currentValue !== "" ? currentValue : (def.default ?? "false")
// A saved boolean can be cleared back to its env/default value.
const canClear =
(isDirty && pendingValue !== null) || source === "file"
control = (
<div className="flex items-center gap-3">
<Switch
id={inputId}
checked={effective === "true"}
disabled={disabled}
onCheckedChange={(checked) =>
onChange(checked ? "true" : "false")
}
/>
{canClear && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => onChange(null)}
>
{dict.admin.resetToDefault}
</Button>
)}
</div>
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
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 ?? undefined)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : undefined)
}
/>
</div>
)
break
case "number":
control = (
<Input
id={inputId}
type="number"
inputMode="numeric"
min={def.min}
max={def.max}
value={currentValue}
disabled={disabled}
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-xs tabular-nums"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
break
default:
control = (
<Input
id={inputId}
type="text"
value={currentValue}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-md"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<div className="border-b border-border/60 py-4 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
<SourceChip source={source} />
{def.restartRequired && <RestartBadge />}
{isDirty && (
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
{dict.admin.modified}
</span>
)}
</div>
{description && (
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
{description}
</p>
)}
{control}
<p
id={errorId}
className={cn(
"text-xs text-destructive",
error ? "mt-1.5" : "sr-only",
)}
aria-live="polite"
>
{error ?? ""}
</p>
</div>
)
}
+15 -2
View File
@@ -41,19 +41,24 @@ export async function generateMetadata({
params: Promise<{ lang: string }>
}): Promise<Metadata> {
const { lang: rawLang } = await params
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale
const lang = (
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
// Default to English metadata
const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
}
const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
}
return {
@@ -80,7 +85,14 @@ export async function generateMetadata({
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US",
locale:
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
images: [
{
url: "/architecture.png",
@@ -115,6 +127,7 @@ export async function generateMetadata({
en: "/en",
zh: "/zh",
ja: "/ja",
"zh-Hant": "/zh-Hant",
},
},
}
+50 -31
View File
@@ -10,27 +10,33 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram()
const {
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
// Load preferences from localStorage after mount
useEffect(() => {
@@ -41,13 +47,15 @@ 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
}
}
const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") {
if (isDrawioTheme(savedUi)) {
setDrawioUi(savedUi)
}
@@ -64,6 +72,17 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark)
}
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
setIsLoaded(true)
}, [pathname, router])
@@ -81,35 +100,24 @@ export default function Home() {
resetDrawioReady()
}
const handleDrawioUiChange = () => {
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
const handleDrawioUiChange = (theme: DrawioTheme) => {
localStorage.setItem("drawio-theme", theme)
setDrawioUi(theme)
setIsDrawioReady(false)
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
@@ -160,8 +168,10 @@ export default function Home() {
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}`}
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef}
autosave
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
@@ -172,8 +182,17 @@ export default function Home() {
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark: darkMode,
lang: currentLang,
dark:
darkMode || drawioUi === "dark",
// 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,
}),
}}
/>
</div>
@@ -216,7 +235,7 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onToggleDrawioUi={handleDrawioUiChange}
onDrawioUiChange={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
+96
View File
@@ -0,0 +1,96 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import {
getEnvFallback,
isSettingsWritable,
saveSettings,
} from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
async function payload() {
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
// read-only in the panel; their credentials live in the environment
const envConfig = await loadEnvServerModelsConfig()
const adminProviders = loadAdminProviders()
// A panel default overrides any env default (matches the merge in
// loadRawServerModelsConfig), so env stars must reflect that
const adminHasDefault = adminProviders.some(
(p) => p.isDefault && p.models.length > 0,
)
return {
writable: isSettingsWritable(),
providers: maskAdminProviders(adminProviders),
envProviders:
envConfig?.providers.map((p) => ({
name: p.name,
provider: p.provider,
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"),
}
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json(await payload())
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProvidersSchema.safeParse(
(body as { providers?: unknown })?.providers,
)
if (!parsed.success) {
return Response.json(
{
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
},
{ status: 400 },
)
}
const stored = loadAdminProviders()
const merged = mergeSecrets(parsed.data, stored)
const envConfig = await loadEnvServerModelsConfig()
const validationError = validateAdminProviders(merged, envConfig)
if (validationError) {
return Response.json({ error: validationError }, { status: 400 })
}
saveSettings(deriveEnvUpdates(merged, stored))
return Response.json(await payload())
}
+126
View File
@@ -0,0 +1,126 @@
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
import {
SETTINGS_BY_KEY,
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
const source = getValueSource(def.key)
const raw =
source === "file"
? fileValues[def.key]
: (getEnvFallback(def.key) ?? null)
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
return { key: def.key, source, value }
})
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
writable: isSettingsWritable(),
settings: serializeSettings(),
})
}
function validateValue(def: SettingDef, value: string): string | null {
switch (def.type) {
case "number": {
const num = Number(value)
if (!Number.isFinite(num)) return "Must be a number"
if (def.min !== undefined && num < def.min)
return `Must be at least ${def.min}`
if (def.max !== undefined && num > def.max)
return `Must be at most ${def.max}`
return null
}
case "boolean":
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: { values?: Record<string, unknown> }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
if (!body.values || typeof body.values !== "object") {
return Response.json(
{ error: "Body must contain a values object" },
{ status: 400 },
)
}
const updates: Record<string, string | null> = {}
const errors: Record<string, string> = {}
for (const [key, value] of Object.entries(body.values)) {
const def = SETTINGS_BY_KEY.get(key)
if (!def) {
errors[key] = "Unknown setting"
continue
}
if (value === null || value === "") {
updates[key] = null
continue
}
if (typeof value !== "string") {
errors[key] = "Value must be a string"
continue
}
const error = validateValue(def, value)
if (error) {
errors[key] = error
continue
}
updates[key] = value
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 400 })
}
saveSettings(updates)
return Response.json({
writable: true,
settings: serializeSettings(),
})
}
+66
View File
@@ -0,0 +1,66 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
loadAdminProviders,
mergeSecrets,
} from "@/lib/admin/providers"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// Test a model with the client's CURRENT provider state (which may be
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
// masked {isSet} markers, which are resolved against settings.json — so
// testing works both before and after saving.
export async function POST(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
let body: { provider?: unknown; modelId?: string }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProviderSchema.safeParse(body.provider)
if (!parsed.success || !body.modelId) {
return Response.json(
{ valid: false, error: "Invalid provider or model" },
{ status: 400 },
)
}
// SECURITY: a stored secret is only resolved from an {isSet} marker if
// the endpoint it would be sent to (provider + baseUrl) still matches
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
const sameEndpoint =
stored &&
stored.provider === parsed.data.provider &&
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
const [resolved] = mergeSecrets(
[parsed.data],
sameEndpoint && stored ? [stored] : [],
)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl,
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
)
}
+309 -344
View File
@@ -4,41 +4,62 @@ 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,
supportsImageInput,
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 { findServerModelById } from "@/lib/server-model-config"
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
import {
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
} from "@/lib/output-token-limit"
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"
export const maxDuration = 120
// No explicit cap: a reasoning model can spend minutes planning before it emits
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
// which is also where Node's response-body timeout on the upstream stream lands.
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
@@ -70,26 +91,23 @@ 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 { messages, xml, previousXml, sessionId } = await req.json()
const body = await req.json()
const { messages, xml, previousXml, sessionId } = body
const customSystemMessage =
typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000)
: ""
// Get user ID for Langfuse tracking and quota
const userId = getUserIdFromRequest(req)
@@ -115,33 +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")
)
// 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) {
@@ -151,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]
@@ -178,17 +169,27 @@ 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")
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string
apiKeyEnv?: string | string[]
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}`,
)
@@ -207,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"),
@@ -217,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
@@ -232,8 +237,71 @@ async function handleChatRequest(req: Request): Promise<Response> {
)
// Get AI model with optional client overrides
const { model, providerOptions, headers, modelId } =
getAIModel(clientOverrides)
const {
model: baseModel,
providerOptions,
modelId,
provider: resolvedProvider,
} = getAIModel(clientOverrides)
// 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 },
)
}
// === 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}`)
// Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId)
@@ -243,22 +311,19 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
: systemMessage
// Extract file parts (images) from the last user message
const fileParts =
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Check if user is sending images to a model that doesn't support them
// AI SDK silently drops unsupported parts, so we need to catch this early
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
return Response.json(
{
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
},
{ status: 400 },
)
}
// Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models
// (see issue #874), so we now let the request through and surface the real
// provider error if the model genuinely can't accept images.
// User input only - XML is now in a separate cached system message
const formattedUserInput = `User input:
@@ -269,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
@@ -315,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) {
@@ -385,7 +434,7 @@ ${userInputText}
contentParts.push({
type: "image",
image: filePart.url,
mimeType: filePart.mediaType,
mediaType: filePart.mediaType,
})
}
@@ -405,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
}
@@ -415,40 +462,73 @@ ${userInputText}
}
// System messages with multiple cache breakpoints for optimal caching:
// - Breakpoint 1: Static instructions (~1500 tokens) - rarely changes
// - Breakpoint 1: System instructions + custom instructions - changes when user updates custom system message
// - Breakpoint 2: Current XML context - changes per diagram, but constant within a conversation turn
// This allows: if only user message changes, both system caches are reused
// if XML changes, instruction cache is still reused
const systemMessages = [
// Cache breakpoint 1: Instructions (rarely change)
{
role: "system" as const,
content: systemMessage,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
},
// Cache breakpoint 2: Previous and Current diagram XML context
{
role: "system" as const,
content: `${previousXml ? `Previous diagram XML (before user's last message):\n"""xml\n${previousXml}\n"""\n\n` : ""}Current diagram XML (AUTHORITATIVE - the source of truth):\n"""xml\n${xml || ""}\n"""\n\nIMPORTANT: 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!`,
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
},
]
// Some providers (e.g. MiniMax) don't support multiple system messages
// Merge them into a single system message for compatibility
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
const isCustomOpenAIEndpoint =
resolvedProvider === "openai" &&
!!(
baseUrl ||
process.env.OPENAI_BASE_URL ||
(serverModelConfig.baseUrlEnv &&
process.env[serverModelConfig.baseUrlEnv])
)
const isSingleSystemProvider =
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint
const xmlContext = `${
previousXml
? `Previous diagram XML (before user's last message):
"""xml
${previousXml}
"""
`
: ""
}Current diagram XML (AUTHORITATIVE - the source of truth):
"""xml
${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.`
const systemMessages = isSingleSystemProvider
? [
{
role: "system" as const,
content: `${finalSystemMessage}\n\n${xmlContext}`,
},
]
: [
// Cache breakpoint 1: Instructions (+ optional custom instructions)
{
role: "system" as const,
content: finalSystemMessage,
...(shouldCache && { providerOptions: CACHE_POINT }),
},
// Cache breakpoint 2: Previous and Current diagram XML context
{
role: "system" as const,
content: xmlContext,
...(shouldCache && { providerOptions: CACHE_POINT }),
},
]
const allMessages = [...systemMessages, ...enhancedMessages]
const result = streamText({
model,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
// 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.
maxOutputTokens,
stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => {
@@ -466,23 +546,11 @@ ${userInputText}
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}`,
)
@@ -492,26 +560,8 @@ ${userInputText}
`[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
}
}
@@ -520,7 +570,6 @@ ${userInputText}
},
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({
@@ -534,21 +583,20 @@ ${userInputText}
// 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: {
@@ -563,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**.
@@ -655,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
- 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({
@@ -673,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, 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
},
},
},
@@ -720,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
@@ -734,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,
}),
},
@@ -800,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(),
})
+116 -12
View File
@@ -1,12 +1,69 @@
import { extract } from "@extractus/article-extractor"
import { extractFromHtml } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
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)"
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
// would produce mojibake; the article-extractor library does the same detection
// when it fetches the page itself, which we no longer rely on.
function detectCharset(
contentType: string | null,
buffer: ArrayBuffer,
): string {
// 1. HTTP Content-Type header charset (most authoritative).
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
const metaCharset =
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
try {
new TextDecoder(charset)
return charset
} catch {
return "utf-8"
}
}
// 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()
@@ -27,28 +84,61 @@ export async function POST(req: Request) {
)
}
// SSRF protection
if (!allowPrivateUrls && isPrivateUrl(url)) {
// SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (await isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
// Extract article content with timeout to avoid tying up server resources
// Fetch the page ourselves so we control redirect handling. The
// article-extractor library follows redirects internally and ignores a
// `redirect` option, which would let a public URL 302 to an internal
// host and bypass the SSRF check above. `redirect: "error"` rejects any
// redirect outright.
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let article
let html: string
try {
article = await extract(url, undefined, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; NextAIDrawio/1.0)",
},
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT },
redirect: "error",
signal: controller.signal,
})
const contentType = response.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
error: "PDF URLs are not supported. Please download and upload the PDF file directly",
},
{ status: 422 },
)
}
if (!response.ok) {
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
}
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) {
if (err?.name === "AbortError") {
return NextResponse.json(
@@ -56,11 +146,25 @@ export async function POST(req: Request) {
{ status: 504 },
)
}
throw err
// Redirects are rejected with a TypeError ("failed to fetch" /
// "unexpected redirect") when redirect: "error" is set.
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
} finally {
clearTimeout(timeoutId)
}
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
// so map any parse error to the same 400 as the no-content case.
let article: Awaited<ReturnType<typeof extractFromHtml>>
try {
article = await extractFromHtml(html, url)
} catch {
article = null
}
if (!article || !article.content) {
return NextResponse.json(
{ error: "Could not extract content from URL" },
+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 })
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* API endpoint for VLM-based diagram validation.
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
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 {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
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
}
// Default valid result for disabled/error cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
/** A fixed result in the text format useObject reads */
function createStreamingResponse(result: ValidationResult): Response {
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"
if (!enableValidation) {
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
const { imageData, sessionId } = body
if (!imageData) {
return Response.json(
{ error: "Missing imageData" },
{ status: 400 },
)
}
// Validate image data format
if (
!imageData.startsWith("data:image/png;base64,") &&
!imageData.startsWith("data:image/")
) {
return Response.json(
{ error: "Invalid image data format" },
{ status: 400 },
)
}
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
return Response.json(
{ error: "Image data too large" },
{ status: 413 },
)
}
// Get the validation model
let model
try {
model = getValidationModel()
} catch (error) {
console.warn(
"[validate-diagram] Validation model not available:",
error,
)
// Return valid if no vision model is configured
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
const timeout =
Math.max(
1000,
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
// Stream the VLM response for useObject consumption
const result = streamText({
model,
output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: [
{
type: "image",
image: imageData,
},
{
type: "text",
text: "Please analyze this diagram for visual quality issues.",
},
],
},
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ output }) => {
if (sessionId && output) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
)
}
},
})
return result.toTextStreamResponse()
} catch (error) {
// Log with session context if available
const errorMessage =
error instanceof Error ? error.message : String(error)
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}
+91 -274
View File
@@ -1,20 +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 { 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 { 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 type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
interface ValidateRequest {
provider: string
provider: ProviderName
apiKey: string
baseUrl?: string
modelId: string
@@ -26,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 {
@@ -49,7 +60,7 @@ export async function POST(req: Request) {
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -83,281 +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 "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": {
const ollama = createOllama({
baseURL: baseUrl || "http://localhost:11434",
})
model = ollama(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
}
}
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 },
)
}
+8 -2
View File
@@ -1,12 +1,18 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
"ignoreUnknown": false,
"includes": [
"**",
"!public",
"!packages/mcp-server/src/preview",
"!lib/model-catalog.json"
]
},
"formatter": {
"enabled": true,
+58 -15
View File
@@ -1,5 +1,6 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ReactNode } from "react"
import type { ComponentProps, ElementRef, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import {
Command,
CommandDialog,
@@ -69,20 +70,62 @@ export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({
className,
...props
}: ModelSelectorListProps) => (
<div className="relative">
<CommandList
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
</div>
)
}: ModelSelectorListProps) => {
const listRef = useRef<ElementRef<typeof CommandList>>(null)
const [showShadow, setShowShadow] = useState(false)
useEffect(() => {
const listElement = listRef.current
if (!listElement) return
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listElement
// Show shadow if there is more content below
// Using a small threshold to handle fractional pixel rendering
setShowShadow(
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
)
}
// Initial check
checkScroll()
// Event listeners
listElement.addEventListener("scroll", checkScroll)
window.addEventListener("resize", checkScroll)
// Observe content changes (e.g. async loading of items)
const observer = new MutationObserver(checkScroll)
observer.observe(listElement, { childList: true, subtree: true })
return () => {
listElement.removeEventListener("scroll", checkScroll)
window.removeEventListener("resize", checkScroll)
observer.disconnect()
}
}, [])
return (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
}
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
-3
View File
@@ -141,9 +141,6 @@ export default function ExamplePanel({
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-purple-500 text-white rounded">
{dict.examples.preview}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
+457 -366
View File
@@ -1,17 +1,28 @@
"use client"
import {
BookmarkPlus,
Download,
History,
Image as ImageIcon,
Link,
Loader2,
Send,
Square,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
@@ -32,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 {
@@ -138,11 +160,16 @@ function showValidationErrors(errors: string[], dict: any) {
}
}
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onStop?: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
@@ -150,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
@@ -158,141 +185,230 @@ interface ChatInputProps {
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
showUnvalidatedModels?: boolean
onConfigureModels?: () => void
showUnvalidatedModels?: boolean
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
}
export function ChatInput({
input,
status,
onSubmit,
onChange,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
showUnvalidatedModels = false,
onConfigureModels = () => {},
shouldFocus = false,
onFocused,
}: ChatInputProps) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput(
{
input,
status,
onSubmit,
onChange,
onStop,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false,
shouldFocus = false,
onFocused,
},
ref,
) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
// Expose focus method via ref
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
},
}))
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// 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)
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
form.requestSubmit()
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit()
}
}
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
).filter((f): f is File => f !== null)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
imageFiles,
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
// Let validateFiles show a toast for unsupported types
const { validFiles, errors } = validateFiles(
Array.from(e.dataTransfer.files),
files.length,
dict,
)
@@ -301,278 +417,253 @@ export function ChatInput({
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
// 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
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles(
supportedFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
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)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
return (
<form
id="chat-form"
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
: undefined
}
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled || !isRealDiagram(chartXML)}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
<History className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={
isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={isDisabled || !input.trim()}
tooltipContent={dict.templates.saveAsTemplate}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<BookmarkPlus className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
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}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
<div className="w-px h-5 bg-border mx-1" />
{(status === "streaming" || status === "submitted") &&
onStop ? (
<Button
type="button"
onClick={onStop}
size="sm"
variant="destructive"
className="h-8 w-8 p-0 rounded-xl shadow-sm"
aria-label={dict.chat.stopGeneration}
>
<Square className="h-4 w-4" />
</Button>
) : (
<>
<Button
type="submit"
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
</Button>
)}
</Button>
</div>
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
)}
</form>
)
}
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()}
/>
</form>
)
},
)
+333 -265
View File
@@ -3,19 +3,20 @@
import type { UIMessage } from "ai"
import {
BookmarkPlus,
Check,
ChevronDown,
ChevronUp,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import type { MutableRefObject } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
@@ -25,19 +26,24 @@ 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"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import type { ValidationState } from "@/components/chat/ValidationCard"
import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
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(
@@ -57,20 +63,20 @@ function getCompleteOperations(
import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files)
// Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
interface TextSection {
type: "text" | "file"
type: "text" | "file" | "url"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text"
fileType?: "pdf" | "text" | "url"
}
function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
// Match [PDF: filename] or [File: filename] patterns
// Match [PDF: filename], [File: filename], or [URL: url] patterns
const filePattern =
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0
let match
@@ -81,28 +87,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
sections.push({ type: "text", content: beforeText })
}
// Add file section
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text"
// Add file/url section
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const filename = match[2].trim()
const fileContent = match[3].trim()
const content = match[3].trim()
sections.push({
type: "file",
content: fileContent,
type: sectionType === "url" ? "url" : "file",
content: content,
filename,
charCount: fileContent.length,
charCount: content.length,
fileType,
})
lastIndex = match.index + match[0].length
}
// Add remaining text after last file section
// Add remaining text after last section
const remainingText = text.slice(lastIndex).trim()
if (remainingText) {
sections.push({ type: "text", content: remainingText })
}
// If no file sections found, return original text
// If no file/url sections found, return original text
if (sections.length === 0) {
sections.push({ type: "text", content: text })
}
@@ -118,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: ...] and [File: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
}
interface SessionMetadata {
@@ -135,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>>
@@ -148,10 +164,17 @@ interface ChatMessageDisplayProps {
onSelectSession?: (id: string) => void
onDeleteSession?: (id: string) => void
loadedMessageIdsRef?: MutableRefObject<Set<string>>
validationStates?: Record<string, ValidationState>
onImproveWithSuggestions?: (feedback: string) => void
onSendTemplate?: (
template: import("@/lib/template-storage").Template,
) => void
currentInput?: string
}
export function ChatMessageDisplay({
messages,
onOpenModelConfig,
setInput,
setFiles,
processedToolCallsRef,
@@ -165,8 +188,29 @@ export function ChatMessageDisplay({
onSelectSession,
onDeleteSession,
loadedMessageIdsRef,
validationStates = {},
onImproveWithSuggestions,
onSendTemplate,
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)
@@ -187,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>>(
{},
)
@@ -224,6 +254,10 @@ export function ChatMessageDisplay({
const [expandedPdfSections, setExpandedPdfSections] = useState<
Record<string, boolean>
>({})
// Track "Save as Template" dialog
const [saveAsTemplateMessageId, setSaveAsTemplateMessageId] = useState<
string | null
>(null)
const setCopyState = (
messageId: string,
@@ -318,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],
@@ -392,6 +383,7 @@ export function ChatMessageDisplay({
// Track previous message count to detect bulk loads vs streaming
const prevMessageCountRef = useRef(0)
const scrollThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (messagesEndRef.current && messages.length > 0) {
@@ -405,8 +397,17 @@ export function ChatMessageDisplay({
return
}
// Single message added - smooth scroll
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
// Throttle scroll during streaming to avoid layout thrashing
// Leading + trailing: scroll immediately, then once more after cooldown
if (!scrollThrottleRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
scrollThrottleRef.current = setTimeout(() => {
scrollThrottleRef.current = null
messagesEndRef.current?.scrollIntoView({
behavior: "smooth",
})
}, 150)
}
}
}, [messages])
@@ -421,21 +422,36 @@ 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-")) {
const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart
// Auto-collapse on completion, but only if user hasn't manually toggled
if (state === "output-available") {
setExpandedTools((prev) => ({
...prev,
[toolCallId]: false,
}))
setExpandedTools((prev) => {
// Only auto-collapse if not already set (user hasn't interacted)
if (prev[toolCallId] === undefined) {
return { ...prev, [toolCallId]: false }
}
return prev
})
}
if (isRestoredMessage) return
if (
part.type === "tool-display_diagram" &&
input?.xml
@@ -449,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)
@@ -514,7 +529,7 @@ export function ChatMessageDisplay({
toolCallId,
)
) {
if (!chartXML) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
@@ -522,10 +537,9 @@ export function ChatMessageDisplay({
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
chartXML,
baseXml,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
@@ -533,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 (
@@ -633,6 +589,8 @@ export function ChatMessageDisplay({
onDeleteSession={onDeleteSession}
setInput={setInput}
setFiles={setFiles}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
dict={dict}
/>
) : messages.length === 0 ? null : (
@@ -730,8 +688,42 @@ export function ChatMessageDisplay({
<Copy className="h-3.5 w-3.5" />
)}
</button>
{/* Save as Template button - only for user messages */}
<button
type="button"
onClick={() =>
setSaveAsTemplateMessageId(
message.id,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={
dict.templates
?.saveAsTemplate ||
"Save as Template"
}
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Save as Template Dialog */}
{saveAsTemplateMessageId === message.id && (
<TemplateCreateDialog
open={true}
onOpenChange={(open) => {
if (!open)
setSaveAsTemplateMessageId(null)
}}
onSuccess={() => {
setSaveAsTemplateMessageId(null)
}}
initialPrompt={getUserOriginalText(
message,
)}
/>
)}
<div className="max-w-[85%] min-w-0">
{/* Reasoning blocks - displayed first for assistant messages */}
{message.role === "assistant" &&
@@ -770,7 +762,11 @@ export function ChatMessageDisplay({
!isRestoredMessage
}
>
<ReasoningTrigger />
<ReasoningTrigger
getThinkingMessage={
thinkingMessage
}
/>
<ReasoningContent>
{
reasoningPart.text
@@ -876,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) {
@@ -911,30 +911,56 @@ export function ChatMessageDisplay({
return groups.map(
(group, groupIndex) => {
if (group.type === "tool") {
const toolPart = group
.parts[0] as ToolPartLike
const toolCallId =
toolPart.toolCallId
const isDisplayDiagram =
toolPart.type ===
"tool-display_diagram"
const validationState =
validationStates[
toolCallId
]
return (
<ToolCallCard
<div
key={`${message.id}-tool-${group.startIndex}`}
part={
group
.parts[0] as ToolPartLike
}
expandedTools={
expandedTools
}
setExpandedTools={
setExpandedTools
}
onCopy={
copyMessageToClipboard
}
copiedToolCallId={
copiedToolCallId
}
copyFailedToolCallId={
copyFailedToolCallId
}
dict={dict}
/>
>
<ToolCallCard
part={
toolPart
}
expandedTools={
expandedTools
}
setExpandedTools={
setExpandedTools
}
onCopy={
copyMessageToClipboard
}
copiedToolCallId={
copiedToolCallId
}
copyFailedToolCallId={
copyFailedToolCallId
}
dict={dict}
/>
{/* Show validation card for display_diagram tools */}
{isDisplayDiagram &&
validationState && (
<ValidationCard
state={
validationState
}
onImproveWithSuggestions={
onImproveWithSuggestions
}
/>
)}
</div>
)
}
@@ -1048,12 +1074,14 @@ export function ChatMessageDisplay({
) => {
if (
section.type ===
"file"
"file" ||
section.type ===
"url"
) {
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}`
const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
const isExpanded =
expandedPdfSections[
pdfKey
sectionKey
] ??
false
const charDisplay =
@@ -1062,10 +1090,27 @@ export function ChatMessageDisplay({
1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
// Icon selector
const Icon =
section.fileType ===
"pdf"
? FileText
: section.fileType ===
"url"
? Link
: FileCode
const iconColor =
section.fileType ===
"pdf"
? "text-red-500"
: "text-blue-700"
return (
<div
key={
pdfKey
sectionKey
}
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
>
@@ -1080,7 +1125,7 @@ export function ChatMessageDisplay({
prev,
) => ({
...prev,
[pdfKey]:
[sectionKey]:
!isExpanded,
}),
)
@@ -1088,13 +1133,10 @@ export function ChatMessageDisplay({
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{section.fileType ===
"pdf" ? (
<FileText className="h-4 w-4 text-red-500" />
) : (
<FileCode className="h-4 w-4 text-blue-500" />
)}
<span className="text-xs font-medium">
<Icon
className={`h-4 w-4 ${iconColor}`}
/>
<span className="text-xs font-medium truncate max-w-[200px]">
{
section.filename
}
@@ -1185,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>
)
},
+409 -232
View File
@@ -1,15 +1,14 @@
"use client"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import { DefaultChatTransport, isToolUIPart, type UIMessage } from "ai"
import {
MessageSquarePlus,
PanelRightClose,
PanelRightOpen,
Settings,
} from "lucide-react"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type React from "react"
import {
useCallback,
@@ -22,6 +21,7 @@ import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import Image from "@/components/image-with-basepath"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
@@ -29,16 +29,25 @@ import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { useSessionManager } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
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 { ChatMessageDisplay } from "./chat-message-display"
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 {
APPENDED_FILE_SECTIONS_PATTERN,
ChatMessageDisplay,
} from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence
@@ -47,26 +56,11 @@ 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
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean
onToggleDarkMode: () => void
isMobile?: boolean
@@ -75,7 +69,8 @@ interface ChatPanelProps {
// Constants for tool states
const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1
// Increased to 3 to support VLM validation retries (matches MAX_VALIDATION_RETRIES)
const MAX_AUTO_RETRY_COUNT = 3
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
@@ -83,30 +78,30 @@ 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,
drawioUi,
onToggleDrawioUi,
onDrawioUiChange,
darkMode,
onToggleDarkMode,
isMobile = false,
@@ -120,38 +115,36 @@ export default function ChatPanel({
latestSvg,
clearDiagram,
getThumbnailSvg,
captureValidationPng,
diagramHistory,
setDiagramHistory,
} = useDiagram()
const dict = useDictionary()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve
}
resolverRef.current = resolve
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
}),
new Promise<string>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Chart export timed out after 10 seconds",
),
),
10000,
),
),
new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => {
if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
])
}
@@ -173,6 +166,9 @@ export default function ChatPanel({
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0)
const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [customSystemMessage, setCustomSystemMessage] = useState("")
const [maxOutputTokens, setMaxOutputTokens] = useState("")
const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -183,6 +179,30 @@ export default function ChatPanel({
}
}, [])
// Load VLM validation setting from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.vlmValidationEnabled)
if (stored !== null) {
setVlmValidationEnabled(stored === "true")
}
}, [])
// Load custom system message from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.customSystemMessage)
if (stored !== null) {
setCustomSystemMessage(stored)
}
}, [])
// Load output token budget from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.maxOutputTokens)
if (stored !== null) {
setMaxOutputTokens(stored)
}
}, [])
// Check config on mount
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
@@ -270,161 +290,205 @@ export default function ChatPanel({
> | null>(null)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
// Validation state for displaying VLM validation progress
// Key: toolCallId, Value: ValidationState
const [validationStates, setValidationStates] = useState<
Record<string, ValidationState>
>({})
// Callback to update validation state from tool handler
const handleValidationStateChange = useCallback(
(toolCallId: string, state: ValidationState) => {
setValidationStates((prev) => ({
...prev,
[toolCallId]: state,
}))
},
[],
)
// Handler for VLM validation setting change
const handleVlmValidationChange = useCallback((value: boolean) => {
setVlmValidationEnabled(value)
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(value))
}, [])
// Handler for custom system message change
const handleCustomSystemMessageChange = useCallback((value: string) => {
setCustomSystemMessage(value)
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
}, [])
// Handler for output token budget change (empty string = use server default)
const handleMaxOutputTokensChange = useCallback((value: string) => {
const digitsOnly = value.replace(/\D/g, "")
setMaxOutputTokens(digitsOnly)
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
}, [])
// 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()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
captureValidationPng,
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
onValidationStateChange: handleValidationStateChange,
})
const { messages, sendMessage, addToolOutput, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
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
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
}
} catch {
// Not JSON, fall through to string matching for backwards compatibility
}
const {
messages,
sendMessage,
addToolOutput,
status,
error,
setMessages,
stop,
} = useChat({
transport: new DefaultChatTransport({
api: getApiEndpoint("/api/chat"),
}),
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// 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()
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
// 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 {
data = JSON.parse(error.message)
} catch {
// Plain text, e.g. a network failure in the browser
}
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
}
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 (!isAccessCodeError) console.error("Chat error:", error)
// 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) => [
...currentMessages,
{
id: `error-${Date.now()}`,
role: "system" as const,
content: text,
parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings
...(openModelConfig && {
metadata: { openModelConfig: true },
}),
},
])
if (isAccessCodeError) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
// 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)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
quotaManager.showTPMLimitToast()
return
}
// 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)
}
// 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."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [
{ type: "text" as const, text: friendlyMessage },
],
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: () => {},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
autoRetryCountRef.current = 0
toast.error(
formatMessage(dict.errors.continuationRetryLimit, {
max: MAX_CONTINUATION_RETRY_COUNT,
}),
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Continuation mode: limited retries for truncation handling
if (isInContinuationMode) {
if (
continuationRetryCountRef.current >=
MAX_CONTINUATION_RETRY_COUNT
) {
toast.error(
formatMessage(dict.errors.continuationRetryLimit, {
max: MAX_CONTINUATION_RETRY_COUNT,
}),
)
continuationRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
formatMessage(dict.errors.retryLimit, {
max: MAX_AUTO_RETRY_COUNT,
}),
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
continuationRetryCountRef.current++
} else {
// Regular error: check retry count limit
if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) {
toast.error(
formatMessage(dict.errors.retryLimit, {
max: MAX_AUTO_RETRY_COUNT,
}),
)
autoRetryCountRef.current = 0
partialXmlRef.current = ""
return false
}
// Increment retry count for actual errors
autoRetryCountRef.current++
}
return true
},
})
return true
},
})
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
@@ -434,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)
@@ -500,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,
@@ -521,7 +590,7 @@ export default function ChatPanel({
try {
const currentSession = sessionManager.currentSession
if (currentSession && currentSession.messages.length > 0) {
if (currentSession) {
// Restore from session manager (IndexedDB)
justLoadedSessionRef.current = true
syncUIWithSession(currentSession)
@@ -554,11 +623,16 @@ 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 && newSession.messages.length > 0) {
if (newSession) {
justLoadedSessionRef.current = true
syncUIWithSession(newSession)
} else if (!newSession) {
@@ -613,7 +687,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
try {
if (messages.length > 0) {
if (messages.length > 0 || hasDiagramNow) {
const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -635,6 +709,7 @@ export default function ChatPanel({
}
}
}, [
chartXML,
messages,
status,
sessionIsAvailable,
@@ -665,7 +740,8 @@ export default function ChatPanel({
const handleVisibilityChange = async () => {
if (
document.visibilityState === "hidden" &&
messagesRef.current.length > 0
(messagesRef.current.length > 0 ||
isRealDiagram(chartXMLRef.current))
) {
try {
// Attempt to save session - browser may not wait for completion
@@ -694,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
@@ -735,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([])
@@ -744,13 +839,6 @@ export default function ChatPanel({
}
try {
let chartXml = await onFetchChart()
chartXml = formatXML(chartXml)
// Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml
// Build user text by concatenating input with pre-extracted text
// (Backend only reads first text part, so we must combine them)
const parts: any[] = []
@@ -765,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) => {
@@ -820,6 +921,7 @@ export default function ChatPanel({
} else {
justLoadedSessionIdRef.current = null
}
setValidationStates({}) // Clear validation states when switching sessions
syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false })
}
@@ -836,10 +938,10 @@ export default function ChatPanel({
if (result.wasCurrentSession) {
// Deleted current session - clear UI and URL
syncUIWithSession(null)
router.replace(window.location.pathname, { scroll: false })
router.replace(pathname, { scroll: false })
}
},
[sessionManager, syncUIWithSession, router],
[sessionManager, syncUIWithSession, router, pathname],
)
const handleNewChat = useCallback(async () => {
@@ -860,6 +962,7 @@ export default function ChatPanel({
setInput("")
clearDiagram()
setDiagramHistory([])
setValidationStates({}) // Clear validation states to prevent memory leak
handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random()
@@ -871,7 +974,7 @@ export default function ChatPanel({
toast.success(dict.dialogs.clearSuccess)
// Clear URL param to show blank state
router.replace(window.location.pathname, { scroll: false })
router.replace(pathname, { scroll: false })
// After starting a fresh chat, move focus back to the chat input
setShouldFocusInput(true)
@@ -886,8 +989,27 @@ export default function ChatPanel({
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
pathname,
])
// 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)
})
const formElement = document.getElementById(
"chat-form",
) as HTMLFormElement | null
if (formElement) {
formElement.requestSubmit()
}
},
[setInput],
)
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
@@ -900,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]) || "",
)
: ""
}
@@ -925,6 +1049,29 @@ export default function ChatPanel({
}
}
// Handle stop button click
const handleStop = useCallback(() => {
const lastMessage = messages[messages.length - 1]
const toolParts = lastMessage?.parts?.filter(
(part: any) =>
part.type?.startsWith("tool-") &&
part.state === "input-streaming",
)
toolParts?.forEach((part: any) => {
if (part.toolCallId) {
addToolOutput({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
})
stop()
}, [messages, addToolOutput, stop])
// Send chat message with headers
const sendChatMessage = (
parts: any,
@@ -935,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()
@@ -942,7 +1090,7 @@ export default function ChatPanel({
sendMessage(
{ parts },
{
body: { xml, previousXml, sessionId },
body: { xml, previousXml, sessionId, customSystemMessage },
headers: {
"x-access-code": config.accessCode,
...(config.aiProvider && {
@@ -980,6 +1128,9 @@ export default function ChatPanel({
...(minimalStyle && {
"x-minimal-style": "true",
}),
...(maxOutputTokens && {
"x-max-output-tokens": maxOutputTokens,
}),
},
},
)
@@ -1080,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) => {
@@ -1107,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 }]
@@ -1123,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)
@@ -1252,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}
@@ -1266,6 +1431,10 @@ export default function ChatPanel({
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef}
validationStates={validationStates}
onImproveWithSuggestions={handleImproveWithSuggestions}
onSendTemplate={handleSendTemplate}
currentInput={input}
/>
</main>
@@ -1289,6 +1458,7 @@ export default function ChatPanel({
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onStop={handleStop}
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
@@ -1299,8 +1469,8 @@ export default function ChatPanel({
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onModelSelect={modelConfig.setSelectedModelId}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
onConfigureModels={() => setShowModelConfigDialog(true)}
showUnvalidatedModels={modelConfig.showUnvalidatedModels}
shouldFocus={shouldFocusInput}
onFocused={() => setShouldFocusInput(false)}
/>
@@ -1310,11 +1480,18 @@ export default function ChatPanel({
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
drawioUi={drawioUi}
onToggleDrawioUi={onToggleDrawioUi}
onDrawioUiChange={onDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
vlmValidationEnabled={vlmValidationEnabled}
onVlmValidationChange={handleVlmValidationChange}
customSystemMessage={customSystemMessage}
onCustomSystemMessageChange={handleCustomSystemMessageChange}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={handleMaxOutputTokensChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)}
/>
<ModelConfigDialog
+222 -136
View File
@@ -8,9 +8,10 @@ import {
Trash2,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import { useEffect, useState } from "react"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath"
import {
AlertDialog,
AlertDialogAction,
@@ -21,6 +22,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { STORAGE_KEYS } from "@/lib/storage"
import type { Template } from "@/lib/template-storage"
interface SessionMetadata {
id: string
@@ -35,6 +38,8 @@ interface ChatLobbyProps {
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
dict: {
sessionHistory?: {
recentChats?: string
@@ -44,6 +49,10 @@ interface ChatLobbyProps {
deleteTitle?: string
deleteDescription?: string
}
templates?: {
title?: string
myTemplates?: string
}
examples?: {
quickExamples?: string
}
@@ -75,164 +84,241 @@ function formatSessionDate(
})
}
function getPanelVisibility() {
if (typeof window === "undefined")
return { recentChats: true, myTemplates: true, quickExamples: true }
return {
recentChats:
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
myTemplates:
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
quickExamples:
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
}
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
onSendTemplate,
currentInput = "",
dict,
}: ChatLobbyProps) {
// Track whether examples section is expanded (collapsed by default when there's history)
const [examplesExpanded, setExamplesExpanded] = useState(false)
// Delete confirmation dialog state
const [templatesExpanded, setTemplatesExpanded] = useState(true)
const [examplesExpanded, setExamplesExpanded] = useState(true)
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
// Search filter for history
const [searchQuery, setSearchQuery] = useState("")
// Listen for panel visibility changes from settings
useEffect(() => {
const handler = () => setPanelVisibility(getPanelVisibility())
window.addEventListener("panelVisibilityChange", handler)
return () =>
window.removeEventListener("panelVisibilityChange", handler)
}, [])
const hasHistory = sessions.length > 0
if (!hasHistory) {
// Show full examples when no history
return <ExamplePanel setInput={setInput} setFiles={setFiles} />
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
return null
}
return (
<div className="animate-fade-in">
{panelVisibility.myTemplates && (
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
)}
{panelVisibility.quickExamples && (
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
<ExamplePanel setInput={setInput} setFiles={setFiles} />
</div>
)}
</div>
)
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
{panelVisibility.recentChats && (
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
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 === " "
) {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
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) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
</div>
{/* Collapsible Examples Section */}
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
)}
</div>
</div>
)}
{/* Collapsible My Templates Section */}
{panelVisibility.myTemplates && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setTemplatesExpanded(!templatesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.templates?.myTemplates || "My Templates"}
</span>
{templatesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{templatesExpanded && (
<div className="mt-2">
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
</div>
)}
</div>
)}
{/* Collapsible Quick Examples Section */}
{panelVisibility.quickExamples && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
</div>
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
+206
View File
@@ -0,0 +1,206 @@
"use client"
import { Bookmark, Plus } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import {
createTemplate,
type TemplateCreateInput,
} from "@/lib/template-storage"
interface TemplateCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
initialPrompt?: string
}
export function TemplateCreateDialog({
open,
onOpenChange,
onSuccess,
initialPrompt = "",
}: TemplateCreateDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when dialog opens with the latest initialPrompt
useEffect(() => {
if (open) {
setTitle("")
setDescription("")
setPrompt(initialPrompt)
setPinned(false)
setError(null)
}
}, [open, initialPrompt])
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) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const input: TemplateCreateInput = {
prompt: trimmedPrompt,
title: title.trim() || undefined,
description: description.trim() || undefined,
pinned,
}
const template = await createTemplate(input)
if (template) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.createFailed)
}
} catch (err) {
console.error("Failed to create template:", err)
setError(dict.templates.createFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
{dict.templates.createTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.createDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label htmlFor="prompt" className="text-foreground">
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label htmlFor="title" className="text-foreground">
{dict.templates.titleLabel}
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.templates.createButton}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+218
View File
@@ -0,0 +1,218 @@
"use client"
import { Bookmark, Edit2 } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { type Template, updateTemplate } from "@/lib/template-storage"
interface TemplateEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void
}
export function TemplateEditDialog({
open,
onOpenChange,
template,
onSuccess,
}: TemplateEditDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Populate form each time the dialog opens, dropping any cancelled edits
useEffect(() => {
if (open && template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
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
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined,
pinned,
}
const updated = await updateTemplate(template.id, updates)
if (updated) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.updateFailed)
}
} catch (err) {
console.error("Failed to update template:", err)
setError(dict.templates.updateFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Edit2 className="w-5 h-5" />
{dict.templates.editTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.editDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label
htmlFor="edit-prompt"
className="text-foreground"
>
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="edit-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label
htmlFor="edit-title"
className="text-foreground"
>
{dict.templates.titleLabel}
</Label>
<Input
id="edit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="edit-description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="edit-pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="edit-pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.common.save}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
+647
View File
@@ -0,0 +1,647 @@
"use client"
import {
Bookmark,
Copy,
Download,
Edit2,
FileText,
Plus,
Search,
Trash2,
Upload,
} from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary"
import {
deleteTemplate,
duplicateTemplate,
exportTemplates,
getAllTemplates,
importTemplates,
incrementClickCount,
incrementRunCount,
searchTemplates,
type Template,
updateTemplate,
validateImportData,
} from "@/lib/template-storage"
import { TemplateCreateDialog } from "./TemplateCreateDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps {
setInput: (input: string) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
}
function formatLastUsed(timestamp: number, neverUsedText: string): string {
if (!timestamp) return neverUsedText
const now = Date.now()
const diffMs = now - timestamp
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
try {
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
if (diffMins < 1) return rtf.format(0, "minute")
if (diffMins < 60) return rtf.format(-diffMins, "minute")
if (diffHours < 24) return rtf.format(-diffHours, "hour")
if (diffDays < 7) return rtf.format(-diffDays, "day")
} catch {
// Fallback if Intl.RelativeTimeFormat is not available
if (diffMins < 1) return "<1m ago"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
}
return new Date(timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function TemplatePanel({
setInput,
onSendTemplate,
currentInput = "",
}: TemplatePanelProps) {
const dict = useDictionary()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [templateToEdit, setTemplateToEdit] = useState<Template | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(
null,
)
const [confirmSendDialogOpen, setConfirmSendDialogOpen] = useState(false)
const [templateToSend, setTemplateToSend] = useState<Template | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const fileInputRef = useRef<HTMLInputElement>(null)
const [importMessage, setImportMessage] = useState<{
type: "success" | "error"
text: string
} | null>(null)
const loadTemplates = useCallback(async () => {
const result = await getAllTemplates()
setTemplates(result)
setLoading(false)
}, [])
// Filter templates by search query
const filteredTemplates = searchQuery.trim()
? searchTemplates(templates, searchQuery)
: templates
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 = () => {
loadTemplates()
}
const handleEditSuccess = () => {
loadTemplates()
}
const handleEdit = (template: Template) => {
setTemplateToEdit(template)
setEditDialogOpen(true)
}
const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate(
template.id,
dict.templates.copySuffix || "(copy)",
)
if (duplicated) {
loadTemplates()
}
}
const handleDeleteClick = (template: Template) => {
setTemplateToDelete(template)
setDeleteDialogOpen(true)
}
const handleDeleteConfirm = async () => {
if (!templateToDelete) return
const success = await deleteTemplate(templateToDelete.id)
if (success) {
loadTemplates()
}
setDeleteDialogOpen(false)
setTemplateToDelete(null)
}
const handleTogglePin = async (template: Template) => {
const updated = await updateTemplate(template.id, {
pinned: !template.pinned,
})
if (updated) {
loadTemplates()
}
}
// Handle template card click - send directly or show confirmation
const handleTemplateClick = async (template: Template) => {
// If there's unsent content in the input, show confirmation dialog
if (currentInput.trim()) {
setTemplateToSend(template)
setConfirmSendDialogOpen(true)
return
}
// No unsent content, send directly
await sendTemplate(template)
}
// Actually send the template
const sendTemplate = async (template: Template) => {
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (onSendTemplate) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
// Call the send callback
onSendTemplate(template)
} else {
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
}
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Handle confirmation dialog - user confirmed to send template
const handleConfirmSend = async () => {
if (!templateToSend) return
await sendTemplate(templateToSend)
}
// Handle cancel - close dialog without sending
const handleCancelSend = () => {
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
}
// Export templates to JSON file
const handleExport = () => {
if (templates.length === 0) {
setImportMessage({
type: "error",
text: dict.templates.exportEmpty || "No templates to export",
})
return
}
try {
const exportData = exportTemplates(templates)
const json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: "application/json" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
// 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)
URL.revokeObjectURL(url)
setImportMessage({
type: "success",
text: dict.templates.exportSuccess.replace(
"{count}",
String(templates.length),
),
})
setTimeout(() => setImportMessage(null), 3000)
} catch (error) {
console.error("Failed to export templates:", error)
setImportMessage({
type: "error",
text: `Export failed: ${error instanceof Error ? error.message : "Unknown error"}`,
})
}
}
// Import templates from JSON file
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
setImportMessage({
type: "error",
text:
dict.templates.importNoFile || "Please select a JSON file",
})
return
}
try {
const text = await file.text()
const data = JSON.parse(text)
// Validate import data
const validation = validateImportData(data)
if (!validation.valid) {
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
validation.error || "Invalid data",
),
})
return
}
// Import templates with dedup-append strategy
const result = await importTemplates(data.templates, templates)
// Reload template list
await loadTemplates()
setImportMessage({
type: "success",
text: dict.templates.importSuccess
.replace("{imported}", String(result.imported))
.replace("{skipped}", String(result.skipped)),
})
setTimeout(() => setImportMessage(null), 5000)
} catch (error) {
console.error("Failed to import templates:", error)
setImportMessage({
type: "error",
text: dict.templates.importFailed.replace(
"{error}",
error instanceof Error ? error.message : "Unknown error",
),
})
} finally {
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
// 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 (
<div className="py-6 px-2 animate-fade-in">
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
<div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" />
</div>
<p className="text-sm font-medium text-foreground mb-1">
{dict.templates.emptyTitle}
</p>
<p className="text-xs text-muted-foreground text-center max-w-[240px] mb-4">
{dict.templates.emptyDescription}
</p>
<button
type="button"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
onClick={() => setCreateDialogOpen(true)}
>
<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}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
</div>
</div>
)
}
// Template list
return (
<div className="py-2 px-2 animate-fade-in">
<div className="space-y-3">
{/* Search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={dict.templates.searchPlaceholder}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCreateDialogOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-primary hover:bg-primary/10 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{dict.templates.createButton}
</button>
<div className="flex-1" />
<button
type="button"
onClick={handleExport}
disabled={templates.length === 0}
className="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 disabled:opacity-50 disabled:cursor-not-allowed"
title={dict.templates.exportTemplates}
>
<Download className="w-3.5 h-3.5" />
{dict.templates.exportTemplates}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="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"
title={dict.templates.importTemplates}
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
</div>
{/* Import message */}
{importMessageBox}
<div className="space-y-2">
{loading
? // Loading skeleton
Array.from({ length: 3 }).map((_, i) => (
<div
key={`skeleton-${String(i)}`}
className="w-full p-4 rounded-xl border border-border/60 bg-card animate-pulse"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-muted shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
</div>
))
: filteredTemplates.length === 0
? // Search empty state
!loading && (
<div className="flex flex-col items-center justify-center py-6 px-4">
<Search className="w-8 h-8 text-muted-foreground/40 mb-2" />
<p className="text-sm text-muted-foreground text-center">
{dict.templates.searchNoResults}
</p>
</div>
)
: filteredTemplates.map((template) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested action buttons which causes hydration error
<div
key={template.id}
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={() =>
handleTemplateClick(template)
}
onKeyDown={(e) => {
// Ignore keys bubbling up from the action buttons
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
handleTemplateClick(template)
}
}}
role="button"
tabIndex={0}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">
{template.title}
</div>
{template.pinned && (
<Bookmark className="w-3 h-3 text-primary fill-primary shrink-0" />
)}
</div>
{template.description && (
<div className="text-xs text-muted-foreground truncate">
{template.description}
</div>
)}
</div>
{/* Actions and stats */}
<div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
: dict.templates.neverUsed}
</div>
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleTogglePin(template)
}}
className={`p-1.5 rounded-lg transition-all ${
template.pinned
? "text-primary hover:text-primary/80 hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
title={
template.pinned
? dict.templates
.unpin || "Unpin"
: dict.templates.pin ||
"Pin"
}
>
<Bookmark
className={`w-4 h-4 ${template.pinned ? "fill-current" : ""}`}
/>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleEdit(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={dict.common.edit}
>
<Edit2 className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDuplicate(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={
dict.templates.duplicate ||
"Duplicate"
}
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
<TemplateEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
template={templateToEdit}
onSuccess={handleEditSuccess}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.deleteTitle ||
"Delete this template?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.deleteDescription ||
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirm Send Dialog - when there's unsent input */}
<AlertDialog
open={confirmSendDialogOpen}
onOpenChange={setConfirmSendDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.confirmSendTitle ||
"Replace current input?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.confirmSendDescription ||
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelSend}>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton ||
"Send Template"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+46 -37
View File
@@ -66,9 +66,9 @@ export function ToolCallCard({
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
// Default to collapsed if tool is complete, expanded if still streaming
const isExpanded = expandedTools[callId] ?? state !== "output-available"
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-", "")
const isCopied = copiedToolCallId === callId
@@ -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"
@@ -195,7 +196,22 @@ export function ToolCallCard({
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
@@ -209,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" &&
+328
View File
@@ -0,0 +1,328 @@
"use client"
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
Eye,
ImageIcon,
RefreshCw,
X,
} from "lucide-react"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus =
| "idle"
| "capturing"
| "validating"
| "success"
| "success_with_warnings"
| "failed"
| "error"
| "skipped"
export interface ValidationState {
status: ValidationStatus
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string // Base64 PNG data URL
}
interface ValidationCardProps {
state: ValidationState
onImproveWithSuggestions?: (feedback: string) => void
}
export function ValidationCard({
state,
onImproveWithSuggestions,
}: ValidationCardProps) {
const dict = useDictionary()
const [isExpanded, setIsExpanded] = useState(
state.status === "validating" || state.status === "failed",
)
const [hasRequestedImprovement, setHasRequestedImprovement] =
useState(false)
// Generate improvement feedback from validation result
const generateImprovementFeedback = (): string => {
if (!state.result) return ""
const lines: string[] = []
lines.push(
"Please improve the diagram based on the following visual analysis feedback:",
)
lines.push("")
if (state.result.issues.length > 0) {
lines.push("Issues to address:")
for (const issue of state.result.issues) {
lines.push(
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
)
}
lines.push("")
}
if (state.result.suggestions.length > 0) {
lines.push("Suggestions for improvement:")
for (const suggestion of state.result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push("Regenerate the diagram with these improvements applied.")
return lines.join("\n")
}
const handleImproveClick = () => {
if (
!onImproveWithSuggestions ||
!state.result ||
hasRequestedImprovement
)
return
setHasRequestedImprovement(true)
const feedback = generateImprovementFeedback()
onImproveWithSuggestions(feedback)
}
// Check if we should show the improve button
const showImproveButton =
onImproveWithSuggestions &&
state.result &&
(state.status === "success" ||
state.status === "success_with_warnings" ||
state.status === "skipped") &&
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
const getStatusDisplay = () => {
switch (state.status) {
case "capturing":
return {
label: dict.validation.capturing,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "validating":
return {
label: state.attempt
? dict.validation.validatingWithAttempt
.replace("{attempt}", String(state.attempt))
.replace("{max}", String(state.maxAttempts || 3))
: dict.validation.validating,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "success":
return {
label: dict.validation.valid,
color: "text-green-600 bg-green-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
case "success_with_warnings":
return {
label: dict.validation.validWithWarnings,
color: "text-amber-600 bg-amber-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "failed":
return {
label: dict.validation.issuesFound,
color: "text-yellow-600 bg-yellow-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "error":
return {
label: dict.validation.error,
color: "text-red-600 bg-red-50",
icon: <X className="h-4 w-4" aria-hidden="true" />,
}
case "skipped":
return {
label: dict.validation.skipped,
color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
default:
return null
}
}
const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Eye
className="w-3.5 h-3.5 text-primary"
aria-hidden="true"
/>
</div>
<span className="text-sm font-medium text-foreground/80">
{dict.validation.title}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
>
{statusDisplay.icon}
<span className="ml-1">{statusDisplay.label}</span>
</span>
{(state.result || state.error) && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
) : (
<ChevronDown
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
)}
</button>
)}
</div>
</div>
{/* Validation details when expanded */}
{isExpanded && (state.result || state.imageData) && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
{/* Captured image */}
{state.imageData && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
<ImageIcon
className="h-3 w-3"
aria-hidden="true"
/>
{dict.validation.capturedScreenshot}
</div>
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
<Image
src={state.imageData}
alt="Captured diagram for validation"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain"
unoptimized
/>
</div>
</div>
)}
{/* Issues */}
{state.result && state.result.issues.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.issuesFoundLabel}
</div>
<div className="space-y-2">
{state.result.issues.map((issue, index) => (
<div
key={index}
className={`text-xs px-3 py-2 rounded-lg border ${
issue.severity === "critical"
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
}`}
>
<span className="font-medium uppercase text-[10px] mr-2">
[{issue.type}]
</span>
{issue.description}
</div>
))}
</div>
</div>
)}
{/* Suggestions */}
{state.result && state.result.suggestions.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.suggestions}
</div>
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
{state.result.suggestions.map(
(suggestion, index) => (
<li key={index}>{suggestion}</li>
),
)}
</ul>
</div>
)}
{/* Valid result message */}
{state.result?.valid &&
state.result.issues.length === 0 && (
<div className="text-xs text-green-600 dark:text-green-400">
{dict.validation.passedValidation}
</div>
)}
</div>
)}
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
{showImproveButton && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
{hasRequestedImprovement ? (
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
<Check className="h-4 w-4" aria-hidden="true" />
{dict.validation.improvementRequested}
</div>
) : (
<>
<button
type="button"
onClick={handleImproveClick}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
>
<RefreshCw
className="h-4 w-4"
aria-hidden="true"
/>
{dict.validation.improveWithSuggestions}
</button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{dict.validation.regenerateWithFeedback}
</p>
</>
)}
</div>
)}
{/* Error details when expanded */}
{isExpanded && state.error && (
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
<div className="text-xs text-red-600">{state.error}</div>
</div>
)}
</div>
)
}
+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)
}
+1 -1
View File
@@ -1,8 +1,8 @@
"use client"
import { FileCode, FileText, Link, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"
import Image from "next/image"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { Button } from "@/components/ui/button"
import {
Dialog,
+16
View File
@@ -0,0 +1,16 @@
import NextImage, { type ImageProps } from "next/image"
import { forwardRef } from "react"
import { getAssetUrl } from "@/lib/base-path"
export default forwardRef<HTMLImageElement, ImageProps>(
function Image(props, ref) {
const src =
typeof props.src === "string" &&
props.src.startsWith("/") &&
!props.src.startsWith("//")
? getAssetUrl(props.src)
: props.src
return <NextImage {...props} src={src} ref={ref} />
},
)
File diff suppressed because it is too large Load Diff
+240 -209
View File
@@ -27,36 +27,21 @@ import {
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { useDictionary } from "@/hooks/use-dictionary"
import type { FlattenedModel } from "@/lib/types/model-config"
import {
type FlattenedModel,
PROVIDER_LOGO_MAP,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
models: FlattenedModel[]
selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void
onConfigure: () => void
onConfigure?: () => void
disabled?: boolean
showUnvalidatedModels?: boolean
}
// Map our provider names to models.dev logo names
const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
google: "google",
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible, use OpenAI logo
gateway: "vercel",
edgeone: "tencent-cloud",
vertexai: "google",
doubao: "bytedance",
modelscope: "modelscope",
}
// Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider(
models: FlattenedModel[],
@@ -126,9 +111,7 @@ export function ModelSelector({
)
const handleSelect = (value: string) => {
if (value === "__configure__") {
onConfigure()
} else if (value === "__server_default__") {
if (value === "__server_default__") {
onSelect(undefined)
} else {
onSelect(value)
@@ -175,7 +158,7 @@ export function ModelSelector({
}, [])
return (
<div ref={wrapperRef} className="inline-block">
<div ref={wrapperRef} className="min-w-0 max-w-48">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
@@ -184,7 +167,7 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
@@ -193,7 +176,7 @@ export function ModelSelector({
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="text-xs truncate">
<span className="min-w-0 truncate text-xs">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
@@ -214,199 +197,247 @@ export function ModelSelector({
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ModelSelectorEmpty>
{displayModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
<ModelSelectorEmpty>
{displayModels.length === 0 &&
models.length > 0
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={dict.modelConfig.serverModels}
/>
{Array.from(groupedServerModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{/* Server Models Section */}
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(groupedUserModels.entries()).map(
([
providerLabel,
{ provider, models: providerModels },
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.modelId}
onSelect={() =>
handleSelect(model.id)
}
className="cursor-pointer"
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={
dict.modelConfig.serverModels
}
/>
{Array.from(
groupedServerModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] || provider
}
className="mr-2"
/>
<ModelSelectorName>
{model.modelId}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict
.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict
.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
),
</ModelSelectorGroup>
),
)}
</>
)}
</>
)}
{/* Configure Option */}
<ModelSelectorSeparator />
<ModelSelectorGroup>
<ModelSelectorItem
value="__configure__"
onSelect={handleSelect}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
<Settings2 className="mr-2 h-4 w-4" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
{/* Info text */}
<div className="px-3 py-2 text-xs text-muted-foreground border-t">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(
groupedUserModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.validated !==
true && (
<span
title={
dict
.modelConfig
.unvalidatedModelWarning
}
>
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
)}
</ModelSelectorList>
</div>
</ModelSelectorList>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
<div className="relative z-10 shrink-0 border-t bg-background">
{onConfigure && (
<div className="px-3 py-2">
<ModelSelectorItem
value="__configure_models__"
onSelect={() => {
onConfigure()
setOpen(false)
}}
className="flex cursor-pointer items-center gap-2 rounded-sm"
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.configureModels}
</ModelSelectorName>
</ModelSelectorItem>
</div>
)}
<div className="px-3 pb-2 text-xs text-muted-foreground">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div>
</div>
</div>
</ModelSelectorContent>
</ModelSelectorRoot>
</div>
+298
View File
@@ -0,0 +1,298 @@
"use client"
import { Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
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
// renderSecret. That (and the optional test action) are the only genuine
// differences between the two screens; the field structure is shared here.
export type SecretField =
| "apiKey"
| "awsAccessKeyId"
| "awsSecretAccessKey"
| "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens)
const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"],
["us-west-2", "Oregon"],
["eu-west-1", "Ireland"],
["eu-west-2", "London"],
["eu-west-3", "Paris"],
["eu-central-1", "Frankfurt"],
["ap-south-1", "Mumbai"],
["ap-northeast-1", "Tokyo"],
["ap-northeast-2", "Seoul"],
["ap-southeast-1", "Singapore"],
["ap-southeast-2", "Sydney"],
["sa-east-1", "São Paulo"],
]
interface ProviderCredentialsFieldsProps {
provider: ProviderName
// Plain (non-secret) field values — secrets are owned by renderSecret
name?: string
baseUrl?: string
awsRegion?: string
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
// Render the control for a secret field. The caller may include trailing
// UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode
}
// Display name + per-provider credential inputs, shared by the user
// ModelConfigDialog and the admin Models panel.
export function ProviderCredentialsFields({
provider,
name,
baseUrl,
awsRegion,
disabled,
onChange,
renderSecret,
footer,
}: ProviderCredentialsFieldsProps) {
const dict = useDictionary()
const info = PROVIDER_INFO[provider]
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") {
return <div className="space-y-5">{footer}</div>
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
{provider === "bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-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.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : provider === "vertexai" ? (
<>
{/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2">
<Label
htmlFor="vertex-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}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
</>
) : (
<>
{/* API Key, with a link to where the provider issues keys */}
<div className="space-y-2">
<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>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
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}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
</>
)}
{footer}
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers
// that have no logo there
export function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt=""
aria-hidden="true"
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}
+49 -27
View File
@@ -23,9 +23,22 @@ export function QuotaLimitToast({
}: QuotaLimitToastProps) {
const dict = useDictionary()
const isTokenLimit = type === "token"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const formatNumber = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit
? isSelfHosted
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi
const tipHtml = isSelfHosted
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
@@ -71,19 +84,24 @@ export function QuotaLimitToast({
</div>
{/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>
{isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi}
</p>
<p>{quotaMessage}</p>
{!isSelfHosted && (
<p
dangerouslySetInnerHTML={{
__html: formatMessage(
dict.quota.doubaoSponsorship,
{
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
},
),
}}
/>
)}
<p
dangerouslySetInnerHTML={{
__html: formatMessage(dict.quota.doubaoSponsorship, {
link: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project",
}),
__html: tipHtml,
}}
/>
<p dangerouslySetInnerHTML={{ __html: dict.quota.tip }} />
<p>{dict.quota.reset}</p>
</div>{" "}
{/* Action buttons */}
@@ -101,24 +119,28 @@ export function QuotaLimitToast({
{dict.quota.configModel}
</button>
)}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
{dict.quota.selfHost}
</a>
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<Coffee className="w-3.5 h-3.5" />
{dict.quota.sponsor}
</a>
{!isSelfHosted && (
<>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<FaGithub className="w-3.5 h-3.5" />
{dict.quota.selfHost}
</a>
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<Coffee className="w-3.5 h-3.5" />
{dict.quota.sponsor}
</a>
</>
)}
</div>
</div>
)
+6 -1
View File
@@ -20,7 +20,7 @@ import {
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
export type ExportFormat = "drawio" | "png" | "svg"
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
interface SaveDialogProps {
open: boolean
@@ -74,6 +74,11 @@ export function SaveDialog({
label: dict.save.formats.svg,
extension: ".svg",
},
{
value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg,
extension: ".drawio.svg",
},
]
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
+231 -28
View File
@@ -1,8 +1,8 @@
"use client"
import { Github, Info, Moon, Sun, Tag } from "lucide-react"
import { ChevronRight, Github, Info, Moon, Sun, Tag } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
@@ -22,8 +22,10 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
@@ -56,17 +58,25 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English",
zh: "中文",
ja: "日本語",
"zh-Hant": "繁體中文",
}
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
onOpenModelConfig?: () => void
customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void
maxOutputTokens?: string
onMaxOutputTokensChange?: (value: string) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -83,11 +93,18 @@ function SettingsContent({
open,
onOpenChange,
drawioUi,
onToggleDrawioUi,
onDrawioUiChange,
darkMode,
onToggleDarkMode,
minimalStyle = false,
onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
onOpenModelConfig,
customSystemMessage = "",
onCustomSystemMessageChange = () => {},
maxOutputTokens = "",
onMaxOutputTokensChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
@@ -102,14 +119,31 @@ function SettingsContent({
const [currentLang, setCurrentLang] = useState("en")
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Panel visibility state
const [showRecentChats, setShowRecentChats] = useState(true)
const [showMyTemplates, setShowMyTemplates] = useState(true)
const [showQuickExamples, setShowQuickExamples] = useState(true)
const handlePanelToggle = useCallback(
(key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
localStorage.setItem(key, String(value))
window.dispatchEvent(new CustomEvent("panelVisibilityChange"))
},
[],
)
// Proxy settings state (Electron only)
const [httpProxy, setHttpProxy] = useState("")
const [httpsProxy, setHttpsProxy] = useState("")
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
// Re-fetch config whenever the dialog opens to ensure we always show
// the access code input if the server requires it. This fixes the case
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
// configured) would hide the access code input.
if (!open) return
fetch(getApiEndpoint("/api/config"))
.then((res) => {
@@ -125,10 +159,9 @@ function SettingsContent({
setAccessCodeRequired(required)
})
.catch(() => {
// Don't cache on error - allow retry on next mount
setAccessCodeRequired(false)
// Keep existing cached value on error
})
}, [])
}, [open])
// Detect current language from pathname
useEffect(() => {
@@ -152,6 +185,17 @@ function SettingsContent({
)
setSendShortcut(storedSendShortcut || "ctrl-enter")
setShowRecentChats(
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
)
setShowMyTemplates(
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
)
setShowQuickExamples(
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !==
"false",
)
setError("")
// Load proxy settings (Electron only)
@@ -267,7 +311,7 @@ function SettingsContent({
}
return (
<DialogContent className="sm:max-w-lg p-0 gap-0">
<DialogContent className="sm:max-w-lg p-0 gap-0 max-h-[90vh] flex flex-col overflow-hidden">
{/* Header */}
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>{dict.settings.title}</DialogTitle>
@@ -277,8 +321,29 @@ function SettingsContent({
</DialogHeader>
{/* Content */}
<div className="px-6 pb-6">
<div className="px-6 pb-6 overflow-y-auto flex-1 scrollbar-thin">
<div className="divide-y divide-border-subtle">
{/* API Keys & Models */}
{onOpenModelConfig && (
<SettingItem
label={dict.settings.apiKeysModels}
description={dict.settings.apiKeysModelsDescription}
>
<Button
variant="ghost"
size="sm"
className="h-9 w-9 p-0"
onClick={() => {
onOpenChange(false)
onOpenModelConfig()
}}
aria-label={dict.settings.apiKeysModels}
>
<ChevronRight className="h-4 w-4" />
</Button>
</SettingItem>
)}
{/* Access Code (conditional) */}
{accessCodeRequired && (
<div className="py-4 first:pt-0 space-y-3">
@@ -372,23 +437,40 @@ function SettingsContent({
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
description={dict.settings.drawioStyleDescription}
>
<Button
id="drawio-ui"
variant="outline"
onClick={onToggleDrawioUi}
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
<Select
value={drawioUi}
onValueChange={(v) =>
onDrawioUiChange(v as DrawioTheme)
}
>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
<SelectTrigger
id="drawio-ui-select"
aria-label={dict.settings.drawioStyle}
className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kennedy">
{dict.settings.themeDefault}
</SelectItem>
<SelectItem value="atlas">Atlas</SelectItem>
<SelectItem value="dark">
{dict.settings.themeDark}
</SelectItem>
<SelectItem value="min">
{dict.settings.themeMinimal}
</SelectItem>
<SelectItem value="sketch">
{dict.settings.themeSketch}
</SelectItem>
<SelectItem value="simple">
{dict.settings.themeSimple}
</SelectItem>
</SelectContent>
</Select>
</SettingItem>
{/* Diagram Style */}
@@ -410,6 +492,127 @@ function SettingsContent({
</div>
</SettingItem>
{/* Panel Visibility */}
<SettingItem
label={dict.settings.panelVisibility}
description={dict.settings.panelVisibilityDescription}
>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-recent-chats"
checked={showRecentChats}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showRecentChats,
v,
setShowRecentChats,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showRecentChats}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-my-templates"
checked={showMyTemplates}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showMyTemplates,
v,
setShowMyTemplates,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showMyTemplates}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Switch
id="show-quick-examples"
checked={showQuickExamples}
onCheckedChange={(v) =>
handlePanelToggle(
STORAGE_KEYS.showQuickExamples,
v,
setShowQuickExamples,
)
}
/>
<span className="text-xs text-muted-foreground">
{dict.settings.showQuickExamples}
</span>
</label>
</div>
</SettingItem>
{/* VLM Diagram Validation */}
<SettingItem
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
id="vlm-validation"
checked={vlmValidationEnabled}
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>
{/* Custom System Message */}
<div className="py-4 space-y-3">
<div className="space-y-0.5">
<Label
htmlFor="custom-system-message"
className="text-sm font-medium"
>
{dict.settings.customSystemMessage}
</Label>
<p className="text-xs text-muted-foreground">
{dict.settings.customSystemMessageDescription}
</p>
</div>
<Textarea
id="custom-system-message"
value={customSystemMessage}
onChange={(e) =>
onCustomSystemMessageChange(e.target.value)
}
placeholder={
dict.settings.customSystemMessagePlaceholder
}
className="min-h-[80px] max-h-[160px] text-sm"
maxLength={5000}
/>
</div>
{/* Max Output Tokens */}
<SettingItem
label={dict.settings.maxOutputTokens}
description={dict.settings.maxOutputTokensDescription}
>
<Input
id="max-output-tokens"
type="text"
inputMode="numeric"
value={maxOutputTokens}
onChange={(e) =>
onMaxOutputTokensChange(e.target.value)
}
placeholder="64000"
className="h-9 w-28 text-sm"
/>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
@@ -432,7 +635,7 @@ function SettingsContent({
>
<SelectTrigger
id="send-shortcut-select"
className="w-[170px] h-9 rounded-xl"
className="w-auto h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
+7 -5
View File
@@ -77,12 +77,13 @@ function CommandInput({
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => {
return (
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
@@ -91,7 +92,8 @@ function CommandList({
{...props}
/>
)
}
})
CommandList.displayName = CommandPrimitive.List.displayName ?? "CommandList"
function CommandEmpty({
...props
+133 -81
View File
@@ -1,16 +1,17 @@
"use client"
import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
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
@@ -20,9 +21,10 @@ interface DiagramContextType {
loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void
handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void
resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: EventExport) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
filename: string,
@@ -31,6 +33,7 @@ interface DiagramContextType {
successMessage?: string,
) => void
getThumbnailSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean
onDrawioLoad: () => void
resetDrawioReady: () => void
@@ -40,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>("")
@@ -51,10 +60,12 @@ 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)
// 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 if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("")
@@ -63,6 +74,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true
setIsDrawioReady(true)
// Restore diagram after remount (e.g., theme/UI change)
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) {
drawioRef.current.load({ xml: chartXMLRef.current })
}
}
const resetDrawioReady = () => {
@@ -70,32 +85,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Keep chartXMLRef in sync with state for restoration after remount
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle)
if (!isDrawioReady) {
hasDiagramRestoredRef.current = false
return
}
// Only restore once per ready cycle
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
// 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<{
resolver: ((data: string) => void) | null
resolver: ((data: string, fullDiagramXML?: string) => void) | null
format: ExportFormat | null
}>({ resolver: null, format: null })
@@ -118,33 +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(chartXMLRef.current)) 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 = (
@@ -153,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:",
@@ -174,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({
@@ -185,33 +205,40 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
return null
}
const handleDiagramExport = (data: any) => {
// 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)
const handleDiagramExport = (data: EventExport) => {
// 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
}
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
if (format === "png" || format === "svg") {
return
}
return
}
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML)
setLatestSvg(data.data)
// Only add to history if this was a user-initiated export
// 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)
@@ -226,8 +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 but
// DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
return
}
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("")
@@ -246,23 +285,26 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Map format to draw.io export format
const drawioFormat = format === "drawio" ? "xmlsvg" : format
const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Set up the resolver before triggering export
saveResolverRef.current = {
resolver: (exportData: string) => {
resolver: (exportData: string, fullDiagramXML?: string) => {
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") {
// Extract XML from SVG for .drawio format
const xml = extractDiagramXML(exportData)
let xmlContent = xml
if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
}
fileContent = xmlContent
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
@@ -270,8 +312,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
fileContent = exportData
mimeType = "image/png"
extension = ".png"
} else if (format === "xmlsvg") {
// Editable SVG: pass data URL directly (like PNG)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".drawio.svg"
} else {
// SVG format
// SVG format (view-only)
fileContent = exportData
mimeType = "image/svg+xml"
extension = ".svg"
@@ -317,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)
@@ -350,9 +400,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
clearDiagram,
saveDiagramToFile,
getThumbnailSvg,
captureValidationPng,
isDrawioReady,
onDrawioLoad,
resetDrawioReady,
+3
View File
@@ -11,6 +11,9 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# environment:
# # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
+33 -19
View File
@@ -19,7 +19,9 @@
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 的赞助支持,本项目的 Demo 现已接入强大的 K2-thinking 模型!
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型!
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a>
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -28,7 +30,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目录](#目录)
- [示例](#示例)
- [功能特性](#功能特性)
- [MCP服务器(预览)](#mcp服务器预览)
- [MCP服务器](#mcp服务器)
- [Claude Code CLI](#claude-code-cli)
- [快速开始](#快速开始)
- [在线试用](#在线试用)
@@ -54,31 +56,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr>
<td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br />
<p><strong>提示词:</strong> 给我一个带有**动画连接器**的Transformer架构图。</p>
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>GCP架构图</strong><br />
<p><strong>提示词:</strong> 使用**GCP图标**生成一个GCP架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/gcp_demo.svg" alt="GCP架构图" width="480" />
<strong>RAG技术图</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" />
</td>
<td width="50%" valign="top">
<strong>AWS架构图</strong><br />
<p><strong>提示词:</strong> 使用**AWS图标**生成一个AWS架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/aws_demo.svg" alt="AWS架构图" width="480" />
<strong>React和AWS认证流程</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="认证架构图" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Azure架构图</strong><br />
<p><strong>提示词:</strong> 使用**Azure图标**生成一个Azure架构图。在这个图中,用户连接到托管在实例上的前端。</p>
<img src="../../public/azure_demo.svg" alt="Azure架构图" width="480" />
<strong>开放式创新</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="开放式创新图" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫咪素描</strong><br />
<p><strong>提示词:</strong> 给我画一只可爱的猫。</p>
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" />
</td>
</tr>
@@ -96,9 +98,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器(预览)
> **预览功能**:此功能为实验性功能,可能不稳定。
## MCP服务器
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。
@@ -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等客户端配置。
## 快速开始
@@ -195,7 +202,7 @@ npm run dev
## 多提供商支持
- [字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- AWS Bedrock(默认)
- OpenAI
- Anthropic
@@ -204,6 +211,7 @@ npm run dev
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -216,12 +224,18 @@ npm run dev
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理
@@ -236,7 +250,7 @@ npm run dev
## 支持与联系
**特别感谢[字节跳动豆包](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
+24
View File
@@ -0,0 +1,24 @@
# 管理面板
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
## 启用面板
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
## 可配置内容
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
## 注意事项
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
+130 -3
View File
@@ -13,7 +13,7 @@
### 豆包 (字节跳动火山引擎)
> **免费 Token**:在 [火山引擎 ARK 平台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) 注册,即可获得所有模型 50 万免费 Token!
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
```bash
DOUBAO_API_KEY=your_api_key
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
可选的自定义端点:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -53,6 +68,13 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
可选的自定义端点:
```bash
@@ -127,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
@@ -207,6 +231,98 @@ AI_MODEL=openai/gpt-4o
从 [Vercel AI Gateway 仪表板](https://vercel.com/ai-gateway) 获取您的 API 密钥。
### MiniMax
MiniMax 支持两种 API 格式:
- **Anthropic 兼容**(`/anthropic` 端点)— 推荐,支持 interleaved thinking
- **OpenAI 兼容**(`/v1` 端点)— 标准 OpenAI 聊天补全格式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
可选配置:
```bash
# 中国大陆版,Anthropic 兼容(默认)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陆版,OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国际版,Anthropic 兼容
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国际版,OpenAI 兼容
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (智谱 AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
可选的自定义端点:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (阿里云通义千问)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
可选的自定义端点:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (月之暗面 Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
可选的自定义端点:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (七牛云)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
可选的自定义端点:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (小米)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
@@ -214,7 +330,7 @@ AI_MODEL=openai/gpt-4o
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
```bash
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
```
## 服务端多模型配置
@@ -235,6 +351,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider,或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
### 配置示例
```json
+24
View File
@@ -0,0 +1,24 @@
# Admin Panel
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
## Enabling the panel
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
## What you can configure
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
## Precedence
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
## Notes
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.
+130 -3
View File
@@ -13,7 +13,7 @@ This guide explains how to configure different AI model providers for next-ai-dr
### Doubao (ByteDance Volcengine)
> **Free tokens**: Register on the [Volcengine ARK platform](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) to get 500K free tokens for all models!
> **Free tokens**: Register on the [Volcengine ARK platform](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) to get 500K free tokens for all models!
```bash
DOUBAO_API_KEY=your_api_key
@@ -61,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
Optional custom endpoint:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -68,6 +83,13 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
Optional custom endpoint:
```bash
@@ -142,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
@@ -222,6 +246,98 @@ Model format uses `provider/model` syntax:
Get your API key from the [Vercel AI Gateway dashboard](https://vercel.com/ai-gateway).
### MiniMax
MiniMax supports two API formats:
- **Anthropic-compatible** (`/anthropic` endpoint) — recommended, supports interleaved thinking
- **OpenAI-compatible** (`/v1` endpoint) — standard OpenAI chat completions format
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
Optional configuration:
```bash
# China mainland, Anthropic-compatible (default)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# China mainland, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# International, Anthropic-compatible
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# International, OpenAI-compatible
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
Optional custom endpoint:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
Optional custom endpoint:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
Optional custom endpoint:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
Optional custom endpoint:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -229,7 +345,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
```
## Server-Side Multi-Model Configuration
@@ -250,6 +366,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
### Example Configuration
```json
+31 -19
View File
@@ -19,7 +19,7 @@
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project) のご支援により、デモサイトに強力な K2-thinking モデルを導入しました!
> 注:<img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) のご支援により、デモサイトに強力な glm-4.7 モデルを導入しました!
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
@@ -28,7 +28,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- [目次](#目次)
- [例](#例)
- [機能](#機能)
- [MCPサーバー(プレビュー)](#mcpサーバープレビュー)
- [MCPサーバー](#mcpサーバー)
- [Claude Code CLI](#claude-code-cli)
- [はじめに](#はじめに)
- [オンラインで試す](#オンラインで試す)
@@ -54,31 +54,31 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
<tr>
<td colspan="2" valign="top" align="center">
<strong>アニメーションTransformerコネクタ</strong><br />
<p><strong>プロンプト:</strong> **アニメーションコネクタ**付きのTransformerアーキテクチャ図を作成してください。</p>
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="アニメーションコネクタ付きTransformerアーキテクチャ" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>GCPアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **GCPアイコン**を使用してGCPアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/gcp_demo.svg" alt="GCPアーキテクチャ図" width="480" />
<strong>RAG技術ダイアグラム</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAGアーキテクチャ図" width="480" />
</td>
<td width="50%" valign="top">
<strong>AWSアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **AWSアイコン**を使用してAWSアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/aws_demo.svg" alt="AWSアーキテクチャ図" width="480" />
<strong>ReactとAWSによる認証</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="認証アーキテクチャ図" width="480" />
</td>
</tr>
<tr>
<td width="50%" valign="top">
<strong>Azureアーキテクチャ図</strong><br />
<p><strong>プロンプト:</strong> **Azureアイコン**を使用してAzureアーキテクチャ図を生成してください。この図では、ユーザーがインスタンス上でホストされているフロントエンドに接続します。</p>
<img src="../../public/azure_demo.svg" alt="Azureアーキテクチャ図" width="480" />
<strong>オープンイノベーション</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="オープンイノベーション図" width="480" />
</td>
<td width="50%" valign="top">
<strong>猫のスケッチ</strong><br />
<p><strong>プロンプト:</strong> かわいい猫を描いてください。</p>
<p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫の絵" width="240" />
</td>
</tr>
@@ -96,9 +96,7 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
## MCPサーバー(プレビュー)
> **プレビュー機能**:この機能は実験的であり、安定しない可能性があります。
## MCPサーバー
MCP(Model Context Protocol)を介して、Claude Desktop、Cursor、VS CodeなどのAIエージェントでNext AI Draw.ioを使用できます。
@@ -124,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などのクライアント設定も含む)。
## はじめに
@@ -196,7 +201,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
## マルチプロバイダーサポート
- [ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- AWS Bedrock(デフォルト)
- OpenAI
- Anthropic
@@ -205,6 +210,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -217,12 +223,18 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み
@@ -237,7 +249,7 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
## サポート&お問い合わせ
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
**デモサイトのAPIトークン使用を支援してくださった[ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に特別な感謝を申し上げます!** ARKプラットフォームに登録すると、50万トークンが無料でもらえます!
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために[スポンサー](https://github.com/sponsors/DayuanJiang)をご検討ください!
+24
View File
@@ -0,0 +1,24 @@
# 管理パネル
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
## パネルの有効化
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
## 設定できる項目
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
## 優先順位
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
## 注意事項
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。
+130 -3
View File
@@ -13,7 +13,7 @@
### Doubao (ByteDance Volcengine)
> **無料トークン**: [Volcengine ARK プラットフォーム](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&utm_campaign=doubao&utm_content=aidrawio&utm_medium=github&utm_source=coopensrc&utm_term=project)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
> **無料トークン**: [Volcengine ARK プラットフォーム](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)に登録すると、すべてのモデルで使える50万トークンが無料で入手できます!
```bash
DOUBAO_API_KEY=your_api_key
@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1
```
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
任意のカスタムエンドポイント:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic
```bash
@@ -53,6 +68,13 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
任意のカスタムエンドポイント:
```bash
@@ -127,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
@@ -207,6 +231,98 @@ AI_MODEL=openai/gpt-4o
[Vercel AI Gateway ダッシュボード](https://vercel.com/ai-gateway)から API キーを取得してください。
### MiniMax
MiniMax は 2 つの API 形式をサポートしています:
- **Anthropic 互換**(`/anthropic` エンドポイント)— 推奨、インターリーブ思考をサポート
- **OpenAI 互換**(`/v1` エンドポイント)— 標準 OpenAI チャット補完形式
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
```
オプション設定:
```bash
# 中国大陸版、Anthropic 互換(デフォルト)
MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic
# 中国大陸版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimaxi.com/v1
# 国際版、Anthropic 互換
MINIMAX_BASE_URL=https://api.minimax.io/anthropic
# 国際版、OpenAI 互換
MINIMAX_BASE_URL=https://api.minimax.io/v1
```
### GLM (Zhipu AI)
```bash
GLM_API_KEY=your_api_key
AI_MODEL=glm-4
```
オプションのカスタムエンドポイント:
```bash
GLM_BASE_URL=https://your-custom-endpoint
```
### Qwen (Alibaba Cloud)
```bash
QWEN_API_KEY=your_api_key
AI_MODEL=qwen-turbo
```
オプションのカスタムエンドポイント:
```bash
QWEN_BASE_URL=https://your-custom-endpoint
```
### Kimi (Moonshot AI)
```bash
KIMI_API_KEY=your_api_key
AI_MODEL=kimi-latest
```
オプションのカスタムエンドポイント:
```bash
KIMI_BASE_URL=https://your-custom-endpoint
```
### Qiniu (Qiniu Cloud)
```bash
QINIU_API_KEY=your_api_key
AI_MODEL=your_model_id
```
オプションのカスタムエンドポイント:
```bash
QINIU_BASE_URL=https://your-custom-endpoint
```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -214,7 +330,7 @@ AI_MODEL=openai/gpt-4o
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
```
## サーバーサイドマルチモデル設定
@@ -235,6 +351,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
**方法3:`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
### 設定例
```json
+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**
+367
View File
@@ -0,0 +1,367 @@
# material_design
**Type:** SVG images (Google Material Icons CDN)
**URL Pattern:** `https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg`
## Usage
```xml
<mxCell value="label" style="image;aspect=fixed;html=1;image=https://fonts.gstatic.com/s/i/materialicons/{icon_name}/v6/24px.svg;verticalLabelPosition=bottom;verticalAlign=top;align=center;" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="48" height="48" as="geometry" />
</mxCell>
```
Replace `{icon_name}` with any icon name from the list below.
## action (115)
- `account_balance`
- `account_balance_wallet`
- `account_box`
- `account_circle`
- `add_shopping_cart`
- `admin_panel_settings`
- `analytics`
- `arrow_right_alt`
- `article`
- `assessment`
- `assignment`
- `assignment_ind`
- `assignment_turned_in`
- `autorenew`
- `bookmark`
- `bookmark_border`
- `build`
- `calendar_month`
- `calendar_today`
- `card_giftcard`
- `check_circle`
- `check_circle_outline`
- `code`
- `contact_support`
- `credit_card`
- `dashboard`
- `date_range`
- `delete`
- `delete_forever`
- `delete_outline`
- `description`
- `dns`
- `done`
- `done_all`
- `done_outline`
- `drag_indicator`
- `event`
- `exit_to_app`
- `explore`
- `face`
- `fact_check`
- `favorite`
- `favorite_border`
- `feedback`
- `filter_alt`
- `fingerprint`
- `flight_takeoff`
- `grade`
- `help`
- `help_outline`
- `highlight_off`
- `history`
- `home`
- `info`
- `label`
- `language`
- `launch`
- `leaderboard`
- `lightbulb`
- `list`
- `lock`
- `lock_open`
- `login`
- `logout`
- `manage_accounts`
- `note_add`
- `open_in_full`
- `open_in_new`
- `paid`
- `payment`
- `pending`
- `pending_actions`
- `perm_identity`
- `pets`
- `power_settings_new`
- `preview`
- `print`
- `published_with_changes`
- `question_answer`
- `receipt`
- `reorder`
- `report_problem`
- `room`
- `savings`
- `schedule`
- `search`
- `settings`
- `shopping_bag`
- `shopping_basket`
- `shopping_cart`
- `star_rate`
- `stars`
- `store`
- `supervisor_account`
- `swap_horiz`
- `sync_alt`
- `task_alt`
- `thumb_up`
- `thumb_up_off_alt`
- `timeline`
- `tips_and_updates`
- `today`
- `touch_app`
- `trending_up`
- `update`
- `verified`
- `verified_user`
- `view_in_ar`
- `view_list`
- `visibility`
- `visibility_off`
- `watch_later`
- `work`
- `work_outline`
- `zoom_in`
## alert (4)
- `error`
- `error_outline`
- `warning`
- `warning_amber`
## av (12)
- `library_books`
- `mic`
- `pause`
- `play_arrow`
- `play_circle`
- `play_circle_filled`
- `play_circle_outline`
- `replay`
- `skip_next`
- `videocam`
- `volume_off`
- `volume_up`
## communication (13)
- `alternate_email`
- `business`
- `call`
- `chat`
- `chat_bubble_outline`
- `email`
- `forum`
- `list_alt`
- `location_on`
- `mail_outline`
- `phone`
- `qr_code_scanner`
- `vpn_key`
## content (27)
- `add`
- `add_box`
- `add_circle`
- `add_circle_outline`
- `block`
- `bolt`
- `calculate`
- `clear`
- `content_copy`
- `create`
- `filter_list`
- `flag`
- `how_to_reg`
- `insights`
- `inventory`
- `inventory_2`
- `link`
- `mail`
- `push_pin`
- `remove`
- `remove_circle`
- `remove_circle_outline`
- `reply`
- `save`
- `send`
- `sort`
- `undo`
## device (9)
- `dark_mode`
- `devices`
- `light_mode`
- `password`
- `restart_alt`
- `sell`
- `signal_cellular_alt`
- `summarize`
- `task`
## editor (9)
- `attach_file`
- `attach_money`
- `bar_chart`
- `checklist`
- `edit_note`
- `format_list_bulleted`
- `mode_edit`
- `monetization_on`
- `post_add`
## file (8)
- `cloud_upload`
- `download`
- `file_download`
- `file_upload`
- `folder`
- `folder_open`
- `grid_view`
- `upload_file`
## hardware (6)
- `computer`
- `keyboard_arrow_down`
- `keyboard_arrow_right`
- `phone_iphone`
- `security`
- `smartphone`
## image (16)
- `add_a_photo`
- `auto_awesome`
- `auto_stories`
- `circle`
- `collections`
- `edit`
- `image`
- `navigate_before`
- `navigate_next`
- `palette`
- `photo_camera`
- `picture_as_pdf`
- `receipt_long`
- `remove_red_eye`
- `timer`
- `tune`
## maps (11)
- `badge`
- `category`
- `directions_car`
- `local_fire_department`
- `local_offer`
- `local_shipping`
- `map`
- `menu_book`
- `place`
- `restaurant`
- `volunteer_activism`
## navigation (29)
- `apps`
- `arrow_back`
- `arrow_back_ios`
- `arrow_back_ios_new`
- `arrow_downward`
- `arrow_drop_down`
- `arrow_drop_up`
- `arrow_forward`
- `arrow_forward_ios`
- `arrow_right`
- `arrow_upward`
- `campaign`
- `cancel`
- `check`
- `chevron_left`
- `chevron_right`
- `close`
- `double_arrow`
- `east`
- `expand_less`
- `expand_more`
- `fullscreen`
- `menu`
- `menu_open`
- `more_horiz`
- `more_vert`
- `payments`
- `refresh`
- `unfold_more`
## notification (6)
- `account_tree`
- `event_available`
- `priority_high`
- `support_agent`
- `sync`
- `wifi`
## places (2)
- `apartment`
- `storefront`
## search (2)
- `feed`
- `manage_search`
## social (23)
- `construction`
- `emoji_emotions`
- `emoji_events`
- `engineering`
- `group`
- `group_add`
- `groups`
- `health_and_safety`
- `notifications`
- `notifications_active`
- `notifications_none`
- `people`
- `people_alt`
- `person`
- `person_add`
- `person_outline`
- `psychology`
- `public`
- `school`
- `share`
- `thumb_up_alt`
- `travel_explore`
- `water_drop`
## toggle (8)
- `check_box`
- `check_box_outline_blank`
- `radio_button_checked`
- `radio_button_unchecked`
- `star`
- `star_border`
- `star_outline`
- `toggle_on`
Total: 300 icons (top by popularity from 2100+ available)
+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) {
+15 -5
View File
@@ -10,8 +10,13 @@ directories:
afterPack: ./scripts/afterPack.cjs
files:
- dist-electron/**/*
- "!node_modules"
- from: dist-electron
to: dist-electron
filter:
- "**/*"
- from: .
filter:
- package.json
asarUnpack:
- "**/*.node"
@@ -37,10 +42,11 @@ mac:
arch:
- x64
- arm64
hardenedRuntime: true
# Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
# to properly sign nested bundles with --deep flag for bundled draw.io files
identity: null
hardenedRuntime: false
gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg:
contents:
@@ -89,6 +95,10 @@ linux:
arch:
- x64
- arm64
- target: rpm
arch:
- x64
- arm64
# Publish configuration (optional)
publish:
+4 -6
View File
@@ -60,16 +60,14 @@ 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) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined>
getUserLocale: () => Promise<
"en" | "zh" | "ja" | "zh-Hant" | undefined
>
/** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
}
@@ -99,8 +97,8 @@ declare global {
}
export type {
ConfigPreset,
ApplyPresetResult,
ConfigPreset,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,
+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)}`,
)
}
},
}))
+130 -74
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)) {
@@ -137,7 +146,7 @@ interface ConfigPresetsFile {
version: 1
currentPresetId: string | null
presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja"
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
}
const CONFIG_FILE_NAME = "config-presets.json"
@@ -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
@@ -359,84 +384,29 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
baseUrl: "MODELSCOPE_BASE_URL",
},
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock and ollama don't use API keys in the same way
// bedrock doesn't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "", baseUrl: "OLLAMA_BASE_URL" },
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
}
/**
* 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,11 +436,95 @@ 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
*/
export function getUserLocale(): "en" | "zh" | "ja" | undefined {
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
const data = loadPresets()
return data.userLocale
}
@@ -478,7 +532,9 @@ export function getUserLocale(): "en" | "zh" | "ja" | undefined {
/**
* Set user's preferred locale in config
*/
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void {
export function setUserLocale(
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
): void {
const data = loadPresets()
data.userLocale = locale === null ? undefined : locale
savePresets(data)
+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 -19
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,23 +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")
) {
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)
}
})
})
}
+82 -102
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,18 +237,18 @@ 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"].includes(locale)) {
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" }
}
try {
setUserLocale(locale as "en" | "zh" | "ja")
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
// Rebuild the menu to reflect the new locale
rebuildAppMenu()
return { success: true }
+52 -3
View File
@@ -5,7 +5,7 @@
import { getUserLocale } from "./config-manager"
export type MenuLocale = "en" | "zh" | "ja"
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
export interface MenuTranslations {
// App menu (macOS only)
@@ -119,6 +119,34 @@ const translations: Record<MenuLocale, MenuTranslations> = {
documentation: "ドキュメント",
reportIssue: "問題を報告",
},
"zh-Hant": {
// App menu
settings: "設定...",
// File menu
file: "檔案",
// Edit menu
edit: "編輯",
// View menu
view: "檢視",
// Configuration menu
configuration: "配置",
switchPreset: "切換預設",
managePresets: "管理預設...",
addConfigurationPreset: "新增配置預設...",
// Window menu
window: "視窗",
// Help menu
help: "說明",
documentation: "文件",
reportIssue: "回報問題",
},
}
/**
@@ -126,6 +154,16 @@ const translations: Record<MenuLocale, MenuTranslations> = {
* Falls back to English if locale is not supported
*/
export function getMenuTranslations(locale: string): MenuTranslations {
// Check for zh-Hant before normalizing
if (
locale === "zh-Hant" ||
locale.toLowerCase().startsWith("zh-hant") ||
locale.toLowerCase().startsWith("zh-tw") ||
locale.toLowerCase().startsWith("zh-hk")
) {
return translations["zh-Hant"]
}
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0]
@@ -136,10 +174,21 @@ export function getMenuTranslations(locale: string): MenuTranslations {
/**
* Detect system locale from Electron app
* Returns one of: "en", "zh", "ja"
* Returns one of: "en", "zh", "ja", "zh-Hant"
*/
export function detectSystemLocale(appLocale: string): MenuLocale {
const normalized = appLocale.toLowerCase().split("-")[0]
const lower = appLocale.toLowerCase()
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
if (
lower.startsWith("zh-hant") ||
lower.startsWith("zh-tw") ||
lower.startsWith("zh-hk")
) {
return "zh-Hant"
}
const normalized = lower.split("-")[0]
if (normalized === "zh") return "zh"
if (normalized === "ja") return "ja"
+72 -43
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")
@@ -68,9 +84,16 @@ export async function startNextServer(): Promise<string> {
const env: Record<string, string> = {
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "localhost",
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
})
}
+89 -10
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"
/**
@@ -9,9 +11,11 @@ import { app } from "electron"
const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility
development: 6002,
// Production mode uses fixed port (61337) to preserve localStorage
// Falls back to sequential ports if unavailable
production: 61337,
// Legacy production port — tried first to preserve localStorage for existing users
legacyProduction: 61337,
// New production port below the ephemeral range (49152-65535)
// to avoid conflicts with Windows Hyper-V / ephemeral port reservations
production: 13370,
// Maximum attempts to find an available port (fallback)
maxAttempts: 100,
}
@@ -21,13 +25,50 @@ 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
*/
export function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once("error", () => resolve(false))
server.once("error", (err: NodeJS.ErrnoException) => {
console.warn(`Port ${port} unavailable: ${err.code}`)
resolve(false)
})
server.once("listening", () => {
server.close()
resolve(true)
@@ -39,12 +80,13 @@ export function isPortAvailable(port: number): Promise<boolean> {
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: uses fixed port (61337) 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)
*
* @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port
* @throws Error if no available port found after max attempts
*/
export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged
@@ -64,7 +106,30 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// Try preferred port first
// 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
if (await isPortAvailable(legacyPort)) {
allocatedPort = legacyPort
return legacyPort
}
}
// Try preferred port
if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort
return preferredPort
@@ -84,9 +149,23 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
}
}
throw new Error(
`Failed to find available port after ${PORT_CONFIG.maxAttempts} attempts`,
// Last resort: let the OS pick an available port
console.warn(
"All sequential ports failed. Requesting OS-assigned port (localStorage may not persist across restarts).",
)
const osPort = await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.once("listening", () => {
const addr = server.address()
const port = (addr as net.AddressInfo).port
server.close(() => resolve(port))
})
server.listen(0, "127.0.0.1")
})
allocatedPort = osPort
console.log(`OS assigned port: ${osPort}`)
return osPort
}
/**
@@ -113,5 +192,5 @@ export function getServerUrl(): string {
"No port allocated yet. Call findAvailablePort() first.",
)
}
return `http://localhost:${allocatedPort}`
return `http://127.0.0.1:${allocatedPort}`
}
+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
+50 -2
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,17 +60,28 @@ export function createWindow(serverUrl: string): BrowserWindow {
})
// Open DevTools in development
if (process.env.NODE_ENV === "development") {
if (!app.isPackaged) {
mainWindow.webContents.openDevTools()
}
// Override the draw.io iframe's beforeunload handler so the window can
// close after the user edits text in a shape (fixes #815). Diagrams are
// already persisted via autosave, so the prompt is unnecessary.
mainWindow.webContents.on("will-prevent-unload", (event) => {
event.preventDefault()
})
mainWindow.on("closed", () => {
mainWindow = null
})
// Handle page title updates
mainWindow.webContents.on("page-title-updated", (event, title) => {
if (title && !title.includes("localhost")) {
if (
title &&
!title.includes("localhost") &&
!title.includes("127.0.0.1")
) {
mainWindow?.setTitle(title)
} else {
event.preventDefault()
@@ -82,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()
}
}
+81 -6
View File
@@ -1,12 +1,22 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
# Default: bedrock
AI_PROVIDER=bedrock
# AI_MODEL: The model ID for your chosen provider (REQUIRED)
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
# The first one becomes the default and the rest appear in the model picker.
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
# so a thinking model can spend it all before the tool call. Users can 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
# AWS Bedrock Configuration
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your-access-key-id
@@ -25,7 +35,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
# Anthropic (Direct) Configuration
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
@@ -33,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)
@@ -59,14 +69,19 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_EFFORT=low # Optional: Azure reasoning effort (low, medium, high)
# AZURE_REASONING_SUMMARY=detailed
# Ollama (Local) Configuration
# OLLAMA_BASE_URL=http://localhost:11434/api # Optional, defaults to localhost
# Ollama Configuration (Local or Cloud)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
# OpenRouter Configuration
# OPENROUTER_API_KEY=sk-or-v1-...
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
# AIHubMix Configuration
# AIHUBMIX_API_KEY=your-aihubmix-api-key
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
# DeepSeek Configuration
# DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
@@ -108,12 +123,25 @@ 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
# Admin Panel (Optional)
# Set a password to enable the web admin panel at /admin, where most of the
# settings in this file can be edited at runtime (stored in data/settings.json,
# which takes precedence over environment variables).
# Leave unset to disable the admin panel entirely.
# ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Draw.io Configuration (Optional)
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance
@@ -134,3 +162,50 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Allow private/internal URLs for reverse proxy setups (default: true)
# Set to "false" to block private IPs, localhost, and internal hostnames
# ALLOW_PRIVATE_URLS=false
# Self-hosted deployment (Optional)
# Self-hosted users may implement custom quota-management solutions,
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
# NEXT_PUBLIC_SELFHOSTED=true
# Minimax Configuration (Optional)
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
# MINIMAX_API_KEY=your_minimax_api_key
# MINIMAX_BASE_URL=https://api.minimaxi.com/anthropic # Optional, default (China mainland)
# GLM Configuration (Optional)
# Get your API key from: https://open.bigmodel.cn/dev/api
# GLM_API_KEY=your_glm_api_key
# GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # Optional, default
# Qwen Configuration (Optional)
# Get your API key from: https://www.aliyun.com/product/bailian
# QWEN_API_KEY=your_qwen_api_key
# QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # Optional, default
# Kimi Configuration (Optional)
# Get your API key from: https://platform.moonshot.cn/
# KIMI_API_KEY=your_kimi_api_key
# KIMI_BASE_URL=https://api.moonshot.cn/v1 # Optional, default
# Qiniu Configuration (Optional)
# Get your API key from: https://www.qiniu.com/ai/models
# QINIU_API_KEY=your_qiniu_api_key
# QINIU_BASE_URL=https://api.qnaigc.com/v1 # Optional, default
# Novita AI Configuration (Optional)
# Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
# MiMo (Xiaomi) Configuration (Optional)
# Get your API key from: https://platform.xiaomimimo.com/
# MIMO_API_KEY=your_mimo_api_key
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
# Atlas Cloud Configuration (Optional)
# Get your API key from: https://www.atlascloud.ai/console/api-keys
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
+244 -55
View File
@@ -1,9 +1,20 @@
import type { MutableRefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
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
@@ -30,13 +41,31 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3
// Type for the validation function passed from useValidateDiagram hook
type ValidateDiagramFn = (
imageData: string,
sessionId?: string,
) => Promise<ValidationResult>
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>
onExport: () => void
captureValidationPng?: () => Promise<string | null>
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
sessionId?: string
onValidationStateChange?: (
toolCallId: string,
state: ValidationState,
) => void
}
/**
@@ -49,11 +78,36 @@ interface UseDiagramToolHandlersParams {
export function useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
sessionId,
onValidationStateChange,
}: UseDiagramToolHandlersParams) {
// Helper to update validation state
const updateValidationState = (
toolCallId: string,
status: ValidationStatus,
options?: {
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string
},
) => {
if (onValidationStateChange) {
onValidationStateChange(toolCallId, {
status,
...options,
})
}
}
const handleToolCall = async (
{ toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn,
@@ -124,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)
@@ -155,7 +210,150 @@ ${finalXml}
// Success - diagram will be rendered by chat-message-display
if (DEBUG) {
console.log(
"[display_diagram] Success! Adding tool output with state: output-available",
"[display_diagram] Success! Checking if VLM validation is enabled...",
)
}
// VLM validation after successful display
if (
enableVlmValidation &&
captureValidationPng &&
validateDiagram
) {
let capturedPngData: string | null = null
try {
// Notify UI that we're starting capture
updateValidationState(toolCall.toolCallId, "capturing")
// Small delay (100ms) to allow diagram rendering to complete before capture.
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
await new Promise((resolve) => setTimeout(resolve, 100))
capturedPngData = await captureValidationPng()
if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
)
}
// 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,
maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData,
},
)
const result = await validateDiagram(
capturedPngData,
sessionId,
)
if (!result.valid) {
if (attempt < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current = attempt
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
// Notify UI of validation failure (include the image)
updateValidationState(
toolCall.toolCallId,
"failed",
{
attempt,
maxAttempts: MAX_VALIDATION_RETRIES,
result,
imageData: capturedPngData,
},
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
})
return
} else {
// Last attempt - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
validationRetryCountRef.current = 0
// Notify UI that we're accepting with issues (include the image)
updateValidationState(
toolCall.toolCallId,
"skipped",
{ result, imageData: capturedPngData },
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Diagram displayed (validation issues noted but max retries reached).",
})
return
}
} else {
// Validation passed - reset retry count
validationRetryCountRef.current = 0
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
)
}
// Notify UI of success (include the image)
// Use "success_with_warnings" if valid but has issues
const hasWarnings = result.issues.length > 0
updateValidationState(
toolCall.toolCallId,
hasWarnings
? "success_with_warnings"
: "success",
{ result, imageData: capturedPngData },
)
}
} else {
// PNG capture failed - skip validation
updateValidationState(toolCall.toolCallId, "skipped")
}
} catch (error) {
// VLM validation error - log but don't block the user
console.warn(
"[display_diagram] VLM validation error:",
error,
)
updateValidationState(toolCall.toolCallId, "error", {
error:
error instanceof Error
? error.message
: "Validation failed",
imageData: capturedPngData || undefined,
})
}
}
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-available",
)
}
addToolOutput({
@@ -180,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 {
@@ -199,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
@@ -232,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)
@@ -270,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,
@@ -294,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()
@@ -332,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({
+59 -30
View File
@@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { getApiEndpoint } from "@/lib/base-path"
import type { FlattenedServerModel } from "@/lib/server-model-config"
import { STORAGE_KEYS } from "@/lib/storage"
import {
@@ -100,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
@@ -143,11 +153,21 @@ 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
fetch("/api/server-models")
fetch(getApiEndpoint("/api/server-models"))
.then((res) => {
if (!res.ok) {
console.error(
@@ -164,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) => {
@@ -259,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 => {
@@ -333,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)
+139
View File
@@ -0,0 +1,139 @@
"use client"
/**
* Hook for VLM-based diagram validation using AI SDK's useObject.
*/
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,
ValidationResultSchema,
} from "@/lib/validation-schema"
export type { ValidationResult }
// Default valid result for fallback cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
interface UseValidateDiagramOptions {
onSuccess?: (result: ValidationResult) => void
onError?: (error: Error) => void
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
reject: (error: Error) => void
}
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { onSuccess, onError } = options
const pendingValidationRef = useRef<PendingValidation | null>(null)
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,
}: {
object: ValidationResult | undefined
error: Error | undefined
}) => {
if (finishError) {
console.error(
"[useValidateDiagram] Validation error:",
finishError,
)
onError?.(finishError)
pendingValidationRef.current?.reject(finishError)
pendingValidationRef.current = null
return
}
if (object) {
const result = object as ValidationResult
onSuccess?.(result)
pendingValidationRef.current?.resolve(result)
pendingValidationRef.current = null
}
},
onError: (err: Error) => {
console.error("[useValidateDiagram] Stream error:", err)
onError?.(err)
pendingValidationRef.current?.reject(err)
pendingValidationRef.current = null
},
})
/**
* Validate a diagram image.
* Returns a promise that resolves with the validation result.
*/
const validate = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers
pendingValidationRef.current = { resolve, reject }
// Submit the validation request
submit({ imageData, sessionId })
})
},
[submit],
)
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
*/
const validateWithFallback = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
try {
return await validate(imageData, sessionId)
} catch (error) {
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
)
return DEFAULT_VALID_RESULT
}
},
[validate],
)
return {
// Validation functions
validate,
validateWithFallback,
stop,
// State
isValidating: isLoading,
partialResult: object as ValidationResult | undefined,
error,
}
}
+11 -1
View File
@@ -1,7 +1,17 @@
import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() {
export async function register() {
// Overlay admin settings file onto process.env before anything reads config
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
const { applyToEnv } = await import("@/lib/admin/settings")
applyToEnv()
} catch (err) {
console.error("[admin-settings] Failed to apply settings:", err)
}
}
// Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn(
+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 },
)
}
+37
View File
@@ -0,0 +1,37 @@
import { timingSafeEqual } from "crypto"
// Shared auth for admin API routes: compares x-admin-password header
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
export function checkAdminAuth(req: Request): Response | null {
const password = process.env.ADMIN_PASSWORD
if (!password) {
return Response.json(
{
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
},
{ status: 403 },
)
}
const provided = req.headers.get("x-admin-password") || ""
const a = Buffer.from(provided)
const b = Buffer.from(password)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json(
{ error: "Invalid admin password" },
{ status: 401 },
)
}
return null
}
export interface MaskedSecret {
isSet: true
hint: string
}
export function maskSecret(value: string): MaskedSecret {
return {
isSet: true,
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
}
}
+315
View File
@@ -0,0 +1,315 @@
import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
slugify,
} from "@/lib/server-model-config"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
import { type MaskedSecret, maskSecret } from "./auth"
import { loadSettings } from "./settings"
// Admin-configured providers, mirroring the user ModelConfigDialog's data
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
//
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
// loadRawServerModelsConfig() merges the env baseline with the panel's
// providers at read time, so .env stays authoritative for its own entries.
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
// OPENAI_API_KEY that env-based entries may rely on.
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
// A secret field in transit: plaintext string (new value) or an
// {isSet} marker meaning "keep the stored value".
const SecretInputSchema = z
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
.optional()
export const AdminProviderSchema = z.object({
id: z.string().min(1),
provider: ProviderNameSchema,
name: z.string().optional(),
apiKey: SecretInputSchema,
baseUrl: z.string().optional(),
awsAccessKeyId: SecretInputSchema,
awsSecretAccessKey: SecretInputSchema,
awsRegion: z.string().optional(),
vertexApiKey: SecretInputSchema,
models: z.array(z.string().min(1)),
isDefault: z.boolean().optional(),
})
export const AdminProvidersSchema = z.array(AdminProviderSchema)
// Stored shape: secrets are plain strings (never {isSet} markers, which
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
// hand-edited/corrupted value can't slip a marker object past maskSecret.
const StoredAdminProviderSchema = AdminProviderSchema.extend({
apiKey: z.string().optional(),
awsAccessKeyId: z.string().optional(),
awsSecretAccessKey: z.string().optional(),
vertexApiKey: z.string().optional(),
})
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
// Stored form: secrets are plain strings
export interface StoredAdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string
baseUrl?: string
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
vertexApiKey?: string
models: string[]
isDefault?: boolean
}
const SECRET_FIELDS = [
"apiKey",
"awsAccessKeyId",
"awsSecretAccessKey",
"vertexApiKey",
] as const
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
function credEnvNames(
provider: ProviderName,
index: number,
): { key?: string; url?: string } {
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
return {}
}
const prefix =
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
const suffix = index === 0 ? "" : `_${index + 1}`
return {
key: `ADMIN_${prefix}_API_KEY${suffix}`,
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
}
}
export function loadAdminProviders(): StoredAdminProvider[] {
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
// Validate each entry's shape — a malformed/hand-edited value must
// not reach runtime code that assumes provider/models exist.
return parsed.flatMap((entry) => {
const result = StoredAdminProviderSchema.safeParse(entry)
return result.success ? [result.data as StoredAdminProvider] : []
})
} catch {
console.error("[admin-providers] Failed to parse stored providers")
return []
}
}
export type MaskedAdminProvider = Omit<
StoredAdminProvider,
(typeof SECRET_FIELDS)[number]
> & {
apiKey?: MaskedSecret
awsAccessKeyId?: MaskedSecret
awsSecretAccessKey?: MaskedSecret
vertexApiKey?: MaskedSecret
}
export function maskAdminProviders(
list: StoredAdminProvider[],
): MaskedAdminProvider[] {
return list.map((p) => {
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
masked[field] = value ? maskSecret(value) : undefined
}
return masked
})
}
// Resolve {isSet} markers in incoming secrets against the stored list
export function mergeSecrets(
incoming: AdminProviderInput[],
stored: StoredAdminProvider[],
): StoredAdminProvider[] {
const storedById = new Map(stored.map((p) => [p.id, p]))
return incoming.map((p) => {
const prev = storedById.get(p.id)
const merged = { ...p } as StoredAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
if (typeof value === "string") {
merged[field] = value || undefined
} else if (value?.isSet) {
merged[field] = prev?.[field]
} else {
merged[field] = undefined
}
}
return merged
})
}
function displayName(p: StoredAdminProvider): string {
return p.name?.trim() || PROVIDER_INFO[p.provider].label
}
export function validateAdminProviders(
list: StoredAdminProvider[],
envConfig: ServerModelsConfig | null = null,
): string | null {
const envProviders = envConfig?.providers ?? []
for (const single of FIXED_CRED_PROVIDERS) {
if (list.filter((p) => p.provider === single).length > 1) {
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
}
// Its credentials are global; a panel instance would silently
// override the credentials env-configured models rely on
if (
list.some((p) => p.provider === single) &&
envProviders.some((p) => p.provider === single)
) {
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))
const slugs = names.map(slugify)
if (new Set(slugs).size !== slugs.length) {
return "Provider display names must be unique (ignoring case and punctuation)."
}
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.`
}
if (list.filter((p) => p.isDefault).length > 1) {
return "Only one provider can be the default."
}
return null
}
// The panel's contribution to the server models config, derived at read
// time and merged with the env baseline by loadRawServerModelsConfig().
export function adminProvidersToConfig(
list: StoredAdminProvider[],
): ServerModelsConfig {
const config: ServerModelsConfig = { providers: [] }
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.models.length === 0) continue
const env = credEnvNames(p.provider, index)
config.providers.push({
name: displayName(p),
provider: p.provider,
models: p.models,
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
...(p.isDefault ? { default: true } : {}),
})
}
return config
}
// Settings updates derived from the provider list: credential env vars,
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
// Keys derived from `previous` but absent now are set to null (removed,
// falling back to the environment).
export function deriveEnvUpdates(
list: StoredAdminProvider[],
previous: StoredAdminProvider[],
): Record<string, string | null> {
const updates: Record<string, string | null> = {}
// Clear everything the previous list owned, then overwrite below
for (const key of derivedEnvKeys(previous)) updates[key] = null
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
// 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.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
} else if (p.provider === "ollama") {
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
} else {
const env = credEnvNames(p.provider, index)
if (env.key && p.apiKey) updates[env.key] = p.apiKey
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
}
}
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
// The panel's default also becomes the server-wide default model;
// without one, the env-configured default applies.
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
if (defaultEntry) {
updates.AI_PROVIDER = defaultEntry.provider
updates.AI_MODEL = defaultEntry.models[0]
}
return updates
}
// Every settings key the panel may have written for a given list.
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>([
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
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")
} else if (p.provider === "vertexai") {
keys.add("GOOGLE_VERTEX_API_KEY")
keys.add("GOOGLE_VERTEX_BASE_URL")
} else if (p.provider === "ollama") {
keys.add("OLLAMA_API_KEY")
keys.add("OLLAMA_BASE_URL")
} else {
const env = credEnvNames(p.provider, index)
if (env.key) keys.add(env.key)
if (env.url) keys.add(env.url)
}
}
return [...keys]
}
+229
View File
@@ -0,0 +1,229 @@
// Declarative registry of the general env vars editable in the admin panel.
// Drives both server-side validation (app/api/admin/settings) and UI
// rendering (app/[lang]/admin). Keys are exactly the env var names.
//
// AI providers and models are managed separately in the panel's Models
// section (lib/admin/providers.ts), not here.
//
// Not listed here (and therefore rejected by the API):
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export interface SettingDef {
key: string
group: string
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
// Built-in default applied at runtime when the value is unset, so the UI
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
default?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
}
export interface SettingGroup {
id: string
title: string
description: string
// Optional sections gated by an on/off switch in the panel; fields are
// grayed out until enabled. Starts on when any field is already set.
toggleable?: boolean
}
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "generation",
title: "Generation",
description: "Output parameters applied to all chat requests.",
},
{
id: "access",
title: "Access Control",
description: "Restrict who can use this deployment.",
},
{
id: "features",
title: "Features",
description: "Optional features and security toggles.",
},
{
id: "observability",
title: "Observability",
description: "Langfuse tracing for LLM calls.",
toggleable: true,
},
{
id: "quota",
title: "Quota & Rate Limits",
description:
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
toggleable: true,
},
]
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── Generation ───────────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "generation",
type: "number",
label: "Temperature",
description:
"Leave unset for reasoning models that reject temperature.",
min: 0,
max: 2,
},
{
key: "MAX_OUTPUT_TOKENS",
group: "generation",
type: "number",
label: "Max Output Tokens",
min: 1,
},
// ── Access Control ───────────────────────────────────────────────
{
key: "ACCESS_CODE_LIST",
group: "access",
type: "string",
label: "Access Codes",
description:
"Comma-separated list. Users must enter one to chat. Empty = open access.",
placeholder: "code1,code2",
},
// ── Features ─────────────────────────────────────────────────────
{
key: "ENABLE_VLM_VALIDATION",
group: "features",
type: "boolean",
label: "VLM Diagram Validation",
description:
"Visually validate generated diagrams with a vision model.",
},
{
key: "VALIDATION_MODEL",
group: "features",
type: "string",
label: "Validation Model",
description: "Falls back to the default AI model when empty.",
},
{
key: "VALIDATION_TIMEOUT",
group: "features",
type: "number",
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
type: "boolean",
label: "Allow Private URLs",
description:
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
// Unset means allowed at runtime (ssrf-protection: !== "false")
default: "true",
},
// ── Observability ────────────────────────────────────────────────
{
key: "LANGFUSE_PUBLIC_KEY",
group: "observability",
type: "string",
label: "Langfuse Public Key",
placeholder: "pk-lf-…",
restartRequired: true,
},
{
key: "LANGFUSE_SECRET_KEY",
group: "observability",
type: "secret",
label: "Langfuse Secret Key",
restartRequired: true,
},
{
key: "LANGFUSE_BASEURL",
group: "observability",
type: "string",
label: "Langfuse Base URL",
placeholder: "https://cloud.langfuse.com",
restartRequired: true,
},
// ── Quota ────────────────────────────────────────────────────────
{
key: "DAILY_REQUEST_LIMIT",
group: "quota",
type: "number",
label: "Daily Request Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "DAILY_TOKEN_LIMIT",
group: "quota",
type: "number",
label: "Daily Token Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "TPM_LIMIT",
group: "quota",
type: "number",
label: "Tokens Per Minute",
min: 1,
},
{
key: "DYNAMODB_QUOTA_TABLE",
group: "quota",
type: "string",
label: "DynamoDB Table",
description: "Quota enforcement is disabled when empty.",
restartRequired: true,
},
{
key: "DYNAMODB_REGION",
group: "quota",
type: "string",
label: "DynamoDB Region",
placeholder: "ap-northeast-1",
restartRequired: true,
},
{
key: "QUOTA_TIMEZONE",
group: "quota",
type: "string",
label: "Quota Timezone",
description: "Timezone for the daily reset boundary.",
placeholder: "UTC",
restartRequired: true,
},
]
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
SETTING_GROUPS.map((g) => [
g.id,
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
]),
)
+149
View File
@@ -0,0 +1,149 @@
import fs from "fs"
import path from "path"
// File-based admin settings, overlaid onto process.env (dotenv-style).
// Precedence: settings file > env var > built-in default.
// Keys are exactly the env var names.
interface SettingsFile {
version: 1
values: Record<string, 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
}
// 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
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
}
export function loadSettings(): Record<string, string> {
if (state.cachedSettings) return state.cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
// Keep only string values — a hand-edited or corrupted file could
// hold null/arrays/numbers that would otherwise be overlaid onto
// process.env and coerce to junk like "[object Object]".
const values: Record<string, string> = {}
const rawValues =
parsed &&
typeof parsed.values === "object" &&
parsed.values &&
!Array.isArray(parsed.values)
? parsed.values
: {}
for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value
}
state.cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
state.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 state.overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else process.env[key] = original
}
}
for (const [key, value] of Object.entries(values)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key] ?? null
}
process.env[key] = value
}
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 (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null
return process.env[key] ?? null
}
// Whether a key's current value comes from the file, the environment, or is unset
export function getValueSource(key: string): "file" | "env" | "default" {
if (key in loadSettings()) return "file"
return getEnvFallback(key) !== null ? "env" : "default"
}
export function saveSettings(updates: Record<string, string | null>): void {
const current = { ...loadSettings() }
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete current[key]
else current[key] = value
}
const filePath = getSettingsPath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp`
const data: SettingsFile = { version: 1, values: current }
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
state.cachedSettings = current
applyToEnv()
}
let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
} catch {
writableCache = false
}
return writableCache
}
// Test-only: reset module state
export function _resetForTests(): void {
state.cachedSettings = null
writableCache = null
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
}
state.overlaidKeys = new Set()
state.originalEnv = {}
}
+540 -588
View File
File diff suppressed because it is too large Load Diff
+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)
}
},
},
})
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Types and utilities for VLM-based diagram validation.
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
/**
* Format validation feedback for display to the AI model.
* This creates a human-readable error message that guides the AI to fix issues.
*
* @param result - The validation result from VLM
* @returns Formatted string for tool error output
*/
export function formatValidationFeedback(result: ValidationResult): string {
// If validation passed with no issues, return empty string
if (result.valid && result.issues.length === 0) {
return ""
}
const lines: string[] = []
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
lines.push("")
// Group issues by severity
const criticalIssues = result.issues.filter(
(i) => i.severity === "critical",
)
const warnings = result.issues.filter((i) => i.severity === "warning")
if (criticalIssues.length > 0) {
lines.push("Critical Issues (must fix):")
for (const issue of criticalIssues) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (warnings.length > 0) {
lines.push("Warnings:")
for (const issue of warnings) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (result.suggestions.length > 0) {
lines.push("Suggestions to fix:")
for (const suggestion of result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push(
"Please regenerate the diagram with corrected layout to fix these visual issues.",
)
return lines.join("\n")
}
+17
View File
@@ -0,0 +1,17 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
export const i18n = {
defaultLocale: "en",
locales: ["en", "zh", "ja"],
locales: ["en", "zh", "ja", "zh-Hant"],
} as const
export type Locale = (typeof i18n)["locales"][number]

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