feat: MCP feature parity, the web app on the MCP core, and provider fixes (#951)

* 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

* 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

* 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

* 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 " 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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.

* 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)

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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.

* 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

* 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

* 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

* refactor(settings): drop an unused index parameter

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* style: auto-format with Biome

* 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.

* 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.

* 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.

* 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.

* fix(chat): keep the canvas after an unrelated error, and more review fixes

Found by the second PR review:
- After a streamed edit, an older render of the stream stored the edit's
  original diagram again, and the next failed request (no quota, a lost
  connection) put that old diagram back on the canvas. The tool handler
  now marks its call as handled, so the preview code leaves it alone.
- An edit applied before the UI showed an earlier broken edit's error was
  erased when that error undid its preview, or was built on that preview.
  The handler now starts from the diagram before all unhandled previews,
  and reads the diagram state that updates at once.
- A failed or stopped display_diagram left its half drawn diagram on the
  canvas. Its preview is undone now, like an edit's.
- "New chat" cleared a chat that could not be saved (storage full).
- The settings dialog showed a model list, a fetch error or a test result
  on the provider that was opened after the request started, and marked a
  model id changed during the test as tested.
- A tool call with broken JSON was shown as cut off by the output limit.
- History entries and session thumbnails could pair with a later diagram
  when draw.io answered an export late.
- A server model saved before non-ASCII provider names got into the id
  was reset to the default model.

* fix(mcp-server): count a one-page view only for that page, and more review fixes

Found by the second PR review:
- get_diagram with a page selector, or a rejected edit's error, counted
  the whole document as seen, so an edit on another page could overwrite
  the user's change there. A one-page view now counts for all pages only
  if the others are unchanged; otherwise the reply says to get them.
- add_page accepted shapes with the root cell ids "0" and "1" and renamed
  them, breaking their edges. The check also missed ids on UserObject
  wrappers and ids written with spaces around the "=".
- Root cells written over two lines were kept as an extra layer, cells
  with id = "a" did not count as cells, and CDATA text before a page's
  model passed the check although draw.io cannot open the page.
- Auto-save cleanup deleted the user's own files that start with mcp-.
  Only names in the session id format are removed now.
- Restoring a history entry dropped edits made in the browser since the
  last entry. They are added to history first.
- A session whose state expired showed a blank page, and the next change
  overwrote its auto-save file. The saved file is loaded instead.
- An edit on a page export's one-page projection, made before the real
  document was back, replaced the whole document.
- A late sync reply could overwrite a newer edit: each sync export is
  numbered, and the server ignores replies older than the current state.
- screenshot_diagram could return another session's image after
  start_session ran during its retries.

* fix(server): use the keys the user sent, and more review fixes

Found by the second PR review:
- With AWS_BEARER_TOKEN_BEDROCK set on the server, a request with the
  user's AWS keys ran on the server's token: the Bedrock SDK prefers it.
  Checked with Bedrock: invalid user keys used to get an answer.
- An OpenAI key with the official URL filled in (the settings form does
  that) went to the Responses API. Back to main's rule: a configured base
  URL uses Chat Completions.
- A user's Ollama key went to the server's OLLAMA_BASE_URL, for chat and
  for the model list. Like every other provider, it goes to the user's
  base URL or Ollama Cloud.
- The server's keyless Ollama and EdgeOne were not counted in the quota.
- AI_MODEL models on the server's keys ran on any provider with a server
  key, not only on AI_PROVIDER.
- A user's Azure key without a base URL used the server's resource name.
- The admin panel's Test button failed whenever access codes were set.
- DeepSeek's errors in the stream (plain text) were shown as they were,
  without a hint and also on the server's keys. Bedrock's throttling in
  the stream was not recognised as a rate limit.
- The EdgeOne function accepted text/plain; x=application/json, which
  other sites can send without a CORS preflight.
- Desktop app: a launch that found the old port taken for a moment (the
  previous version still quitting after an update) remembered the new
  port for good. The new port is kept only when Windows reserves the old
  one. A failed read of the presets file moved it aside as corrupt, and a
  save could then replace the presets. Switching presets on the same port
  now reloads the page. The dev launcher no longer misses a preset change
  made before or during a restart.

* fix(chat): undo rejected or stopped previews, and more fixes from the third review

- A call the server runs (get_shape_library) still reaches the browser's
  tool handler, and it dropped the stored diagram of an earlier broken
  edit, whose preview then stayed. Only the tools that draw take it now.
- A display_diagram whose final XML fails the checks loads the diagram
  from before its preview again, as a failed edit does.
- After Stop, a tool result that arrives later (a screenshot check still
  running) no longer sends a new request; Stop also skips calls the tool
  handler already took.
- New chat and opening another chat kept nothing of a diagram drawn
  without messages when it could not be saved; now they stay on it.
- The settings dialog drops a model list or test result whose provider
  credentials changed meanwhile, also in another tab.
- A saved provider this version does not know crashed the whole page on
  load; it is skipped.
- The input emptied a moment after the message showed in the chat, so it
  briefly appeared twice (seen as a flaky e2e test).
- The desktop app's preset switch on the same port refetches the server
  models instead of reloading the page, which lost unsent attachments.

* fix(mcp-server): recover sessions in one place, and more fixes from the third review

- A session whose state expired was recovered from its auto-save file only
  for the preview page; the tools built on their older copy and then
  overwrote the file. They now recover it first (restoreSavedSession).
- A preview tab that missed the last AI write pushed its older diagram
  over the recovered one after a restart. It now shows the recovered
  diagram and keeps its own copy in History.
- An empty record of what the model has seen (after load_diagram or a
  page tool on unseen changes) no longer lets one page of a multi-page
  document count for all, and get_diagram counts a page only once found.
- History: a thumbnail goes only to the entry it shows, the cached image
  never belongs to an older diagram, a re-serialized copy adds no entry,
  and a cleared document with its own pages is kept before a restore.
- The root cell id check reads attributes one by one: rack-id="1" or id
  text inside a label no longer counts.
- A compressed page counts as having cells; a saved file that could not
  be read is never written over.

* fix(server): keep users' keys at their own endpoints, and more fixes from the third review

- Bedrock: a user's AWS keys no longer go to an endpoint the server sets
  in AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL (read by the
  upgraded SDK), and admin panel keys win over AWS_BEARER_TOKEN_BEDROCK,
  as the Test button checks them. Checked with Bedrock.
- Ollama: a server key without a base URL (admin panel, OLLAMA_API_KEY)
  goes to Ollama Cloud, as env.example says, instead of 127.0.0.1.
- Quota: EdgeOne counts whatever key header comes along, keyless Ollama at
  a private address counts, and their provider texts stay in the log.
- An EdgeOne server model (admin panel, ai-models.json) works: the route
  checked the raw provider header, which holds the name's slug.
- parse-url ends downloads it does not read (too large, PDF, errors).
- Desktop app: the port follows where the chats are (IndexedDB per
  origin) instead of a remembered port, which could hide them for good;
  a same-port restart tells the page to refetch the server models; a
  failed preset switch no longer undoes a newer choice; a presets file
  removed after a failed read can be saved again; .env values quoted from
  start to end keep their inner quotes, as dotenv reads them.

* fix(security): check request sources, regions and endpoints

- Bedrock: a request's AWS region must be a region name. It becomes part
  of the endpoint's host name, so a value such as
  "us-east-1.attacker.example/" sent the server's bearer token or signed
  request to another host.
- MCP preview server: only the preview page itself (Origin equal to the
  Host) or a non-browser client may call it; a page on another localhost
  port could replace the diagram with a plain text POST. History builds
  its thumbnails element by element and shows only SVG data images, so a
  stored value can no longer run script in the preview.
- chat, validate-model, validate-diagram, provider-models and parse-url
  take JSON bodies only, so another website cannot make the user's own
  server (the desktop app, a local install) run models with their keys;
  the desktop app also refuses a foreign Host (DNS rebinding).
- The model list reads at most 2 MB, also through the Gateway SDK, and
  answers only with its own error texts: the URL is the caller's and may
  be an internal address.
- An admin panel provider with its own key and no URL no longer inherits
  the global <P>_BASE_URL, which may be a proxy for another key; OpenAI
  then gets the official endpoint, as its Test. Azure keeps the server's
  resource.

* fix: what the third round broke, and the first batch's review

MCP preview after the server lost a session (it expired, or the MCP
process restarted):
- Every server state has an id, made when the state is created. The tab
  notices a new id even when the version numbers happen to match, and
  every push names the state it was based on, so one based on a lost state
  is refused, also when it comes before the tab's first poll (the server
  recovers the saved file first).
- The tab keeps the newest canvas XML, saved or not. When the server knows
  nothing (no file) or exactly what the tab last saved, the canvas wins and
  is saved, so edits made while the server was down are kept. Otherwise
  the server's diagram (an AI write the tab missed, a cleared document
  that was saved) is shown and the tab's copy goes to History.
- Late answers to an old state's push or poll are dropped; a failed push
  says the server is unreachable; Download as .drawio saves the canvas.

Settings and server:
- Saved providers this version does not know stay in storage with their
  keys, and sending no longer trips over them.
- The desktop "Ollama (Local)" preset with a key goes to local Ollama
  again; a server model's Ollama URL variable is read; the admin panel
  writes Ollama Cloud's URL for a key without one.
- Provider error texts show again in the desktop app and for EdgeOne.
- .env: a quoted value followed by a comment ending in a quote is read as
  dotenv reads it; unquoted values are unchanged.
- Desktop app: the next launch opens the port where a chat was last
  saved; a launch elsewhere that saves nothing does not move it, and a
  page with no chats lets the next launch try the other port once.
- The Test button no longer stays busy after another tab changed the key.
- A completed append_diagram is no longer undone by an earlier failed
  edit's preview; a file read once in vain is saved again once it is read
  or gone.

From the first batch's review:
- The admin panel's Test of an entry without a URL now tests the server's
  <P>_BASE_URL, where chat sends the entry's key; chat is unchanged (the
  first fix rerouted working setups).
- The model list ends downloads that are too large, accepts answers
  without a body, and keeps the "redirects are not allowed" explanation.
- A test covers the preview's History rendering.

* fix: older defects (batch C) and the second batch's review

Chats:
- New Chat right after an answer saves that chat once. Saves run one at
  a time and read the chat on screen when their turn comes; a save
  scheduled for a chat that is no longer on screen is dropped. A chat
  whose id was still on its way to the URL no longer comes back after
  New Chat (the next answer went into it).
- Crossing the 768 px breakpoint keeps the chat panel: a streaming answer,
  unsaved messages and attachments stay. The panel gets the sizes of each
  side, and a panel collapsed on desktop opens on mobile.
- The chat's export waits for its own reply: an edit's history export
  still on its way no longer answers it with the older diagram, and two
  file saves at once no longer swap results.
- A second edit in one answer is previewed on the first edit's result.
- Stop also ends a running screenshot check; a chat that cannot be saved
  (storage full) can be left with "Continue without saving".
- Small diagrams with shapes count as diagrams; the tool card no longer
  crashes on malformed operations.

Quota and providers:
- Requests that reach the server's own endpoints count toward the quota:
  EdgeOne (always its own endpoint now), a private base URL whatever key
  header is sent, keyless Ollama without a URL. With the quota on, a
  redirect is followed only to a public address. The output cap applies
  to these requests too.
- Stop records the tokens of the steps that finished; the screenshot check
  counts its tokens without counting a request.
- EdgeOne configured only by AI_PROVIDER works, also in the admin Test,
  which forwards the access code. Azure set up only in the admin panel
  works in chat. The Test sends a Bedrock session token.
- The admin panel's Test of an entry without a URL uses the server's URL
  as the server does (no private address check for it); the admin panel
  no longer writes an Ollama URL.

MCP server:
- Write tools and start_session run one at a time, so two at once never
  drop each other's change; a cancelled call waiting its turn is skipped.
  get_diagram and export_diagram keep the session they started with.
- Export to .drawio first gets the user's latest edits from the browser.
- History thumbnails: one that arrives after the next AI write is
  dropped; a sync reply keeps the image; a version that changed only page
  settings is its own entry.
- A diagram over the 10 MB limit is saved without its image, or the user
  is told to download it (the server now answers 413 instead of cutting
  the connection).
- Labels holding text like id='1' or parent='1' are no longer read as
  attributes (a layer or a parent was deleted). A broken bare
  <mxGraphModel> file is refused.
- After a sync reply the tab no longer sends its autosave copy again.

Desktop and files:
- A newer switch of the same preset is not rolled back by an older one
  that failed. .env values with escaped quotes are read whole.
- MCP saved files: a file that could not be read stays protected while a
  folder without permission hides it, and is saved again once deleted.
- The desktop app reports "no chats" only when the count was read and no
  model settings are stored.

* fix: what the batch C review found

- A redirect followed for a custom base URL (quota on) no longer carries
  the user's key or cookies to another origin, as fetch itself does, and
  a private address may redirect to another private one (already counted).
- The admin Test of an Ollama or Vertex AI entry without a URL goes where
  chat sends that entry's key: the environment's own URL variable, for
  Ollama else the local default. The Test of an Ollama Cloud key without
  a URL went to the cloud while chat went to local Ollama.
- Chat saves: each save notes the chat on screen and the order of the
  reads before reading its data. A save read before switching chats no
  longer writes into the chat switched to, and a copy that waited for its
  thumbnail no longer replaces a newer one.
- "Continue without saving" keeps its button when a later auto-save fails,
  and goes away when a new message is sent.
- A screenshot check that was waiting for its image when the user pressed
  Stop stays skipped after the next message.
- MCP History: draw.io's own copy of a diagram (after get_diagram) no
  longer adds an entry without a picture; a change of background is still
  its own version. The tab ignores an edit's answer that arrives after a
  newer AI write loaded.
- Desktop: a deleted preset is not brought back by a failed switch, and a
  request naming no preset does not stop a rollback. An origin keeping
  an access code counts as having settings.
- .env: a quoted value ending in a backslash ("C:\dir\") is read as dotenv
  reads it.
- The tool card no longer crashes on an id that does not turn into text;
  an older Test's success timer no longer ends a newer Test's spinner.
- Tests that passed without their fix now check it.

* fix: what the whole-PR review and Copilot found

- A redirect followed for a custom base URL also drops the key headers of
  providers that do not use Authorization (x-api-key, x-goog-api-key,
  api-key) when it goes to another origin.
- A second Enter or click while a message is being prepared (attachments
  read, diagram exported) no longer sends it twice.
- The admin Test on the deployment's own endpoints (EdgeOne, the server's
  keyless Ollama, an address on the server's network) counts toward the
  quota like a chat; the chat and the Test share one rule for it. The
  Test of an Azure entry set up by AZURE_RESOURCE_NAME only goes where chat
  goes.
- EdgeOne's function is called at the site root again, as on main: EdgeOne
  serves edge functions there, outside Next's base path.
- MCP History: the state before a write is kept unless the browser saved
  no change of the user's since the last server write (draw.io's sync copy
  of it adds no entry), and the dedupe compares the exact text again, so a
  change of page size or other settings only is its own version.
- MCP: an edit keeps untouched labels as draw.io shows them (a literal line
  break in an attribute is a space); a new document of empty pages the
  user named is auto-saved; load_diagram reads only regular files, so a
  pipe cannot hold up the other write tools; the preview does not load
  back its own push still on its way (an undo made meanwhile is saved).
- Two overlapping saves of a new chat no longer reload the canvas from the
  older copy.
- At most three screenshot checks per user turn, passed or failed, as
  documented.
- Desktop: the main window navigates only within the app (draw.io stays in
  its frame); a presets file that is not JSON and cannot be moved aside is
  not overwritten.
- A last self-closing cell with a raw "<" in a value is not taken for cut
  off output.
- README: Material Design shapes load their icons from fonts.gstatic.com.

* fix(chat): stop saving and exporting an idle chat every second

Each auto-save takes a thumbnail, and draw.io's SVG gets a new random id
every time, so latestSvg changed and the diagram context rendered again.
getThumbnailSvg was a new function on every render, the auto-save depends
on it, so the next save was scheduled a second later: a chat with some
diagrams was saved and exported once a second for as long as it was open.
getThumbnailSvg (and the export helper it uses) now keep one identity;
they read refs only.

Found by hand in the production build; the new e2e test fails without the
fix on a production build (as CI runs it).

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Dayuan Jiang
2026-10-05 22:39:32 +09:00
committed by GitHub
co-authored by github-actions[bot]
parent a45e5b6796
commit 0b63e28e5a
192 changed files with 20781 additions and 6839 deletions
+70 -39
View File
@@ -11,7 +11,9 @@ import {
} from "lucide-react"
import type React from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
@@ -41,9 +43,20 @@ import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
function isValidFileType(file: File): boolean {
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
@@ -164,7 +177,7 @@ interface ChatInputProps {
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: (data: Map<string, UrlData>) => void
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
sessionId?: string
error?: Error | null
@@ -244,6 +257,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Block sending until attached files and URLs have their text, otherwise
// their content would be silently dropped
const isExtractingAttachments =
files.some((file) => pdfData.get(file)?.isExtracting) ||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
@@ -281,6 +299,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
@@ -292,7 +313,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit()
}
}
@@ -380,13 +406,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
// Let validateFiles show a toast for unsupported types
const { validFiles, errors } = validateFiles(
supportedFiles,
Array.from(e.dataTransfer.files),
files.length,
dict,
)
@@ -401,33 +423,34 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
// Skip if the URL was removed while extracting
onUrlChange((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
@@ -463,11 +486,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
: undefined
}
/>
@@ -559,7 +583,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
@@ -588,7 +612,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
) : (
<Button
type="submit"
disabled={isDisabled || !input.trim()}
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
@@ -614,9 +642,8 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
{onUrlChange && (
<UrlInputDialog
@@ -629,7 +656,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => setShowSaveAsTemplate(false)}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()}
/>
</form>
+188 -225
View File
@@ -26,6 +26,7 @@ import {
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { ChatLobby } from "@/components/chat/ChatLobby"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
@@ -37,12 +38,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import {
applyDiagramOperations,
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
@@ -129,12 +130,14 @@ const getMessageTextContent = (message: UIMessage): string => {
.join("\n")
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
// Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message)
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
}
interface SessionMetadata {
@@ -146,6 +149,8 @@ interface SessionMetadata {
interface ChatMessageDisplayProps {
messages: UIMessage[]
// Shown on an error that a model setting can fix (bad key, unknown model)
onOpenModelConfig?: () => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>>
@@ -169,6 +174,7 @@ interface ChatMessageDisplayProps {
export function ChatMessageDisplay({
messages,
onOpenModelConfig,
setInput,
setFiles,
processedToolCallsRef,
@@ -188,7 +194,24 @@ export function ChatMessageDisplay({
currentInput = "",
}: ChatMessageDisplayProps) {
const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
// 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, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
@@ -208,20 +231,6 @@ export function ChatMessageDisplay({
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
}
}, [messages.length, processedToolCalls])
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
@@ -343,73 +352,30 @@ export function ChatMessageDisplay({
}
}
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string, showToast = false) => {
let currentXml = xml || ""
(xml: string) => {
const completeCells = extractCompleteMxCells(xml || "")
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
// During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML
if (!showToast) {
const completeCells = extractCompleteMxCells(currentXml)
if (!completeCells) {
return
}
currentXml = completeCells
}
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser()
// Wrap in root element for parsing multiple mxCell elements
const testDoc = parser.parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
const parseError = testDoc.querySelector("parsererror")
if (parseError) {
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.malformedXml)
}
return // Skip this update
}
try {
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
const baseXML =
chartXML ||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml)
// During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true)
if (!showToast) {
previousXML.current = convertedXml
onDisplayChart(replacedXML, true)
return
}
// Final output: run full validation and auto-fix
const validation = validateAndFixXml(replacedXML)
if (validation.valid) {
previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML
onDisplayChart(xmlToLoad, true)
} else {
toast.error(dict.errors.validationFailed)
}
} catch (error) {
console.error("Error processing XML:", error)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.failedToProcess)
}
}
try {
// An empty canvas gets a default mxfile to put the cells in
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(baseXML, convertedXml)
previousXML.current = convertedXml
onDisplayChart(replacedXML, true)
} catch (error) {
console.error("Error processing XML:", error)
}
},
[chartXML, onDisplayChart],
@@ -456,8 +422,19 @@ 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, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
messagesToProcess.forEach((message) => {
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
const isRestoredMessage =
loadedMessageIdsRef?.current.has(message.id) ?? false
if (message.parts) {
message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) {
@@ -475,76 +452,87 @@ export function ChatMessageDisplay({
})
}
if (isRestoredMessage) return
if (
part.type === "tool-display_diagram" &&
input?.xml
part.type !== "tool-display_diagram" &&
part.type !== "tool-edit_diagram"
) {
const xml = input.xml as string
return
}
// 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 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
}
// Input complete, or the tool handler, a stop or an
// error took the call already: the tool handler loads
// the checked diagram (with the original XML). The
// messages update at most every 150 ms (useChat
// throttle in chat-panel), so they can still show the
// call streaming after that.
if (
state !== "input-streaming" ||
processedToolCalls.current.has(toolCallId)
) {
processedToolCalls.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(
`${toolCallId}-opCount`,
)
return
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing
const lastXml =
lastProcessedXmlRef.current.get(toolCallId)
if (lastXml === xml) {
return // Skip redundant processing
}
if (
state === "input-streaming" ||
state === "input-available"
!xml ||
lastProcessedXmlRef.current.get(toolCallId) ===
xml
) {
// 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,
)
}
} 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)
processedToolCalls.current.add(toolCallId)
// Clean up the ref entry - tool is complete, no longer needed
lastProcessedXmlRef.current.delete(toolCallId)
return
}
// Keep the diagram from before the preview, to
// undo it on a stop or an error
if (
!editDiagramOriginalXmlRef.current.has(
toolCallId,
)
) {
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml || BLANK_MXFILE,
)
}
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
return
}
// 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") {
if (!input?.operations) return
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref)
@@ -553,7 +541,7 @@ export function ChatMessageDisplay({
toolCallId,
)
) {
if (!chartXML) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
@@ -561,10 +549,9 @@ export function ChatMessageDisplay({
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
chartXML,
baseXml,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
@@ -572,95 +559,37 @@ 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])
}, [messages, handleDisplayChart, chartXMLRef])
return (
<ScrollArea className="h-full w-full scrollbar-thin">
@@ -845,7 +774,11 @@ export function ChatMessageDisplay({
!isRestoredMessage
}
>
<ReasoningTrigger />
<ReasoningTrigger
getThinkingMessage={
thinkingMessage
}
/>
<ReasoningContent>
{
reasoningPart.text
@@ -951,8 +884,12 @@ export function ChatMessageDisplay({
part.type?.startsWith(
"tool-",
)
// Blank text (some models send
// a lone space) gets no bubble
const isContentPart =
part.type === "text" ||
(part.type === "text" &&
part.text.trim() !==
"") ||
part.type === "file"
if (isToolPart) {
@@ -1302,6 +1239,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>
)
},
+357 -201
View File
@@ -1,7 +1,7 @@
"use client"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import { DefaultChatTransport, isToolUIPart, type UIMessage } from "ai"
import {
MessageSquarePlus,
PanelRightClose,
@@ -41,8 +41,13 @@ import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
import type { ValidationState } from "./chat/ValidationCard"
import { ChatMessageDisplay } from "./chat-message-display"
import {
APPENDED_FILE_SECTIONS_PATTERN,
ChatMessageDisplay,
} from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence
@@ -51,21 +56,6 @@ const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
// sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
// Type for message parts (tool calls and their states)
interface MessagePart {
type: string
state?: string
toolName?: string
input?: { xml?: string; [key: string]: unknown }
[key: string]: unknown
}
interface ChatMessage {
role: string
parts?: MessagePart[]
[key: string]: unknown
}
interface ChatPanelProps {
isVisible: boolean
onToggleVisibility: () => void
@@ -88,25 +78,25 @@ const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retr
* Check if auto-resubmit should happen based on tool errors.
* Only checks the LAST tool part (most recent tool call), not all tool parts.
*/
function hasToolErrors(messages: ChatMessage[]): boolean {
function hasToolErrors(messages: UIMessage[]): boolean {
const lastMessage = messages[messages.length - 1]
if (!lastMessage || lastMessage.role !== "assistant") {
return false
}
const toolParts =
(lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>
part.type?.startsWith("tool-"),
) || []
if (toolParts.length === 0) {
return false
}
const lastToolPart = toolParts[toolParts.length - 1]
if (lastMessage?.role !== "assistant") return false
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
return lastToolPart?.state === TOOL_ERROR_STATE
}
/**
* Snapshots keep the full multi-page document, but the model only sees and
* edits the first page, so give it the first page's mxGraphModel.
* Older snapshots already hold a single mxGraphModel and are returned as is.
*/
function getFirstPageXml(xml: string): string {
if (!xml.includes("<mxfile")) return xml
const doc = new DOMParser().parseFromString(xml, "text/xml")
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
}
export default function ChatPanel({
isVisible,
onToggleVisibility,
@@ -120,8 +110,9 @@ export default function ChatPanel({
loadDiagram: onDisplayChart,
handleExport: onExport,
handleExportWithoutHistory,
resolverRef,
exportResolversRef,
chartXML,
chartXMLRef: liveChartXMLRef,
latestSvg,
clearDiagram,
getThumbnailSvg,
@@ -137,21 +128,15 @@ export default function ChatPanel({
const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => {
// Waits for the reply to its own export, by its tag
const tag = saveToHistory ? onExport() : handleExportWithoutHistory()
return Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
if (tag) exportResolversRef.current[tag] = resolve
}),
new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => {
if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
delete exportResolversRef.current[tag]
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
@@ -290,8 +275,16 @@ export default function ChatPanel({
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
const processedToolCallsRef = useRef<Set<string>>(new Set())
// Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
// Key: toolCallId, Value: original XML before any operations applied
// Set by Stop until the user sends the next message
const stoppedRef = useRef(false)
const preparingSendRef = useRef(false)
// Presses of Stop: a check that began before one still knows of it after
// the next message clears stoppedRef
const stopCountRef = useRef(0)
// Store original XML for display_diagram and edit_diagram streaming -
// shared between streaming preview and tool handler
// Key: toolCallId, Value: XML before the call's preview was drawn
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
// Debounce timeout for localStorage writes (prevents blocking during streaming)
@@ -336,28 +329,21 @@ export default function ChatPanel({
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
}, [])
// Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null)
// Callback to improve diagram with validation suggestions
const handleImproveWithSuggestions = useCallback((feedback: string) => {
if (sendMessageRef.current) {
// Send the feedback as a new user message to trigger regeneration
sendMessageRef.current({
role: "user",
parts: [{ type: "text", text: feedback }],
})
}
}, [])
// Failed VLM validations in the current user turn (reset on user action)
const validationRetryCountRef = useRef(0)
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
const { validateWithFallback, cancel: cancelValidation } =
useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
chartXMLRef,
processedToolCallsRef,
validationRetryCountRef,
// A preview undone just before the tool call is in this one already
chartXMLRef: liveChartXMLRef,
onDisplayChart,
onFetchChart,
onExport,
@@ -365,6 +351,11 @@ export default function ChatPanel({
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
watchStop: () => {
const stopsBefore = stopCountRef.current
return () =>
stoppedRef.current || stopCountRef.current !== stopsBefore
},
onValidationStateChange: handleValidationStateChange,
})
@@ -383,96 +374,104 @@ export default function ChatPanel({
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput)
},
onFinish: ({ message, isAbort, isError }) => {
// Stopped or failed: tool calls still streaming never reach the
// tool handler. Mark them handled so a later render of the
// stream does not draw their preview again.
if (!isAbort && !isError) return
for (const part of message.parts as any[]) {
if (part.state === "input-streaming" && part.toolCallId) {
processedToolCallsRef.current.add(part.toolCallId)
}
}
},
onError: (error) => {
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
// A diagram still streaming when the request failed never
// reaches the tool handler: undo its preview. Only previews not
// handled yet are stored, and the first one holds the diagram
// before any of them.
const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true)
editDiagramOriginalXmlRef.current.clear()
// Server errors are JSON: a quota limit ({type: request, token or
// tpm}), a provider error ({type: "provider", code, message}) or
// {error}. The SDK puts the response body in error.message.
let data: any = null
try {
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
data = JSON.parse(error.message)
} catch {
// Not JSON, fall through to string matching for backwards compatibility
// Plain text, e.g. a network failure in the browser
}
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
if (data?.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
if (data?.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
if (data?.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
const isAccessCodeError = String(
data?.error ?? error.message,
).includes("Invalid or missing access code")
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
if (!isAccessCodeError) console.error("Chat error:", error)
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url")
) {
friendlyMessage = "This model doesn't support image input."
// A hint the user can act on, then the provider's own words
let text: string = error.message
let openModelConfig = false
if (data?.type === "provider") {
const hints = dict.errors.llm as Record<string, string>
text = hints[data.code]
? `${hints[data.code]}\n\n${data.message}`
: data.message
openModelConfig = [
"invalid_api_key",
"forbidden",
"model_not_found",
].includes(data.code)
} else if (typeof data?.error === "string") {
text = data.error
} else if (error.message === "Failed to fetch") {
text = dict.errors.networkError
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => {
const errorMessage = {
setMessages((currentMessages) => [
...currentMessages,
{
id: `error-${Date.now()}`,
role: "system" as const,
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
}
return [...currentMessages, errorMessage]
})
content: text,
parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings
...(openModelConfig && {
metadata: { openModelConfig: true },
}),
},
])
if (error.message.includes("Invalid or missing access code")) {
if (isAccessCodeError) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
onFinish: () => {},
// Re-render streamed messages at most every 150 ms. The streaming
// diagram preview draws on each update, so this also limits redraws
experimental_throttle: 150,
sendAutomaticallyWhen: ({ messages }) => {
// The user stopped: a tool result that arrives later (a VLM
// check still running) must not start a new request
if (stoppedRef.current) return false
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
const shouldRetry = hasToolErrors(messages)
if (!shouldRetry) {
// No error, reset retry count and clear state
@@ -518,11 +517,6 @@ export default function ChatPanel({
},
})
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => {
sendMessageRef.current = sendMessage
}, [sendMessage])
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
useEffect(() => {
@@ -531,6 +525,10 @@ export default function ChatPanel({
// Track last synced session ID to detect external changes (e.g., URL back/forward)
const lastSyncedSessionIdRef = useRef<string | null>(null)
// Message arrays of our own saves. A session holding one of them was
// created by our own save, so it must not be treated as an external
// switch (with two saves of a new chat at once, the first creates it).
const savedMessagesRef = useRef(new WeakSet<object>())
// Helper: Sync UI state with session data (eliminates duplication)
// Track message IDs that are being loaded from session (to skip animations/scroll)
@@ -597,8 +595,10 @@ export default function ChatPanel({
thumbnailDataUrl = latestSvgRef.current
}
}
const messages = sanitizeMessages(messagesRef.current)
savedMessagesRef.current.add(messages)
return {
messages: sanitizeMessages(messagesRef.current),
messages,
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
diagramXml: currentDiagramXml,
thumbnailDataUrl,
@@ -651,8 +651,13 @@ export default function ChatPanel({
// Skip if session ID hasn't changed (our own saves don't change the ID)
if (newSessionId === lastSyncedSessionIdRef.current) return
// Our own save created this session; the UI already shows its content
const isOwnNewSession =
!!newSession && savedMessagesRef.current.has(newSession.messages)
// Update last synced ID
lastSyncedSessionIdRef.current = newSessionId
if (isOwnNewSession) return
// Sync UI with new session
if (newSession) {
@@ -675,6 +680,8 @@ export default function ChatPanel({
isAvailable: sessionIsAvailable,
currentSessionId,
saveCurrentSession,
getChatGeneration,
getSaveTicket,
} = sessionManager
// Use ref for saveCurrentSession to avoid infinite loop
@@ -699,26 +706,30 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current)
}
// Capture current session ID at schedule time to verify at save time
const scheduledForSessionId = currentSessionId
// Capture the chat on screen at schedule time; the save is dropped
// if another chat is on screen by the time it runs
const scheduledForChat = getChatGeneration()
// Capture whether there's a REAL diagram NOW (not just empty template)
const hasDiagramNow = isRealDiagram(chartXMLRef.current)
// Check if this session was just loaded without a diagram
const isNodiagramSession =
justLoadedSessionIdRef.current === scheduledForSessionId
justLoadedSessionIdRef.current === currentSessionId
// Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => {
try {
if (messages.length > 0 || hasDiagramNow) {
// Taken before the data is read, for the chat it was
// scheduled for
const ticket = {
...getSaveTicket(),
generation: scheduledForChat,
}
const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession,
})
await saveCurrentSessionRef.current(
sessionData,
scheduledForSessionId,
)
await saveCurrentSessionRef.current(sessionData, ticket)
}
} catch (error) {
console.error("Failed to save session:", error)
@@ -737,6 +748,8 @@ export default function ChatPanel({
status,
sessionIsAvailable,
currentSessionId,
getChatGeneration,
getSaveTicket,
buildSessionData,
])
@@ -769,10 +782,11 @@ export default function ChatPanel({
try {
// Attempt to save session - browser may not wait for completion
// Skip thumbnail capture as it may not complete in time
const ticket = sessionManager.getSaveTicket()
const sessionData = await buildSessionData({
withThumbnail: false,
})
await sessionManager.saveCurrentSession(sessionData)
await sessionManager.saveCurrentSession(sessionData, ticket)
} catch (error) {
console.error(
"Failed to save session on visibility change:",
@@ -790,19 +804,27 @@ export default function ChatPanel({
)
}, [sessionManager, buildSessionData])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const submitInput = async () => {
const isProcessing = status === "streaming" || status === "submitted"
if (input.trim() && !isProcessing) {
// Check if input matches a cached example (only when no messages yet)
if (messages.length === 0) {
// Attachments still extracting have no text yet. Template sends call
// requestSubmit() and skip the disabled send button, so check here too.
const isExtracting =
files.some((f) => pdfData.get(f)?.isExtracting) ||
Array.from(urlData.values()).some((d) => d.isExtracting)
if (input.trim() && !isProcessing && !isExtracting) {
// Check if input matches a cached example (only when no messages
// yet and the canvas is empty, same rule as the server)
if (messages.length === 0 && !hasCells(chartXMLRef.current || "")) {
// Pass the file name so a user's own file never matches an example
const cached = findCachedResponse(
input.trim(),
files.length > 0,
files.length === 1 ? files[0].name : undefined,
)
if (cached) {
// Add user message and fake assistant response to messages
// The chat-message-display useEffect will handle displaying the diagram
// Add the user message and a finished display_diagram
// answer, and load its diagram here: these messages never
// reach the tool handler
const toolCallId = `cached-${Date.now()}`
// Build user message text including any file content
@@ -834,6 +856,16 @@ export default function ChatPanel({
],
},
] as any)
// Snapshot the canvas before the example so editing this message works
xmlSnapshotsRef.current.set(
0,
chartXMLRef.current || BLANK_MXFILE,
)
const prepared = prepareNewDiagram(cached.xml, {
pageId: "page-1",
pageName: "Page-1",
})
if (prepared.ok) onDisplayChart(prepared.xml, true)
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
@@ -843,9 +875,6 @@ export default function ChatPanel({
}
try {
let chartXml = await onFetchChart()
chartXml = formatXML(chartXml)
// Build user text by concatenating input with pre-extracted text
// (Backend only reads first text part, so we must combine them)
const parts: any[] = []
@@ -860,48 +889,102 @@ 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]) || ""
: ""
// 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())
await sendWithCurrentDiagram(parts, () => {
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)
}
}
}
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// While a send is prepared (attachments read, diagram exported) the
// status is still "ready": a second Enter or click would send the
// message again
if (preparingSendRef.current) return
preparingSendRef.current = true
try {
await submitInput()
} finally {
preparingSendRef.current = false
}
}
// Export the current diagram, snapshot it for this message, and send.
// onSent runs right after sending, so the input empties as the message
// shows in the chat
const sendWithCurrentDiagram = async (
parts: any[],
onSent?: () => void,
) => {
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)
onSent?.()
}
// 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)
}
}
// The current chat could not be saved (storage full). The list where
// old chats can be deleted shows only in an empty chat, so let the user
// go on without saving. It replaces the plain message, and has its own
// id so a later failed auto-save does not take its button away.
const offerToContinueUnsaved = useCallback(
(proceed: () => void) => {
toast.dismiss("session-save-failed")
toast.error(dict.errors.sessionSaveFailedLeave, {
id: "session-save-leave",
duration: 15000,
action: {
label: dict.errors.continueWithoutSaving,
onClick: proceed,
},
})
},
[dict],
)
// A new turn makes the offer stale: going on would clear the chat while
// the answer streams in
useEffect(() => {
if (status === "submitted" || status === "streaming") {
toast.dismiss("session-save-leave")
}
}, [status])
// Handle session switching from history dropdown
const handleSelectSession = useCallback(
async (sessionId: string) => {
if (!sessionManager.isAvailable) return
// Save current session before switching
if (messages.length > 0) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
await sessionManager.saveCurrentSession(sessionData)
}
// Switch to selected session
const sessionData = await sessionManager.switchSession(sessionId)
if (sessionData) {
const open = async () => {
const sessionData =
await sessionManager.switchSession(sessionId)
if (!sessionData) return
const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
justLoadedSessionRef.current = true
@@ -919,8 +1002,37 @@ export default function ChatPanel({
syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false })
}
// Save current session before switching (also a diagram drawn
// without messages); if that failed (storage full), stay on it
// unless the user goes on without saving it
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
// Of the chat on screen now, also if another one comes on
// screen while the thumbnail is taken
const ticket = sessionManager.getSaveTicket()
const sessionData = await buildSessionData({
withThumbnail: true,
})
if (
!(await sessionManager.saveCurrentSession(
sessionData,
ticket,
))
) {
offerToContinueUnsaved(open)
return
}
}
await open()
},
[sessionManager, messages, buildSessionData, syncUIWithSession, router],
[
sessionManager,
messages,
buildSessionData,
syncUIWithSession,
router,
offerToContinueUnsaved,
],
)
// Handle session deletion from history dropdown
@@ -938,15 +1050,7 @@ export default function ChatPanel({
[sessionManager, syncUIWithSession, router, pathname],
)
const handleNewChat = useCallback(async () => {
// Save current session before creating new one
if (sessionManager.isAvailable && messages.length > 0) {
const sessionData = await buildSessionData({ withThumbnail: true })
await sessionManager.saveCurrentSession(sessionData)
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
const startNewChat = useCallback(() => {
// Clear session manager state BEFORE clearing URL to prevent race condition
// (otherwise the URL update effect would restore the old session URL)
sessionManager.clearCurrentSession()
@@ -978,21 +1082,47 @@ export default function ChatPanel({
setMessages,
setSessionId,
sessionManager,
messages,
router,
dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory,
pathname,
])
const handleNewChat = useCallback(async () => {
// Save current session before creating new one (also a diagram
// drawn without messages)
if (
sessionManager.isAvailable &&
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
) {
const ticket = sessionManager.getSaveTicket()
const sessionData = await buildSessionData({ withThumbnail: true })
// Not saved (storage full): keep the chat on screen, unless the
// user goes on without saving it
if (
!(await sessionManager.saveCurrentSession(sessionData, ticket))
) {
offerToContinueUnsaved(startNewChat)
return
}
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
startNewChat()
}, [
sessionManager,
messages,
buildSessionData,
offerToContinueUnsaved,
startNewChat,
])
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
// Keep attachments: they are sent along with the template prompt
flushSync(() => {
setInput(template.prompt)
setFiles([])
setUrlData(new Map())
})
const formElement = document.getElementById(
@@ -1002,7 +1132,7 @@ export default function ChatPanel({
formElement.requestSubmit()
}
},
[setInput, setFiles, setUrlData],
[setInput],
)
const handleInputChange = (
@@ -1017,13 +1147,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]) || "",
)
: ""
}
@@ -1044,11 +1176,19 @@ export default function ChatPanel({
// Handle stop button click
const handleStop = useCallback(() => {
stoppedRef.current = true
stopCountRef.current++
// A running screenshot check holds up the chat (the SDK waits for
// the tool handler): end it, so the call gets its result now
cancelValidation()
const lastMessage = messages[messages.length - 1]
// Calls the tool handler already took can still show as streaming:
// the messages update at most every 150 ms (useChat throttle)
const toolParts = lastMessage?.parts?.filter(
(part: any) =>
part.type?.startsWith("tool-") &&
part.state === "input-streaming",
part.state === "input-streaming" &&
!processedToolCallsRef.current.has(part.toolCallId),
)
toolParts?.forEach((part: any) => {
@@ -1063,7 +1203,7 @@ export default function ChatPanel({
})
stop()
}, [messages, addToolOutput, stop])
}, [messages, addToolOutput, stop, cancelValidation])
// Send chat message with headers
const sendChatMessage = (
@@ -1075,7 +1215,9 @@ export default function ChatPanel({
// Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
validationRetryCountRef.current = 0
partialXmlRef.current = ""
stoppedRef.current = false
const config = getSelectedAIConfig()
@@ -1223,7 +1365,12 @@ export default function ChatPanel({
})
// Now send the message after state is guaranteed to be updated
sendChatMessage(userParts, savedXml, previousXml, sessionId)
sendChatMessage(
userParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
}
const handleEditMessage = async (messageIndex: number, newText: string) => {
@@ -1250,10 +1397,13 @@ export default function ChatPanel({
// Clean up snapshots for messages after the user message (they will be removed)
cleanupSnapshotsAfter(messageIndex)
// Create new parts with updated text
// Create new parts with updated text. The edit box only shows the typed
// text, so keep the appended PDF/file/URL content
const newParts = message.parts?.map((part: any) => {
if (part.type === "text") {
return { ...part, text: newText }
const appended =
part.text.match(APPENDED_FILE_SECTIONS_PATTERN)?.[0] ?? ""
return { ...part, text: newText + appended }
}
return part
}) || [{ type: "text", text: newText }]
@@ -1266,7 +1416,12 @@ export default function ChatPanel({
})
// Now send the edited message after state is guaranteed to be updated
sendChatMessage(newParts, savedXml, previousXml, sessionId)
sendChatMessage(
newParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
}
// Collapsed view (desktop only)
@@ -1395,6 +1550,7 @@ export default function ChatPanel({
{/* Messages */}
<main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay
onOpenModelConfig={() => setShowModelConfigDialog(true)}
messages={messages}
setInput={setInput}
setFiles={handleFileChange}
+2
View File
@@ -194,6 +194,8 @@ export function ChatLobby({
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
+3
View File
@@ -55,6 +55,9 @@ export function TemplateCreateDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
e.stopPropagation()
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
+6 -3
View File
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Populate form when template changes
// Populate form each time the dialog opens, dropping any cancelled edits
useEffect(() => {
if (template) {
if (open && template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [template])
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
@@ -59,6 +59,9 @@ export function TemplateEditDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// any form this dialog may be rendered in
e.stopPropagation()
if (!template) return
+44 -19
View File
@@ -110,6 +110,10 @@ export function TemplatePanel({
useEffect(() => {
loadTemplates()
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates])
const handleCreateSuccess = () => {
@@ -220,7 +224,8 @@ export function TemplatePanel({
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
// Local date as YYYY-MM-DD (toISOString would give UTC)
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -302,6 +307,28 @@ export function TemplatePanel({
}
}
// Shared by the empty state and the list, so import works in both
const importInput = (
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
)
const importMessageBox = importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)
// Empty state: no templates at all
if (!loading && templates.length === 0) {
return (
@@ -332,6 +359,18 @@ export function TemplatePanel({
<Plus className="w-4 h-4" />
{dict.templates.createFirst}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
{importMessageBox && (
<div className="mt-3">{importMessageBox}</div>
)}
<TemplateCreateDialog
open={createDialogOpen}
@@ -389,27 +428,11 @@ export function TemplatePanel({
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
{importInput}
</div>
{/* Import message */}
{importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)}
{importMessageBox}
<div className="space-y-2">
{loading
@@ -447,6 +470,8 @@ export function TemplatePanel({
handleTemplateClick(template)
}
onKeyDown={(e) => {
// Ignore keys bubbling up from the action buttons
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
+41 -39
View File
@@ -20,11 +20,17 @@ interface ToolCallCardProps {
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
// Streamed or invalid input can hold anything: show only what React can
// render (an object in place of a string would crash the whole chat)
const shown = operations.filter(
(op) => typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) => (typeof value === "string" ? value : "")
return (
<div className="space-y-3">
{operations.map((op, index) => (
{shown.map((op, index) => (
<div
key={`${op.operation}-${op.cell_id}-${index}`}
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {op.cell_id}
cell_id: {text(op.cell_id)}
</span>
</div>
{op.new_xml && (
{text(op.new_xml) && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{op.new_xml}
{text(op.new_xml)}
</pre>
</div>
)}
@@ -66,7 +72,7 @@ export function ToolCallCard({
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output } = part
const { state, input, output, errorText } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
@@ -92,6 +98,16 @@ export function ToolCallCard({
}
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves. Without an input the JSON was
// broken (the server repairs JSON cut short by the limit).
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof input?.xml === "string" &&
!isMxCellXmlComplete(input.xml)
const handleCopy = () => {
let textToCopy = ""
@@ -161,22 +177,15 @@ export function ToolCallCard({
</>
)}
{state === "output-error" &&
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
(isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
))}
{input && Object.keys(input).length > 0 && (
<button
type="button"
@@ -224,23 +233,16 @@ export function ToolCallCard({
) : null}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* AI SDK stores tool errors in errorText */}
{state === "output-error" && (errorText || output) && (
<div
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
: (errorText ?? output)}
</div>
)}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
+4 -5
View File
@@ -1,8 +1,6 @@
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation }
export interface ToolPartLike {
type: string
@@ -13,4 +11,5 @@ export interface ToolPartLike {
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
errorText?: string
}
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { wrapWithMxFile } from "@/lib/utils"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
// Dev XML presets for streaming simulator
const DEV_XML_PRESETS: Record<string, string> = {
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
})
// Display the final diagram
const fullXml = wrapWithMxFile(xml)
onDisplayChart(fullXml)
const prepared = prepareNewDiagram(xml)
if (prepared.ok) onDisplayChart(prepared.xml)
setIsSimulating(false)
}
+488 -200
View File
@@ -4,12 +4,12 @@ import {
AlertCircle,
Check,
ChevronRight,
Clock,
Eye,
EyeOff,
Key,
Loader2,
Plus,
RefreshCw,
Server,
Settings2,
Sparkles,
@@ -34,6 +34,13 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
@@ -43,6 +50,11 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Select,
@@ -56,7 +68,13 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import type { ListedModel } from "@/lib/provider-models"
import { STORAGE_KEYS } from "@/lib/storage"
import type {
ModelConfig,
ProviderConfig,
ProviderName,
} from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
@@ -125,22 +143,31 @@ export function ModelConfigDialog({
> | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState("")
const [validatingModelIndex, setValidatingModelIndex] = useState<
number | null
>(null)
// Models whose test is running (they are all tested at once)
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
() => new Set(),
)
const [duplicateError, setDuplicateError] = useState<string>("")
const [editError, setEditError] = useState<{
modelId: string
message: string
} | null>(null)
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
Partial<Record<ProviderName, string[]>>
// Model ID being typed; written to the config only when valid on blur
const [modelIdDraft, setModelIdDraft] = useState<{
id: string
value: string
} | null>(null)
// Models fetched from the provider, per provider config
const [fetchedModels, setFetchedModels] = useState<
Record<string, ListedModel[]>
>({})
const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
Partial<Record<ProviderName, boolean>>
>({})
const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
useState<ProviderName | null>(null)
const [fetchingModels, setFetchingModels] = useState(false)
const [fetchModelsError, setFetchModelsError] = useState("")
const [modelPickerOpen, setModelPickerOpen] = useState(false)
// models.dev data for hints, loaded with the dialog (it is ~180 KB)
const [getModelInfo, setGetModelInfo] = useState<
typeof import("@/lib/model-catalog").getModelInfo | null
>(null)
const {
config,
@@ -156,6 +183,35 @@ export function ModelConfigDialog({
const selectedProvider = config.providers.find(
(p) => p.id === selectedProviderId,
)
// For requests that finish after the user switched provider or edited
// a model id
const selectedProviderIdRef = useRef(selectedProviderId)
selectedProviderIdRef.current = selectedProviderId
const configRef = useRef(config)
configRef.current = config
// Number of the latest Test click: only that test may reset the busy
// state when its credentials changed meanwhile
const validationRunRef = useRef(0)
// A model list or test result belongs to the credentials it was asked
// with; they can change meanwhile, here or in another tab
const credentialsOf = (providerId: string) => {
const p = configRef.current.providers.find((x) => x.id === providerId)
return JSON.stringify([
p?.provider,
p?.apiKey,
p?.baseUrl,
p?.awsAccessKeyId,
p?.awsSecretAccessKey,
p?.awsRegion,
p?.awsSessionToken,
p?.vertexApiKey,
])
}
// Discard an unfinished model ID edit when the dialog closes
useEffect(() => {
if (!open) setModelIdDraft(null)
}, [open])
// Cleanup validation reset timeout on unmount
useEffect(() => {
@@ -167,73 +223,83 @@ export function ModelConfigDialog({
}, [])
useEffect(() => {
if (
!open ||
selectedProvider?.provider !== "aihubmix" ||
loadedSuggestedProviders.aihubmix
) {
return
}
if (!open || getModelInfo) return
import("@/lib/model-catalog").then((catalog) =>
setGetModelInfo(() => catalog.getModelInfo),
)
}, [open, getModelInfo])
let cancelled = false
setLoadingSuggestedProvider("aihubmix")
fetch(getApiEndpoint("/api/aihubmix-models"))
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load models: ${response.status}`)
}
return response.json()
})
.then((data: { models?: unknown }) => {
if (cancelled || !Array.isArray(data.models)) {
return
}
const models = data.models.filter(
(model): model is string => typeof model === "string",
)
if (models.length > 0) {
setDynamicSuggestedModels((current) => ({
...current,
aihubmix: models,
}))
}
})
.catch((error) => {
console.warn("Failed to load AIHubMix models:", error)
})
.finally(() => {
if (cancelled) {
return
}
setLoadedSuggestedProviders((current) => ({
const handleFetchModels = async () => {
if (!selectedProvider) return
const providerId = selectedProvider.id
const askedWith = credentialsOf(providerId)
setFetchingModels(true)
setFetchModelsError("")
try {
const response = await fetch(
getApiEndpoint("/api/provider-models"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl: selectedProvider.baseUrl,
}),
},
)
const data = await response.json().catch(() => ({}))
if (credentialsOf(providerId) !== askedWith) return
// The picker and the error belong to the provider shown
const stillShown = selectedProviderIdRef.current === providerId
if (Array.isArray(data.models)) {
setFetchedModels((current) => ({
...current,
aihubmix: true,
[providerId]: data.models,
}))
setLoadingSuggestedProvider(null)
})
return () => {
cancelled = true
if (stillShown) setModelPickerOpen(true)
} else if (stillShown) {
const hints = dict.errors.llm as Record<string, string>
setFetchModelsError(
[hints[data.code], data.error].filter(Boolean).join(" ") ||
`Request failed (${response.status})`,
)
}
} catch {
if (
selectedProviderIdRef.current === providerId &&
credentialsOf(providerId) === askedWith
) {
setFetchModelsError(dict.errors.networkError)
}
} finally {
setFetchingModels(false)
}
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
}
// Get suggested models for current provider
const suggestedModels = selectedProvider
? dynamicSuggestedModels[selectedProvider.provider] ||
SUGGESTED_MODELS[selectedProvider.provider] ||
[]
// The provider's own list once fetched, else the suggested models
const suggestedModels: ListedModel[] = selectedProvider
? fetchedModels[selectedProvider.id] ||
(SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({
id,
}))
: []
const isLoadingSuggestedModels =
selectedProvider?.provider === loadingSuggestedProvider
// Tool calls are what drawing needs: false when known to be missing
const supportsTools = (model: ListedModel) =>
selectedProvider
? (model.tools ??
getModelInfo?.(selectedProvider.provider, model.id)?.tools)
: undefined
// Filter out already-added models from suggestions
const existingModelIds =
selectedProvider?.models.map((m) => m.modelId) || []
const availableSuggestions = suggestedModels.filter(
(modelId) => !existingModelIds.includes(modelId),
(model) => !existingModelIds.includes(model.id),
)
const emptyStateSuggestions = selectedProvider
? (SUGGESTED_MODELS[selectedProvider.provider] || [])
@@ -246,6 +312,8 @@ export function ModelConfigDialog({
const newProvider = addProvider(providerType)
setSelectedProviderId(newProvider.id)
setValidationStatus("idle")
setFetchModelsError("")
setModelPickerOpen(false)
}
// Handle provider field updates
@@ -253,9 +321,9 @@ export function ModelConfigDialog({
field: keyof ProviderConfig,
value: string | boolean,
) => {
if (!selectedProviderId) return
updateProvider(selectedProviderId, { [field]: value })
// Reset validation when credentials change
if (!selectedProviderId || !selectedProvider) return
const updates: Partial<ProviderConfig> = { [field]: value }
// Reset validation of the provider and its models when credentials change
const credentialFields = [
"apiKey",
"baseUrl",
@@ -266,8 +334,19 @@ export function ModelConfigDialog({
]
if (credentialFields.includes(field)) {
setValidationStatus("idle")
updateProvider(selectedProviderId, { validated: false })
setValidatingModelIds(new Set())
setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest)
setFetchModelsError("")
updates.validated = false
updates.models = selectedProvider.models.map((m) => ({
...m,
validated: undefined,
validationError: undefined,
validationWarning: undefined,
responseTime: undefined,
}))
}
updateProvider(selectedProviderId, updates)
}
// Handle adding a model to current provider
@@ -337,77 +416,164 @@ export function ModelConfigDialog({
let allValid = true
let errorCount = 0
let idChanged = false
const askedWith = credentialsOf(selectedProviderId)
const run = ++validationRunRef.current
// Validate each model
for (let i = 0; i < selectedProvider.models.length; i++) {
const model = selectedProvider.models[i]
setValidatingModelIndex(i)
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}/api/edgeai`
: selectedProvider.baseUrl
try {
// For EdgeOne, construct baseUrl from current origin
const baseUrl = isEdgeOne
? `${window.location.origin}/api/edgeai`
: selectedProvider.baseUrl
const response = await fetch("/api/validate-model", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl,
modelId: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
})
const data = await response.json()
if (data.valid) {
updateModel(selectedProviderId, model.id, {
validated: true,
validationError: undefined,
// Test every model at once; each row updates when its answer arrives
setValidatingModelIds(new Set(selectedProvider.models.map((m) => m.id)))
await Promise.all(
selectedProvider.models.map(async (model) => {
let update: Partial<ModelConfig>
try {
const response = await fetch(
getApiEndpoint("/api/validate-model"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(
STORAGE_KEYS.accessCode,
) || "",
},
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl,
modelId: model.modelId,
// AWS Bedrock credentials
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey:
selectedProvider.awsSecretAccessKey,
awsRegion: selectedProvider.awsRegion,
// Temporary AWS credentials, as the chat sends
awsSessionToken:
selectedProvider.awsSessionToken,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
},
)
const data = await response.json().catch(() => ({}))
update = data.valid
? {
validated: true,
validationError: undefined,
validationWarning: data.warning,
responseTime: data.responseTime,
}
: {
validated: false,
// The hint for the error's kind, then the
// provider's own message
validationError:
[
(
dict.errors.llm as Record<
string,
string
>
)[data.code],
data.error,
]
.filter(Boolean)
.join(" ") ||
(response.ok
? "Validation failed"
: `Request failed (${response.status})`),
validationWarning: undefined,
}
} catch {
update = {
validated: false,
validationError: "Network error",
validationWarning: undefined,
}
}
// A newer test started: its own results and spinners count,
// whatever the credentials are now (they may have come back)
if (run !== validationRunRef.current) return
// Credentials changed during the test: drop the result. A
// change in another tab left the spinner on, so clear it
// (model ids are unique, whatever provider is shown).
if (credentialsOf(selectedProviderId) !== askedWith) {
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
} else {
return
}
// So did this model's id: the result is for the old one
const current = configRef.current.providers
.find((p) => p.id === selectedProviderId)
?.models.find((m) => m.id === model.id)
if (current?.modelId !== model.modelId) {
idChanged = true
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
return
}
if (update.validated === false) {
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
}
} catch {
allValid = false
errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: "Network error",
updateModel(selectedProviderId, model.id, update)
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
}),
)
if (run !== validationRunRef.current) return
if (credentialsOf(selectedProviderId) !== askedWith) {
// The status line is about the provider shown now
if (selectedProviderIdRef.current === selectedProviderId) {
setValidationStatus("idle")
}
return
}
setValidatingModelIndex(null)
if (allValid) {
setValidationStatus("success")
// A model whose id changed was not tested
if (allValid && !idChanged) {
updateProvider(selectedProviderId, { validated: true })
}
// The status line is about the provider shown now
if (selectedProviderIdRef.current !== selectedProviderId) return
if (idChanged) {
setValidationStatus("idle")
} else if (allValid) {
setValidationStatus("success")
// Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
}
validationResetTimeoutRef.current = setTimeout(() => {
setValidationStatus("idle")
validationResetTimeoutRef.current = null
if (run !== validationRunRef.current) return
setValidationStatus("idle")
}, 1500)
} else {
setValidationStatus("error")
setValidationError(`${errorCount} model(s) failed validation`)
}
}, [selectedProvider, selectedProviderId, updateProvider, updateModel])
}, [
selectedProvider,
selectedProviderId,
updateProvider,
updateModel,
dict,
])
// Get all available provider types
const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[]
@@ -563,6 +729,10 @@ export function ModelConfigDialog({
)
setValidationStatus("idle")
setShowApiKey(false)
// These belong to the
// provider shown before
setFetchModelsError("")
setModelPickerOpen(false)
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
@@ -615,7 +785,9 @@ export function ModelConfigDialog({
{/* Add Provider */}
<div className="p-3 border-t border-border-subtle">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
onValueChange={(v) =>
handleAddProvider(v as ProviderName)
}
@@ -836,57 +1008,136 @@ export function ModelConfigDialog({
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Select
onValueChange={(value) => {
if (value) {
handleAddModel(
value,
)
{PROVIDER_INFO[
selectedProvider.provider
].modelList && (
<Button
variant="outline"
size="sm"
className="h-8 rounded-lg"
onClick={
handleFetchModels
}
}}
disabled={
isLoadingSuggestedModels ||
availableSuggestions.length ===
0
}
>
<SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
{isLoadingSuggestedModels ? (
disabled={
fetchingModels
}
title={
dict.modelConfig
.fetchModels
}
aria-label={
dict.modelConfig
.fetchModels
}
>
{fetchingModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span className="text-xs">
{availableSuggestions.length ===
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
{/* modal: the dialog blocks the
wheel outside itself, and the
list is rendered outside it */}
<Popover
modal
open={modelPickerOpen}
onOpenChange={
setModelPickerOpen
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-28 h-8 rounded-lg text-xs"
disabled={
availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</span>
)}
</SelectTrigger>
<SelectContent className="max-h-72">
{availableSuggestions.map(
(modelId) => (
<SelectItem
key={
modelId
}
>
{availableSuggestions.length ===
0
? dict
.modelConfig
.allAdded
: dict
.modelConfig
.suggested}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80 p-0"
align="end"
>
<Command>
<CommandInput
placeholder={
dict
.modelConfig
.searchModels
}
/>
<CommandList className="max-h-72">
<CommandEmpty>
{
dict
.modelConfig
.noModelsFound
}
value={
modelId
}
className="font-mono text-xs"
>
{modelId}
</SelectItem>
),
)}
</SelectContent>
</Select>
</CommandEmpty>
{availableSuggestions.map(
(model) => (
<CommandItem
key={
model.id
}
value={
model.id
}
onSelect={() => {
handleAddModel(
model.id,
)
setModelPickerOpen(
false,
)
}}
className="font-mono text-xs"
>
<span className="truncate">
{
model.id
}
</span>
{supportsTools(
model,
) ===
false && (
<span className="ml-auto shrink-0 font-sans text-[10px] text-amber-600 dark:text-amber-400">
{
dict
.modelConfig
.noTools
}
</span>
)}
</CommandItem>
),
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
}
>
{fetchModelsError && (
<p className="mb-2 text-xs text-destructive">
{fetchModelsError}
</p>
)}
{/* Model List */}
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
{selectedProvider.models.length ===
@@ -940,7 +1191,7 @@ export function ModelConfigDialog({
) : (
<div className="divide-y divide-border-subtle">
{selectedProvider.models.map(
(model, index) => (
(model) => (
<div
key={model.id}
className={cn(
@@ -950,28 +1201,24 @@ export function ModelConfigDialog({
<div className="flex items-center gap-3 p-3 min-w-0">
{/* Status icon */}
<div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
{validatingModelIndex !==
null &&
index ===
validatingModelIndex ? (
{validatingModelIds.has(
model.id,
) ? (
// Currently validating
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
</div>
) : validatingModelIndex !==
null &&
index >
validatingModelIndex &&
model.validated ===
undefined ? (
// Queued
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
) : model.validated ===
true ? (
// Valid
<div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
// Valid, with the time the test took
<div
className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center"
title={
model.responseTime
? `${(model.responseTime / 1000).toFixed(1)} s`
: undefined
}
>
<Check className="h-4 w-4 text-success" />
</div>
) : model.validated ===
@@ -989,7 +1236,10 @@ export function ModelConfigDialog({
</div>
<Input
value={
model.modelId
modelIdDraft?.id ===
model.id
? modelIdDraft.value
: model.modelId
}
title={
model.modelId
@@ -1007,24 +1257,14 @@ export function ModelConfigDialog({
null,
)
}
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
setModelIdDraft(
{
id: model.id,
value: e
.target
.value,
},
)
}}
onKeyDown={(
e,
@@ -1041,6 +1281,10 @@ export function ModelConfigDialog({
) => {
const newModelId =
e.target.value.trim()
// Drop the draft; an invalid ID falls back to the saved one
setModelIdDraft(
null,
)
// Helper to show error with shake
const showError =
@@ -1135,6 +1379,28 @@ export function ModelConfigDialog({
setEditError(
null,
)
if (
selectedProviderId &&
newModelId !==
model.modelId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
newModelId,
validated:
undefined,
validationError:
undefined,
validationWarning:
undefined,
responseTime:
undefined,
},
)
}
}}
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
/>
@@ -1162,6 +1428,28 @@ export function ModelConfigDialog({
}
</p>
)}
{!model.validationWarning &&
getModelInfo?.(
selectedProvider.provider,
model.modelId,
)?.tools ===
false && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
{
dict
.modelConfig
.mayNotDraw
}
</p>
)}
{model.validated &&
model.validationWarning && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
{
model.validationWarning
}
</p>
)}
{/* Show edit error inline */}
{editError?.modelId ===
model.id && (
+12 -6
View File
@@ -264,9 +264,13 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
@@ -351,9 +355,11 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={
model.modelId
}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
+46 -12
View File
@@ -13,7 +13,12 @@ import {
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
import {
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
@@ -26,7 +31,7 @@ export type SecretField =
| "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens)
const AWS_REGIONS: Array<[string, string]> = [
export const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"],
["us-west-2", "Oregon"],
@@ -77,6 +82,7 @@ export function ProviderCredentialsFields({
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
@@ -208,17 +214,29 @@ export function ProviderCredentialsFields({
</>
) : (
<>
{/* API Key */}
{/* API Key, with a link to where the provider issues keys */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
@@ -238,12 +256,28 @@ export function ProviderCredentialsFields({
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(
e.target.value,
)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}