From 0b63e28e5a7018e8bb1b737e2782bc4e80a52969 Mon Sep 17 00:00:00 2001 From: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:39:32 +0900 Subject: [PATCH] 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 deleted ), fixes tag case, drops orphan 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/.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 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. 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 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 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 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

_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

_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 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/workflows/auto-format.yml | 2 +- .github/workflows/publish-mcp.yml | 4 + .github/workflows/test.yml | 4 + README.md | 7 + app/[lang]/admin/models-section.tsx | 22 +- app/[lang]/admin/page.tsx | 63 +- app/[lang]/admin/setting-field.tsx | 15 +- app/[lang]/page.tsx | 38 +- app/api/admin/providers/route.ts | 9 +- app/api/admin/test-model/route.ts | 22 +- app/api/aihubmix-models/route.ts | 61 - app/api/chat/route.ts | 622 +++--- app/api/log-save/route.ts | 2 +- app/api/parse-url/route.ts | 21 +- app/api/provider-models/route.ts | 79 + app/api/validate-diagram/route.ts | 86 +- app/api/validate-model/route.ts | 523 ++--- app/api/verify-access-code/route.ts | 28 +- biome.json | 7 +- components/chat-input.tsx | 109 +- components/chat-message-display.tsx | 413 ++-- components/chat-panel.tsx | 558 ++++-- components/chat/ChatLobby.tsx | 2 + components/chat/TemplateCreateDialog.tsx | 3 + components/chat/TemplateEditDialog.tsx | 9 +- components/chat/TemplatePanel.tsx | 63 +- components/chat/ToolCallCard.tsx | 80 +- components/chat/types.ts | 9 +- components/dev-xml-simulator.tsx | 6 +- components/model-config-dialog.tsx | 688 +++++-- components/model-selector.tsx | 18 +- components/provider-credentials-fields.tsx | 58 +- contexts/diagram-context.tsx | 379 ++-- docs/cn/README_CN.md | 7 + docs/cn/ai-providers.md | 4 +- docs/en/ai-providers.md | 4 +- docs/ja/README_JA.md | 7 + docs/ja/ai-providers.md | 4 +- docs/shape-libraries/README.md | 7 +- edge-functions/api/edgeai/chat/completions.ts | 68 +- electron/electron.d.ts | 13 +- electron/main/app-menu.ts | 96 +- electron/main/config-manager.ts | 223 ++- electron/main/env-loader.ts | 43 +- electron/main/index.ts | 70 +- electron/main/ipc-handlers.ts | 190 +- electron/main/next-server.ts | 111 +- electron/main/port-manager.ts | 97 +- electron/main/proxy-manager.ts | 23 +- electron/main/window-manager.ts | 42 +- electron/preload/index.ts | 18 +- electron/settings/settings.js | 13 +- env.example | 13 +- hooks/use-diagram-tool-handlers.ts | 213 ++- hooks/use-model-config.ts | 156 +- hooks/use-session-manager.ts | 307 ++- hooks/use-validate-diagram.ts | 18 + lib/access-code.ts | 50 + lib/admin/providers.ts | 26 +- lib/admin/settings.ts | 53 +- lib/ai-providers.ts | 1228 +++++------- lib/aihubmix-models.ts | 79 - lib/cached-responses.ts | 10 + lib/chat-helpers.ts | 136 +- lib/deprecated-params.ts | 94 + lib/dynamo-quota-manager.ts | 5 +- lib/i18n/dictionaries/en.json | 29 +- lib/i18n/dictionaries/ja.json | 29 +- lib/i18n/dictionaries/zh-Hant.json | 29 +- lib/i18n/dictionaries/zh.json | 29 +- lib/langfuse.ts | 9 +- lib/llm-errors.ts | 187 ++ lib/model-catalog.json | 1682 +++++++++++++++++ lib/model-catalog.ts | 45 + lib/output-token-limit.ts | 155 +- lib/pdf-utils.ts | 9 +- lib/provider-models.ts | 255 +++ lib/read-limited-body.ts | 32 + lib/server-model-config.ts | 18 +- lib/session-storage.ts | 65 +- lib/ssrf-protection.ts | 84 + lib/system-prompts.ts | 43 +- lib/types/model-config.ts | 157 +- lib/url-utils.ts | 7 +- lib/use-file-processor.tsx | 116 +- lib/utils.ts | 1404 +------------- next.config.ts | 4 +- package-lock.json | 335 ++-- package.json | 27 +- packages/claude-plugin/README.md | 22 +- packages/mcp-server/README.md | 40 +- packages/mcp-server/package-lock.json | 50 +- packages/mcp-server/package.json | 10 +- packages/mcp-server/scripts/check-package.mjs | 23 + packages/mcp-server/scripts/copy-assets.mjs | 22 + packages/mcp-server/src/diagram-operations.ts | 135 +- packages/mcp-server/src/dom.ts | 98 + packages/mcp-server/src/drawing-guide.ts | 107 ++ packages/mcp-server/src/edit-diagram.ts | 109 ++ packages/mcp-server/src/edit-gate.ts | 44 +- packages/mcp-server/src/exclusive.ts | 25 + packages/mcp-server/src/history.ts | 45 +- packages/mcp-server/src/http-server.ts | 980 ++++------ packages/mcp-server/src/index.ts | 920 +++++---- packages/mcp-server/src/load-diagram.ts | 26 +- packages/mcp-server/src/logger.ts | 3 +- packages/mcp-server/src/new-diagram.ts | 70 + packages/mcp-server/src/pages.ts | 76 +- packages/mcp-server/src/persistence.ts | 161 ++ packages/mcp-server/src/preview/index.html | 94 + packages/mcp-server/src/preview/preview.css | 190 ++ packages/mcp-server/src/preview/preview.js | 527 ++++++ packages/mcp-server/src/shape-library.ts | 73 + packages/mcp-server/src/xml-attributes.ts | 27 + packages/mcp-server/src/xml-examples.ts | 43 + packages/mcp-server/src/xml-syntax.ts | 21 + packages/mcp-server/src/xml-validation.ts | 538 +++--- .../tests/diagram-operations.test.ts | 151 ++ .../mcp-server/tests/edit-diagram.test.ts | 163 ++ packages/mcp-server/tests/edit-gate.test.ts | 44 +- packages/mcp-server/tests/exclusive.test.ts | 59 + packages/mcp-server/tests/http-server.test.ts | 710 +++++++ .../mcp-server/tests/load-diagram.test.ts | 20 +- packages/mcp-server/tests/multi-page.test.ts | 38 +- packages/mcp-server/tests/persistence.test.ts | 216 +++ .../mcp-server/tests/server-wiring.test.ts | 52 +- packages/mcp-server/tests/wrap-cells.test.ts | 182 ++ .../mcp-server/tests/xml-validation.test.ts | 355 ++++ packages/mcp-server/tsconfig.json | 1 + proxy.ts | 13 +- scripts/electron-dev.mjs | 167 +- scripts/prepare-electron-build.mjs | 9 + scripts/test-diagram-operations.mjs | 375 ---- scripts/update-model-catalog.mjs | 78 + tests/e2e/chat-errors.spec.ts | 77 + tests/e2e/chat.spec.ts | 115 +- tests/e2e/diagram-content.spec.ts | 737 ++++++++ tests/e2e/error-handling.spec.ts | 41 + tests/e2e/file-upload.spec.ts | 12 +- tests/e2e/history-restore.spec.ts | 275 +++ tests/e2e/model-test.spec.ts | 98 + tests/e2e/provider-models.spec.ts | 359 ++++ tests/unit/admin-providers-route.test.ts | 71 + tests/unit/admin-providers.test.ts | 79 +- tests/unit/admin-secret-input.test.tsx | 87 + tests/unit/admin-settings.test.ts | 20 +- tests/unit/admin-test-model.test.ts | 119 ++ tests/unit/ai-providers-cache.test.ts | 85 + tests/unit/ai-providers-credentials.test.ts | 508 +++++ tests/unit/ai-providers-google.test.ts | 62 + tests/unit/ai-providers-openai.test.ts | 27 + tests/unit/ai-providers.test.ts | 68 +- tests/unit/api-access-code.test.ts | 156 ++ tests/unit/app-menu.test.ts | 104 + tests/unit/base-url.test.ts | 42 + tests/unit/cached-responses.test.ts | 27 +- tests/unit/chat-helpers.test.ts | 163 +- .../chat-message-display-preview.test.tsx | 68 + tests/unit/chat-route-abort.test.ts | 139 ++ tests/unit/chat-route-edgeone.test.ts | 139 ++ tests/unit/chat-route-errors.test.ts | 248 +++ tests/unit/chat-route-quota.test.ts | 242 +++ tests/unit/config-manager.test.ts | 109 ++ tests/unit/cross-site-requests.test.ts | 95 + tests/unit/deprecated-params.test.ts | 119 ++ tests/unit/diagram-context.test.tsx | 119 ++ tests/unit/edgeone-function.test.ts | 62 + tests/unit/env-loader.test.ts | 111 ++ tests/unit/llm-errors.test.ts | 265 +++ tests/unit/log-save-route.test.ts | 42 + tests/unit/mcp-core.test.ts | 265 +++ tests/unit/mcp-preview-history.test.ts | 46 + tests/unit/mcp-preview-recovery.test.ts | 436 +++++ tests/unit/model-catalog.test.ts | 32 + tests/unit/output-token-limit.test.ts | 225 ++- tests/unit/parse-url-route.test.ts | 41 + tests/unit/pdf-utils.test.ts | 16 + tests/unit/port-manager.test.ts | 153 ++ tests/unit/provider-models.test.ts | 314 +++ tests/unit/server-model-config.test.ts | 48 + tests/unit/ssrf-protection.test.ts | 133 +- tests/unit/tool-call-card.test.tsx | 45 + tests/unit/url-utils.test.ts | 25 + tests/unit/use-diagram-tool-handlers.test.tsx | 204 ++ tests/unit/use-file-processor.test.tsx | 102 + tests/unit/use-model-config.test.tsx | 262 +++ tests/unit/use-session-manager.test.tsx | 232 +++ tests/unit/utils.test.ts | 112 +- tests/unit/validate-diagram-route.test.ts | 136 ++ tests/unit/validate-model-bedrock.test.ts | 34 + tests/unit/validate-model-route.test.ts | 368 ++++ tsconfig.json | 1 + 192 files changed, 20781 insertions(+), 6839 deletions(-) delete mode 100644 app/api/aihubmix-models/route.ts create mode 100644 app/api/provider-models/route.ts create mode 100644 lib/access-code.ts delete mode 100644 lib/aihubmix-models.ts create mode 100644 lib/deprecated-params.ts create mode 100644 lib/llm-errors.ts create mode 100644 lib/model-catalog.json create mode 100644 lib/model-catalog.ts create mode 100644 lib/provider-models.ts create mode 100644 lib/read-limited-body.ts create mode 100644 packages/mcp-server/scripts/check-package.mjs create mode 100644 packages/mcp-server/scripts/copy-assets.mjs create mode 100644 packages/mcp-server/src/dom.ts create mode 100644 packages/mcp-server/src/drawing-guide.ts create mode 100644 packages/mcp-server/src/edit-diagram.ts create mode 100644 packages/mcp-server/src/exclusive.ts create mode 100644 packages/mcp-server/src/new-diagram.ts create mode 100644 packages/mcp-server/src/persistence.ts create mode 100644 packages/mcp-server/src/preview/index.html create mode 100644 packages/mcp-server/src/preview/preview.css create mode 100644 packages/mcp-server/src/preview/preview.js create mode 100644 packages/mcp-server/src/shape-library.ts create mode 100644 packages/mcp-server/src/xml-attributes.ts create mode 100644 packages/mcp-server/src/xml-examples.ts create mode 100644 packages/mcp-server/src/xml-syntax.ts create mode 100644 packages/mcp-server/tests/diagram-operations.test.ts create mode 100644 packages/mcp-server/tests/edit-diagram.test.ts create mode 100644 packages/mcp-server/tests/exclusive.test.ts create mode 100644 packages/mcp-server/tests/http-server.test.ts create mode 100644 packages/mcp-server/tests/persistence.test.ts create mode 100644 packages/mcp-server/tests/wrap-cells.test.ts create mode 100644 packages/mcp-server/tests/xml-validation.test.ts delete mode 100644 scripts/test-diagram-operations.mjs create mode 100644 scripts/update-model-catalog.mjs create mode 100644 tests/e2e/chat-errors.spec.ts create mode 100644 tests/e2e/diagram-content.spec.ts create mode 100644 tests/e2e/model-test.spec.ts create mode 100644 tests/e2e/provider-models.spec.ts create mode 100644 tests/unit/admin-providers-route.test.ts create mode 100644 tests/unit/admin-secret-input.test.tsx create mode 100644 tests/unit/admin-test-model.test.ts create mode 100644 tests/unit/ai-providers-cache.test.ts create mode 100644 tests/unit/ai-providers-credentials.test.ts create mode 100644 tests/unit/ai-providers-google.test.ts create mode 100644 tests/unit/ai-providers-openai.test.ts create mode 100644 tests/unit/api-access-code.test.ts create mode 100644 tests/unit/app-menu.test.ts create mode 100644 tests/unit/base-url.test.ts create mode 100644 tests/unit/chat-message-display-preview.test.tsx create mode 100644 tests/unit/chat-route-abort.test.ts create mode 100644 tests/unit/chat-route-edgeone.test.ts create mode 100644 tests/unit/chat-route-errors.test.ts create mode 100644 tests/unit/chat-route-quota.test.ts create mode 100644 tests/unit/config-manager.test.ts create mode 100644 tests/unit/cross-site-requests.test.ts create mode 100644 tests/unit/deprecated-params.test.ts create mode 100644 tests/unit/diagram-context.test.tsx create mode 100644 tests/unit/edgeone-function.test.ts create mode 100644 tests/unit/env-loader.test.ts create mode 100644 tests/unit/llm-errors.test.ts create mode 100644 tests/unit/log-save-route.test.ts create mode 100644 tests/unit/mcp-core.test.ts create mode 100644 tests/unit/mcp-preview-history.test.ts create mode 100644 tests/unit/mcp-preview-recovery.test.ts create mode 100644 tests/unit/model-catalog.test.ts create mode 100644 tests/unit/parse-url-route.test.ts create mode 100644 tests/unit/pdf-utils.test.ts create mode 100644 tests/unit/port-manager.test.ts create mode 100644 tests/unit/provider-models.test.ts create mode 100644 tests/unit/tool-call-card.test.tsx create mode 100644 tests/unit/url-utils.test.ts create mode 100644 tests/unit/use-diagram-tool-handlers.test.tsx create mode 100644 tests/unit/use-file-processor.test.tsx create mode 100644 tests/unit/use-model-config.test.tsx create mode 100644 tests/unit/use-session-manager.test.tsx create mode 100644 tests/unit/validate-diagram-route.test.ts create mode 100644 tests/unit/validate-model-bedrock.test.ts create mode 100644 tests/unit/validate-model-route.test.ts diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 3ca6ee41..ada9eba8 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -25,7 +25,7 @@ jobs: - name: Run Biome format # Pin to the version in package.json so CI matches local/pre-commit # (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs). - run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched . + run: npx @biomejs/biome@2.5.7 check --write --no-errors-on-unmatched . - name: Check for changes id: changes diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 2cee715c..6b816c45 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -62,6 +62,10 @@ jobs: if: steps.version.outputs.publish == 'true' run: npm test + - name: Build and check package contents + if: steps.version.outputs.publish == 'true' + run: npm run build && npm run check-package + - name: Publish to npm if: steps.version.outputs.publish == 'true' run: npm publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8be1c4b..bc8376c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,10 @@ jobs: - name: Run MCP server unit tests run: npm --prefix packages/mcp-server test + # Tests run from src/, so check the built npm package separately + - name: Build MCP server and check package contents + run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package + e2e: name: E2E Tests runs-on: ubuntu-latest diff --git a/README.md b/README.md index 37043a36..5c94015a 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,13 @@ Then ask Claude to create diagrams: The diagram appears in your browser in real-time! +The MCP server includes most of the web app's drawing features: + +- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more) +- A screenshot tool, so the AI can check the rendered diagram and fix it +- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg` +- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart + See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations. ## Getting Started diff --git a/app/[lang]/admin/models-section.tsx b/app/[lang]/admin/models-section.tsx index 4f293c70..242d3ab8 100644 --- a/app/[lang]/admin/models-section.tsx +++ b/app/[lang]/admin/models-section.tsx @@ -33,8 +33,10 @@ import { import { Switch } from "@/components/ui/switch" import { useDictionary } from "@/hooks/use-dictionary" import { formatMessage } from "@/lib/i18n/utils" +import { STORAGE_KEYS } from "@/lib/storage" import { FIXED_CRED_PROVIDERS, + generateId, PROVIDER_INFO, type ProviderName, SUGGESTED_MODELS, @@ -87,6 +89,11 @@ function ProviderDetail({ try { const data = await adminFetch("/api/admin/test-model", password, { method: "POST", + // EdgeOne's function also checks the access code + headers: { + "x-access-code": + localStorage.getItem(STORAGE_KEYS.accessCode) || "", + }, body: JSON.stringify({ provider, modelId }), }) setTestResults((prev) => ({ @@ -225,6 +232,7 @@ function ProviderDetail({ {suggestions.length > 0 && ( addProvider(v as ProviderName)} > diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx index cbf5bba5..d66924d2 100644 --- a/app/[lang]/admin/page.tsx +++ b/app/[lang]/admin/page.tsx @@ -37,6 +37,19 @@ import { SettingField } from "./setting-field" const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)] +// For each toggleable group, whether any of its settings has a value (from +// the settings file or the environment) +function groupsWithValues(map: SettingsMap): Record { + const result: Record = {} + for (const group of SETTING_GROUPS) { + if (!group.toggleable) continue + result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some( + (d) => map[d.key]?.source !== "default", + ) + } + return result +} + export default function AdminPage() { const dict = useDictionary() // Localized group title/description, keyed by group id @@ -62,6 +75,8 @@ export default function AdminPage() { // Models section state const [providers, setProviders] = useState([]) const [envProviders, setEnvProviders] = useState([]) + // Whether .env itself sets AI_MODEL (a default the panel would override) + const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false) const [savedProviders, setSavedProviders] = useState("[]") const providersDirty = JSON.stringify(providers) !== savedProviders @@ -88,15 +103,13 @@ export default function AdminPage() { const map: SettingsMap = {} for (const s of data.settings) map[s.key] = s setSettings(map) - // Seed each toggle once from whether the group has configured - // values; don't stomp a user's explicit toggle on later saves + // A group stays on while it still has values (e.g. from env vars + // that saving can't remove); a user's explicit "on" for a group + // with no values yet is kept across saves setEnabledGroups((prev) => { - const next = { ...prev } - for (const group of SETTING_GROUPS) { - if (!group.toggleable || group.id in next) continue - next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some( - (d) => map[d.key]?.source !== "default", - ) + const next = groupsWithValues(map) + for (const id of Object.keys(next)) { + next[id] = next[id] || !!prev[id] } return next }) @@ -108,10 +121,12 @@ export default function AdminPage() { (data: { providers: AdminProvider[] envProviders?: EnvProvider[] + envHasDefaultModel?: boolean }) => { setProviders(data.providers) setSavedProviders(JSON.stringify(data.providers)) setEnvProviders(data.envProviders ?? []) + setEnvHasDefaultModel(!!data.envHasDefaultModel) }, [], ) @@ -181,8 +196,9 @@ export default function AdminPage() { return () => observer.disconnect() }, [authedPassword]) + // value undefined drops the pending change (back to the saved value) const handleChange = useCallback( - (key: string, value: string | null) => { + (key: string, value: string | null | undefined) => { setSaveMessage(null) setErrors((prev) => { if (!(key in prev)) return prev @@ -201,7 +217,7 @@ export default function AdminPage() { value === "" && (!state || state.source !== "file") && !isSecretValue(state?.value) - if (isRevert || isNoop) { + if (value === undefined || isRevert || isNoop) { const next = { ...prev } delete next[key] return next @@ -225,9 +241,10 @@ export default function AdminPage() { const next = { ...prev } for (const key of keys) { if (!enabled) { - // Stage deletion only for values currently set - if (settings[key]?.source !== "default") - next[key] = null + // Stage deletion of saved values; drop unsaved input + if (settings[key]?.source === "default") + delete next[key] + else next[key] = null } else if (next[key] === null) { delete next[key] } @@ -447,6 +464,7 @@ export default function AdminPage() { { @@ -462,6 +480,11 @@ export default function AdminPage() { const defs = SETTINGS_BY_GROUP.get(group.id) ?? [] const groupOff = group.toggleable && !enabledGroups[group.id] + // Values from env vars can't be removed here, so the + // group can't be turned off from the panel + const envLocked = defs.some( + (d) => settings[d.key]?.source === "env", + ) const fieldsDisabled = !writable || saving || !!groupOff const gt = groupText(group.id) const title = gt?.title ?? group.title @@ -480,6 +503,11 @@ export default function AdminPage() { {group.toggleable && (

+ {/* Clearing a saved secret reverts to it; the X button deletes */} - onChange(typeof v === "string" ? v : "") + onChange(typeof v === "string" ? v : undefined) } />
diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index b44c9fef..79d0a2e8 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -37,7 +37,6 @@ export default function Home() { ) const chatPanelRef = useRef(null) - const isMobileRef = useRef(false) // Load preferences from localStorage after mount useEffect(() => { @@ -48,7 +47,9 @@ export default function Home() { const currentLocale = pathParts[0] if (currentLocale !== savedLocale) { pathParts[0] = savedLocale - router.replace(`/${pathParts.join("/")}`) + // Keep the query (e.g. ?session=) and hash + const { search, hash } = window.location + router.replace(`/${pathParts.join("/")}${search}${hash}`) return // Wait for redirect } } @@ -106,27 +107,25 @@ export default function Home() { resetDrawioReady() } - // Check mobile - reset draw.io before crossing breakpoint - const isInitialRenderRef = useRef(true) + // Check mobile. No panel is remounted when crossing the breakpoint, so + // the draw.io ready state and the chat's turn stay as they are. useEffect(() => { const checkMobile = () => { - const newIsMobile = window.innerWidth < 768 - if ( - !isInitialRenderRef.current && - newIsMobile !== isMobileRef.current - ) { - setIsDrawioReady(false) - resetDrawioReady() - } - isMobileRef.current = newIsMobile - isInitialRenderRef.current = false - setIsMobile(newIsMobile) + setIsMobile(window.innerWidth < 768) } checkMobile() window.addEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile) - }, [resetDrawioReady]) + }, []) + + // Give the chat panel the size of this side of the breakpoint. It is + // open on both sides: the mobile panel cannot be collapsed, and one + // collapsed on desktop comes back open + useEffect(() => { + chatPanelRef.current?.resize(isMobile ? 50 : 33) + setIsChatVisible(true) + }, [isMobile]) const toggleChatPanel = () => { const panel = chatPanelRef.current @@ -193,7 +192,11 @@ export default function Home() { noExitBtn: true, dark: darkMode || drawioUi === "dark", - lang: currentLang, + // draw.io names Traditional Chinese "zh-tw" + lang: + currentLang === "zh-Hant" + ? "zh-tw" + : currentLang, // Enable offline mode in Electron to disable external service calls ...(isElectron && { offline: true, @@ -217,7 +220,6 @@ export default function Home() { {/* Chat Panel */} _BASE_URL: test that endpoint, not + // another one. It is the server's own, which chat uses + // without the checks for a URL a user typed. + baseUrl: resolved.baseUrl || serverUrl, + ...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }), modelId: body.modelId, awsAccessKeyId: resolved.awsAccessKeyId, awsSecretAccessKey: resolved.awsSecretAccessKey, diff --git a/app/api/aihubmix-models/route.ts b/app/api/aihubmix-models/route.ts deleted file mode 100644 index 9e4d0e8c..00000000 --- a/app/api/aihubmix-models/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextResponse } from "next/server" -import { - AIHUBMIX_MODELS_ENDPOINT, - extractAihubmixModelIds, -} from "@/lib/aihubmix-models" -import { SUGGESTED_MODELS } from "@/lib/types/model-config" - -const SUCCESS_CACHE_CONTROL = - "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400" - -function fallbackResponse() { - return NextResponse.json( - { - models: SUGGESTED_MODELS.aihubmix || [], - source: "fallback", - }, - { - headers: { - "Cache-Control": "no-store", - }, - }, - ) -} - -export async function GET() { - try { - const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, { - next: { revalidate: 3600 }, - }) - - if (!response.ok) { - console.warn( - `[aihubmix-models] Failed to fetch models: ${response.status}`, - ) - return fallbackResponse() - } - - const payload = await response.json() - const models = extractAihubmixModelIds(payload) - - if (models.length === 0) { - console.warn("[aihubmix-models] Model list response was empty") - return fallbackResponse() - } - - return NextResponse.json( - { - models, - source: "aihubmix", - }, - { - headers: { - "Cache-Control": SUCCESS_CACHE_CONTROL, - }, - }, - ) - } catch (error) { - console.warn("[aihubmix-models] Failed to load models:", error) - return fallbackResponse() - } -} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index ec404eb1..c5ea47a2 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,43 +4,62 @@ import { createUIMessageStream, createUIMessageStreamResponse, InvalidToolInputError, - LoadAPIKeyError, stepCountIs, streamText, } from "ai" -import fs from "fs/promises" import { jsonrepair } from "jsonrepair" import path from "path" import { z } from "zod" +import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { + CACHE_POINT, + edgeOneEndpoint, getAIModel, + getServerProvider, SINGLE_SYSTEM_PROVIDERS, supportsPromptCaching, + usesServerCredentials, + usesServerEndpoint, } from "@/lib/ai-providers" import { findCachedResponse } from "@/lib/cached-responses" import { - isMinimalDiagram, + dropInvalidToolCalls, + fixToolInputJson, replaceHistoricalToolInputs, validateFileParts, } from "@/lib/chat-helpers" +import { withDeprecatedParamsFallback } from "@/lib/deprecated-params" import { checkAndIncrementRequest, isQuotaEnabled, recordTokenUsage, } from "@/lib/dynamo-quota-manager" import { + endTrace, getTelemetryConfig, setTraceInput, setTraceOutput, wrapWithObserve, } from "@/lib/langfuse" +import { classifyLLMError, streamErrorText } from "@/lib/llm-errors" import { resolveMaxOutputTokens, withOutputTokenLimitFallback, } from "@/lib/output-token-limit" -import { findServerModelById } from "@/lib/server-model-config" +import { + type FlattenedServerModel, + findServerModelById, +} from "@/lib/server-model-config" +import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { getSystemPrompt } from "@/lib/system-prompts" +import { normalizeBaseUrl } from "@/lib/types/model-config" import { getUserIdFromRequest } from "@/lib/user-id" +import { hasCells } from "@/packages/mcp-server/src/pages.ts" +import { + getShapeLibrary, + SHAPE_LIBRARY_LIST, +} from "@/packages/mcp-server/src/shape-library.ts" +import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts" // No explicit cap: a reasoning model can spend minutes planning before it emits // the tool call, so take whatever the host allows. Vercel's own default is 300s, @@ -76,24 +95,18 @@ function createCachedStreamResponse(xml: string): Response { return createUIMessageStreamResponse({ stream }) } +// Responses streamed from the model, whose trace streamText's callbacks end +const modelStreamResponses = new WeakSet() + // Inner handler function +const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true" + async function handleChatRequest(req: Request): Promise { + const crossSite = rejectCrossSite(req) + if (crossSite) return crossSite // Check for access code - const accessCodes = - process.env.ACCESS_CODE_LIST?.split(",") - .map((code) => code.trim()) - .filter(Boolean) || [] - if (accessCodes.length > 0) { - const accessCodeHeader = req.headers.get("x-access-code") - if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) { - return Response.json( - { - error: "Invalid or missing access code. Please configure it in Settings.", - }, - { status: 401 }, - ) - } - } + const accessDenied = checkAccessCode(req) + if (accessDenied) return accessDenied const body = await req.json() const { messages, xml, previousXml, sessionId } = body @@ -126,36 +139,6 @@ async function handleChatRequest(req: Request): Promise { userId: userId, }) - // === SERVER-SIDE QUOTA CHECK START === - // Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set - const hasOwnApiKey = !!( - req.headers.get("x-ai-provider") && - (req.headers.get("x-ai-api-key") || - req.headers.get("x-aws-access-key-id") || - req.headers.get("x-vertex-api-key")) - ) - - // Skip quota check if: quota disabled, user has own API key, or is anonymous - if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") { - const quotaCheck = await checkAndIncrementRequest(userId, { - requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10, - tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000, - tpm: Number(process.env.TPM_LIMIT) || 20000, - }) - if (!quotaCheck.allowed) { - return Response.json( - { - error: quotaCheck.error, - type: quotaCheck.type, - used: quotaCheck.used, - limit: quotaCheck.limit, - }, - { status: 429 }, - ) - } - } - // === SERVER-SIDE QUOTA CHECK END === - // === FILE VALIDATION START === const fileValidation = validateFileParts(messages) if (!fileValidation.valid) { @@ -165,7 +148,7 @@ async function handleChatRequest(req: Request): Promise { // === CACHE CHECK START === const isFirstMessage = messages.length === 1 - const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml) + const isEmptyDiagram = !xml || !hasCells(xml) if (isFirstMessage && isEmptyDiagram) { const lastMessage = messages[0] @@ -185,24 +168,15 @@ async function handleChatRequest(req: Request): Promise { let baseUrl = req.headers.get("x-ai-base-url") const selectedModelId = req.headers.get("x-selected-model-id") - // For EdgeOne provider, construct full URL from request origin - // because createOpenAI needs absolute URL, not relative path - if (provider === "edgeone" && !baseUrl) { - const origin = req.headers.get("origin") || new URL(req.url).origin - baseUrl = `${origin}/api/edgeai` - } - - // Get cookie header for EdgeOne authentication (eo_token, eo_time) - const cookieHeader = req.headers.get("cookie") - // Check if this is a server model with custom env var names let serverModelConfig: { apiKeyEnv?: string | string[] baseUrlEnv?: string provider?: string } = {} + let serverModel: FlattenedServerModel | null = null if (selectedModelId?.startsWith("server:")) { - const serverModel = await findServerModelById(selectedModelId) + serverModel = await findServerModelById(selectedModelId) console.log( `[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`, ) @@ -216,12 +190,41 @@ async function handleChatRequest(req: Request): Promise { } } + // A server model's provider comes from its config: for one set up in + // the admin panel the header holds the provider name's slug. Without + // either, the server's own AI_PROVIDER. + const isEdgeOne = + (serverModelConfig.provider || provider || getServerProvider()) === + "edgeone" + + // EdgeOne is this deployment's own function, whatever URL the request + // names: another host would get the user's EdgeOne cookies, and the + // quota counts it. Absolute, as the SDK needs. + if (isEdgeOne) baseUrl = edgeOneEndpoint(req) + + // Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may + // not point the server at a private or internal address + if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) { + return Response.json( + { error: "Private or internal base URLs are not allowed." }, + { status: 400 }, + ) + } + + // Get cookie header for EdgeOne authentication (eo_token, eo_time) + const cookieHeader = req.headers.get("cookie") + const clientOverrides = { - // Server model provider takes precedence over client header - provider: serverModelConfig.provider || provider, + // Server model provider takes precedence over client header; EdgeOne + // named only in AI_PROVIDER is named here, for its own base URL + provider: + serverModelConfig.provider || + provider || + (isEdgeOne ? "edgeone" : null), baseUrl, apiKey: req.headers.get("x-ai-api-key"), - modelId: req.headers.get("x-ai-model"), + // A server model runs the model it was configured with, whatever the header says + modelId: serverModel?.modelId || req.headers.get("x-ai-model"), // AWS Bedrock credentials awsAccessKeyId: req.headers.get("x-aws-access-key-id"), awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"), @@ -231,11 +234,14 @@ async function handleChatRequest(req: Request): Promise { ...serverModelConfig, // Vertex AI credentials (Express Mode) vertexApiKey: req.headers.get("x-vertex-api-key"), - // Pass cookies for EdgeOne Pages authentication - ...(provider === "edgeone" && - cookieHeader && { - headers: { cookie: cookieHeader }, - }), + // Pass cookies for EdgeOne Pages authentication, and the access code, + // which the EdgeOne function checks too + ...(isEdgeOne && { + headers: { + ...(cookieHeader && { cookie: cookieHeader }), + "x-access-code": req.headers.get("x-access-code") || "", + }, + }), } // Read minimal style preference from header @@ -249,17 +255,80 @@ async function handleChatRequest(req: Request): Promise { const { model: baseModel, providerOptions, - headers, modelId, provider: resolvedProvider, } = getAIModel(clientOverrides) - // Retry with a smaller budget if the provider rejects the requested one - const model = withOutputTokenLimitFallback(baseModel) + // On the server's own keys, only run models the server offers: a server + // model picked by id (its model name is fixed above) or one in AI_MODEL + // on AI_PROVIDER. With their own key, users can run any model. + const onServerCredentials = usesServerCredentials( + resolvedProvider, + clientOverrides, + ) + const envModels = + process.env.AI_MODEL?.split(",").map((m) => m.trim()) || [] + const offeredInEnv = + envModels.includes(modelId) && resolvedProvider === getServerProvider() + if (onServerCredentials && !serverModel && !offeredInEnv) { + return Response.json( + { + error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`, + }, + { status: 400 }, + ) + } - // User setting wins over server env, so desktop users can raise it themselves + // === SERVER-SIDE QUOTA CHECK START === + // Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the + // server's keys, or on the server's own endpoints: EdgeOne, its keyless + // Ollama, and anything at a private address (the server's network, + // which ignores a dummy key header). Bedrock and EdgeOne never use the + // base URL header. In the desktop app every endpoint is the user's. + const clientBaseUrl = normalizeBaseUrl( + req.headers.get("x-ai-base-url") ?? "", + ) + const onServerEndpoint = await usesServerEndpoint( + resolvedProvider, + clientBaseUrl, + clientOverrides.apiKey, + ) + const countsQuota = + isQuotaEnabled() && + (onServerCredentials || onServerEndpoint) && + userId !== "anonymous" + if (countsQuota) { + const quotaCheck = await checkAndIncrementRequest(userId, { + requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10, + tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000, + tpm: Number(process.env.TPM_LIMIT) || 20000, + }) + if (!quotaCheck.allowed) { + return Response.json( + { + error: quotaCheck.error, + type: quotaCheck.type, + used: quotaCheck.used, + limit: quotaCheck.limit, + }, + { status: 429 }, + ) + } + } + // === SERVER-SIDE QUOTA CHECK END === + + // Retry once if the provider rejects the requested budget, or (newer + // Claude models) the sampling or thinking settings + const model = withOutputTokenLimitFallback( + withDeprecatedParamsFallback(baseModel), + ) + + // The user setting can raise the budget only on their own key (in the + // desktop app every key is the user's); on the server's keys or own + // endpoints it can only lower it const maxOutputTokens = resolveMaxOutputTokens( req.headers.get("x-max-output-tokens"), + onServerCredentials || onServerEndpoint, ) console.log(`[maxOutputTokens] ${maxOutputTokens}`) @@ -291,39 +360,46 @@ async function handleChatRequest(req: Request): Promise { ${userInputText} """` - // Convert UIMessages to ModelMessages and add system message - const modelMessages = await convertToModelMessages(messages) - - // DEBUG: Log incoming messages structure - console.log("[route.ts] Incoming messages count:", messages.length) - messages.forEach((msg: any, idx: number) => { - console.log( - `[route.ts] Message ${idx} role:`, - msg.role, - "parts count:", - msg.parts?.length, - ) - if (msg.parts) { - msg.parts.forEach((part: any, partIdx: number) => { - if ( - part.type === "tool-invocation" || - part.type === "tool-result" - ) { - console.log(`[route.ts] Part ${partIdx}:`, { - type: part.type, - toolName: part.toolName, - hasInput: !!part.input, - inputType: typeof part.input, - inputKeys: - part.input && typeof part.input === "object" - ? Object.keys(part.input) - : null, - }) - } - }) - } + // Convert UIMessages to ModelMessages and add system message. A tool + // call that never got its result (the user stopped while it ran) is + // left out: the SDK would refuse this and every later request of the + // chat (MissingToolResultsError) + const modelMessages = await convertToModelMessages(messages, { + ignoreIncompleteToolCalls: true, }) + // DEBUG_LLM_PAYLOAD=true logs the incoming message structure + if (DEBUG_LLM_PAYLOAD) { + console.log("[route.ts] Incoming messages count:", messages.length) + messages.forEach((msg: any, idx: number) => { + console.log( + `[route.ts] Message ${idx} role:`, + msg.role, + "parts count:", + msg.parts?.length, + ) + if (msg.parts) { + msg.parts.forEach((part: any, partIdx: number) => { + if ( + part.type === "tool-invocation" || + part.type === "tool-result" + ) { + console.log(`[route.ts] Part ${partIdx}:`, { + type: part.type, + toolName: part.toolName, + hasInput: !!part.input, + inputType: typeof part.input, + inputKeys: + part.input && typeof part.input === "object" + ? Object.keys(part.input) + : null, + }) + } + }) + } + }) + } + // Replace historical tool call XML with placeholders to reduce tokens // Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML const enableHistoryReplace = @@ -340,61 +416,43 @@ ${userInputText} ) // Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming) - // Bedrock API rejects messages where toolUse.input is not a valid JSON object - enhancedMessages = enhancedMessages - .map((msg: any) => { - if (msg.role !== "assistant" || !Array.isArray(msg.content)) { - return msg - } - const filteredContent = msg.content.filter((part: any) => { - if (part.type === "tool-call") { - // Check if input is a valid object (not null, undefined, or empty) - if ( - !part.input || - typeof part.input !== "object" || - Object.keys(part.input).length === 0 - ) { - console.warn( - `[route.ts] Filtering out tool-call with invalid input:`, - { toolName: part.toolName, input: part.input }, - ) - return false - } - } - return true - }) - return { ...msg, content: filteredContent } - }) - .filter((msg: any) => msg.content && msg.content.length > 0) + // and their results. Bedrock API rejects messages where toolUse.input is not a valid + // JSON object, and every provider rejects a tool result whose call is gone. + enhancedMessages = dropInvalidToolCalls(enhancedMessages) - // DEBUG: Log modelMessages structure (what's being sent to AI) - console.log("[route.ts] Model messages count:", enhancedMessages.length) - enhancedMessages.forEach((msg: any, idx: number) => { - console.log( - `[route.ts] ModelMsg ${idx} role:`, - msg.role, - "content count:", - msg.content?.length, - ) - if (msg.content) { - msg.content.forEach((part: any, partIdx: number) => { - if (part.type === "tool-call" || part.type === "tool-result") { - console.log(`[route.ts] Content ${partIdx}:`, { - type: part.type, - toolName: part.toolName, - hasInput: !!part.input, - inputType: typeof part.input, - inputValue: - part.input === undefined - ? "undefined" - : part.input === null - ? "null" - : "object", - }) - } - }) - } - }) + // DEBUG_LLM_PAYLOAD=true logs what is sent to the model + if (DEBUG_LLM_PAYLOAD) { + console.log("[route.ts] Model messages count:", enhancedMessages.length) + enhancedMessages.forEach((msg: any, idx: number) => { + console.log( + `[route.ts] ModelMsg ${idx} role:`, + msg.role, + "content count:", + msg.content?.length, + ) + if (msg.content) { + msg.content.forEach((part: any, partIdx: number) => { + if ( + part.type === "tool-call" || + part.type === "tool-result" + ) { + console.log(`[route.ts] Content ${partIdx}:`, { + type: part.type, + toolName: part.toolName, + hasInput: !!part.input, + inputType: typeof part.input, + inputValue: + part.input === undefined + ? "undefined" + : part.input === null + ? "null" + : "object", + }) + } + }) + } + }) + } // Update the last message with user input only (XML moved to separate cached system message) if (enhancedMessages.length >= 1) { @@ -410,7 +468,7 @@ ${userInputText} contentParts.push({ type: "image", image: filePart.url, - mimeType: filePart.mediaType, + mediaType: filePart.mediaType, }) } @@ -430,9 +488,7 @@ ${userInputText} if (enhancedMessages[i].role === "assistant") { enhancedMessages[i] = { ...enhancedMessages[i], - providerOptions: { - bedrock: { cachePoint: { type: "default" } }, - }, + providerOptions: CACHE_POINT, } break // Only cache the last assistant message } @@ -471,7 +527,7 @@ ${previousXml} ${xml || ""} """ -IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!` +IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.` const systemMessages = isSingleSystemProvider ? [ @@ -485,28 +541,26 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on { role: "system" as const, content: finalSystemMessage, - ...(shouldCache && { - providerOptions: { - bedrock: { cachePoint: { type: "default" } }, - }, - }), + ...(shouldCache && { providerOptions: CACHE_POINT }), }, // Cache breakpoint 2: Previous and Current diagram XML context { role: "system" as const, content: xmlContext, - ...(shouldCache && { - providerOptions: { - bedrock: { cachePoint: { type: "default" } }, - }, - }), + ...(shouldCache && { providerOptions: CACHE_POINT }), }, ] const allMessages = [...systemMessages, ...enhancedMessages] + // Set by onAbort, which records the finished steps' tokens itself + let stopped = false const result = streamText({ model, + // The system messages carry cache points, so they go in messages. + // A client's own system messages have string content and were + // dropped by the empty-content filter above. + allowSystemInMessages: true, abortSignal: req.signal, // Must be sent: unset means the provider's own default, and Bedrock's is // 4096, enough for a small diagram, so larger ones were cut off mid-attribute. @@ -528,23 +582,11 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on error.name === "AI_InvalidToolInputError" ) { try { - // Pre-process to fix common LLM JSON errors that jsonrepair can't handle - let inputToRepair = toolCall.input - if (typeof inputToRepair === "string") { - // Fix `:=` instead of `: ` (LLM sometimes generates this) - inputToRepair = inputToRepair.replace(/:=/g, ": ") - // Fix `= "` instead of `: "` - inputToRepair = inputToRepair.replace(/=\s*"/g, ': "') - // Fix inconsistent quote escaping in XML attributes within JSON strings - // Pattern: attribute="value\" where opening quote is unescaped but closing is escaped - // Example: y="-20\" should be y=\"-20\" - inputToRepair = inputToRepair.replace( - /(\w+)="([^"]*?)\\"/g, - '$1=\\"$2\\"', - ) - } - // Use jsonrepair to fix truncated JSON - const repairedInput = jsonrepair(inputToRepair) + // Pre-process to fix common LLM JSON errors that jsonrepair can't handle, + // then use jsonrepair to fix truncated JSON + const repairedInput = jsonrepair( + fixToolInputJson(toolCall.input), + ) console.log( `[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`, ) @@ -554,26 +596,8 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on `[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`, repairError, ) - // Return a placeholder input to avoid API errors in multi-step - // The tool will fail gracefully on client side - if (toolCall.toolName === "edit_diagram") { - return { - ...toolCall, - input: { - operations: [], - _error: "JSON repair failed - no operations to apply", - }, - } - } - if (toolCall.toolName === "display_diagram") { - return { - ...toolCall, - input: { - xml: "", - _error: "JSON repair failed - empty diagram", - }, - } - } + // Keep the original error, so the model and the client see why + // the input was rejected and the model can retry the call return null } } @@ -582,7 +606,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on }, messages: allMessages, ...(providerOptions && { providerOptions }), // This now includes all reasoning configs - ...(headers && { headers }), // Langfuse telemetry config (returns undefined if not configured) ...(getTelemetryConfig({ sessionId: validSessionId, userId }) && { experimental_telemetry: getTelemetryConfig({ @@ -596,21 +619,36 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on // Record token usage for server-side quota tracking (if enabled) // Use totalUsage (cumulative across all steps) instead of usage (final step only) - // Include all 4 token types: input, output, cache read, cache write - if ( - isQuotaEnabled() && - !hasOwnApiKey && - userId !== "anonymous" && - totalUsage - ) { + // inputTokens already includes cache reads and writes in AI SDK 6 + if (countsQuota && totalUsage && !stopped) { const totalTokens = (totalUsage.inputTokens || 0) + - (totalUsage.outputTokens || 0) + - (totalUsage.cachedInputTokens || 0) + - (totalUsage.inputTokenDetails?.cacheWriteTokens || 0) + (totalUsage.outputTokens || 0) recordTokenUsage(userId, totalTokens) } }, + // onFinish is skipped when the stream fails or is aborted, so end the trace here + onError: ({ error }) => { + console.error(error) // what AI SDK does without an onError + endTrace() + }, + onAbort: ({ steps }) => { + stopped = true + endTrace() + // Stopped (or disconnected) after some steps finished: their + // tokens were used, or stopping every request after a costly + // first step would get around the token limits + if (countsQuota) { + const tokens = steps.reduce( + (sum, step) => + sum + + (step.usage.inputTokens || 0) + + (step.usage.outputTokens || 0), + 0, + ) + if (tokens > 0) recordTokenUsage(userId, tokens) + } + }, tools: { // Client-side tool that will be executed on the client display_diagram: { @@ -625,21 +663,7 @@ VALIDATION RULES (XML will be rejected if violated): 6. Escape special chars in values: < > & " Example (generate ONLY this - no wrapper tags): - - - - - - - - - - - - - - - +${SWIMLANE_EXAMPLE} Notes: - For AWS diagrams, use **AWS 2025 icons**. @@ -717,14 +741,7 @@ Example: If previous output ended with ' streamErrorText(error, onServerCredentials), messageMetadata: ({ part }) => { if (part.type === "finish") { const usage = (part as any).totalUsage @@ -796,63 +784,28 @@ Call this tool to get shape names and usage syntax for a specific library.`, return undefined }, }) + modelStreamResponses.add(response) + return response } -// Helper to categorize errors and return appropriate response +// Errors before the stream starts, as JSON the chat panel reads function handleError(error: unknown): Response { console.error("Error in chat route:", error) const isDev = process.env.NODE_ENV === "development" - - // Check for specific AI SDK error types - if (APICallError.isInstance(error)) { - return Response.json( - { - error: error.message, - ...(isDev && { - details: error.responseBody, - stack: error.stack, - }), - }, - { status: error.statusCode || 500 }, - ) - } - - if (LoadAPIKeyError.isInstance(error)) { - return Response.json( - { - error: "Authentication failed. Please check your API key.", - ...(isDev && { - stack: error.stack, - }), - }, - { status: 401 }, - ) - } - - // Fallback for other errors with safety filter - const message = - error instanceof Error ? error.message : "An unexpected error occurred" - const status = (error as any)?.statusCode || (error as any)?.status || 500 - - // Prevent leaking API keys, tokens, or other sensitive data - const lowerMessage = message.toLowerCase() - const safeMessage = - lowerMessage.includes("key") || - lowerMessage.includes("token") || - lowerMessage.includes("sig") || - lowerMessage.includes("signature") || - lowerMessage.includes("secret") || - lowerMessage.includes("password") || - lowerMessage.includes("credential") - ? "Authentication failed. Please check your credentials." - : message + const classified = classifyLLMError(error) + const status = + (error as { statusCode?: number })?.statusCode || + (error as { status?: number })?.status || + (classified.code === "invalid_api_key" ? 401 : 500) return Response.json( { - error: safeMessage, + ...classified, ...(isDev && { - details: message, + details: APICallError.isInstance(error) + ? error.responseBody + : undefined, stack: error instanceof Error ? error.stack : undefined, }), }, @@ -862,11 +815,16 @@ function handleError(error: unknown): Response { // Wrap handler with error handling async function safeHandler(req: Request): Promise { + let response: Response try { - return await handleChatRequest(req) + response = await handleChatRequest(req) } catch (error) { - return handleError(error) + response = handleError(error) } + // Early returns, cache hits and errors never reach streamText's callbacks, + // so their Langfuse trace has to be ended here + if (!modelStreamResponses.has(response)) endTrace() + return response } // Wrap with Langfuse observe (if configured) diff --git a/app/api/log-save/route.ts b/app/api/log-save/route.ts index fc73fb2b..eb30e0fe 100644 --- a/app/api/log-save/route.ts +++ b/app/api/log-save/route.ts @@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse" const saveSchema = z.object({ filename: z.string().min(1).max(255), - format: z.enum(["drawio", "png", "svg"]), + format: z.enum(["drawio", "png", "svg", "xmlsvg"]), sessionId: z.string().min(1).max(200).optional(), }) diff --git a/app/api/parse-url/route.ts b/app/api/parse-url/route.ts index 794b711e..33a15c4b 100644 --- a/app/api/parse-url/route.ts +++ b/app/api/parse-url/route.ts @@ -1,9 +1,12 @@ import { extractFromHtml } from "@extractus/article-extractor" import { NextResponse } from "next/server" import TurndownService from "turndown" +import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" +import { readLimitedBody } from "@/lib/read-limited-body" import { isPrivateUrl } from "@/lib/ssrf-protection" const MAX_CONTENT_LENGTH = 150000 // Match PDF limit +const MAX_RESPONSE_BYTES = 5 * 1024 * 1024 const EXTRACT_TIMEOUT_MS = 15000 const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)" @@ -33,6 +36,11 @@ function detectCharset( } export async function POST(req: Request) { + const crossSite = rejectCrossSite(req) + if (crossSite) return crossSite + const accessError = checkAccessCode(req) + if (accessError) return accessError + try { const { url } = await req.json() @@ -97,7 +105,15 @@ export async function POST(req: Request) { ) } - const buffer = await response.arrayBuffer() + const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES) + if (!buffer) { + return NextResponse.json( + { + error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`, + }, + { status: 413 }, + ) + } const charset = detectCharset(contentType, buffer) html = new TextDecoder(charset).decode(buffer) } catch (err: any) { @@ -115,6 +131,9 @@ export async function POST(req: Request) { ) } finally { clearTimeout(timeoutId) + // Ends a download left unread (too large, PDF, error status); + // a body already read is not affected + controller.abort() } // extractFromHtml throws (not returns null) on empty/non-HTML bodies, diff --git a/app/api/provider-models/route.ts b/app/api/provider-models/route.ts new file mode 100644 index 00000000..6b362e73 --- /dev/null +++ b/app/api/provider-models/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server" +import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" +import { classifyLLMError } from "@/lib/llm-errors" +import { + canListModels, + listProviderModels, + ModelListError, +} from "@/lib/provider-models" +import { + allowPrivateUrls, + isPrivateUrl, + RedirectRefusedError, + redirectGuardedFetch, +} from "@/lib/ssrf-protection" +import type { ProviderName } from "@/lib/types/model-config" + +export const runtime = "nodejs" + +// Public lists need no key +const NO_KEY_NEEDED = new Set([ + "ollama", + "openrouter", + "aihubmix", +]) + +/** + * The models a provider offers, for the "Fetch models" button in model + * settings. Answers { models: null } for providers that cannot list them, + * so the dialog keeps its suggested models. + */ +export async function POST(req: Request) { + const crossSite = rejectCrossSite(req) + if (crossSite) return crossSite + // Sends requests to a URL the client chose, so require the access code + const accessError = checkAccessCode(req) + if (accessError) return accessError + + const { provider, apiKey, baseUrl } = (await req.json()) as { + provider: ProviderName + apiKey?: string + baseUrl?: string + } + if (!canListModels(provider)) { + return NextResponse.json({ models: null }) + } + // SECURITY: Block SSRF attacks via custom baseUrl + if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) { + return NextResponse.json({ error: "Invalid base URL" }, { status: 400 }) + } + if (!apiKey && !NO_KEY_NEEDED.has(provider)) { + return NextResponse.json( + { error: "API key is required" }, + { status: 400 }, + ) + } + + try { + const models = await listProviderModels( + provider, + { apiKey, baseUrl }, + (baseUrl && redirectGuardedFetch()) || fetch, + ) + return NextResponse.json({ models }) + } catch (error) { + console.warn("[provider-models] Listing failed:", error) + // Only our own explanations go back: the URL may be an internal + // address, whose answer or host names must not reach the caller. + // The Gateway SDK wraps them, keeping ours as the cause. + const isOwn = (e: unknown): e is Error => + e instanceof ModelListError || e instanceof RedirectRefusedError + const cause = (error as { cause?: unknown })?.cause + const own = isOwn(error) ? error : isOwn(cause) ? cause : null + const { code } = classifyLLMError(own ?? error) + return NextResponse.json({ + code, + error: own?.message ?? "The model list request failed.", + }) + } +} diff --git a/app/api/validate-diagram/route.ts b/app/api/validate-diagram/route.ts index 61464017..3fba6628 100644 --- a/app/api/validate-diagram/route.ts +++ b/app/api/validate-diagram/route.ts @@ -3,8 +3,15 @@ * Accepts a PNG image and streams validation results using useObject-compatible format. */ -import { streamObject } from "ai" +import { Output, streamText } from "ai" +import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { getValidationModel } from "@/lib/ai-providers" +import { + checkAndIncrementRequest, + isQuotaEnabled, + recordTokenUsage, +} from "@/lib/dynamo-quota-manager" +import { getUserIdFromRequest } from "@/lib/user-id" import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts" import { type ValidationResult, @@ -13,6 +20,9 @@ import { export const maxDuration = 30 +// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture +const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024 + interface ValidateDiagramRequest { imageData: string // Base64 PNG data URL sessionId?: string @@ -25,25 +35,20 @@ const DEFAULT_VALID_RESULT: ValidationResult = { suggestions: [], } -/** - * Create a streaming response for useObject compatibility. - * useObject expects text stream format, not plain JSON. - */ +/** A fixed result in the text format useObject reads */ function createStreamingResponse(result: ValidationResult): Response { - const encoder = new TextEncoder() - const stream = new ReadableStream({ - start(controller) { - // Stream the JSON as text (useObject parses this) - controller.enqueue(encoder.encode(JSON.stringify(result))) - controller.close() - }, - }) - return new Response(stream, { + return new Response(JSON.stringify(result), { headers: { "Content-Type": "text/plain; charset=utf-8" }, }) } export async function POST(req: Request): Promise { + const crossSite = rejectCrossSite(req) + if (crossSite) return crossSite + // Uses the server's model credentials, so require the access code + const accessError = checkAccessCode(req) + if (accessError) return accessError + try { // Check if VLM validation is enabled (default: true) const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false" @@ -72,6 +77,42 @@ export async function POST(req: Request): Promise { ) } + if (imageData.length > MAX_IMAGE_DATA_LENGTH) { + return Response.json( + { error: "Image data too large" }, + { status: 413 }, + ) + } + + // It runs the server's vision model: with the quota on, the daily + // and per-minute token limits apply, and its tokens are counted. Not + // the request limit, which is for chats: the day's last chat still + // gets its check, and a check does not count as a chat. + const userId = getUserIdFromRequest(req) + const countsQuota = isQuotaEnabled() && userId !== "anonymous" + if (countsQuota) { + const quotaCheck = await checkAndIncrementRequest( + userId, + { + requests: 0, + tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000, + tpm: Number(process.env.TPM_LIMIT) || 20000, + }, + 0, + ) + if (!quotaCheck.allowed) { + return Response.json( + { + error: quotaCheck.error, + type: quotaCheck.type, + used: quotaCheck.used, + limit: quotaCheck.limit, + }, + { status: 429 }, + ) + } + } + // Get the validation model let model try { @@ -93,9 +134,9 @@ export async function POST(req: Request): Promise { ) || 10000 // Stream the VLM response for useObject consumption - const result = streamObject({ + const result = streamText({ model, - schema: ValidationResultSchema, + output: Output.object({ schema: ValidationResultSchema }), system: VALIDATION_SYSTEM_PROMPT, messages: [ { @@ -114,10 +155,17 @@ export async function POST(req: Request): Promise { ], maxOutputTokens: 1024, abortSignal: AbortSignal.timeout(timeout), - onFinish: ({ object }) => { - if (sessionId && object) { + onFinish: ({ output, totalUsage }) => { + if (countsQuota && totalUsage) { + recordTokenUsage( + userId, + (totalUsage.inputTokens || 0) + + (totalUsage.outputTokens || 0), + ) + } + if (sessionId && output) { console.log( - `[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`, + `[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`, ) } }, diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts index 5d08fc85..1109a976 100644 --- a/app/api/validate-model/route.ts +++ b/app/api/validate-model/route.ts @@ -1,27 +1,28 @@ -import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" -import { createAnthropic } from "@ai-sdk/anthropic" -import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" -import { createGateway } from "@ai-sdk/gateway" -import { createGoogleGenerativeAI } from "@ai-sdk/google" -import { createVertex } from "@ai-sdk/google-vertex" -import { createOpenAI } from "@ai-sdk/openai" -import { createAihubmix } from "@aihubmix/ai-sdk-provider" -import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { generateText } from "ai" +import { streamText, tool } from "ai" import { NextResponse } from "next/server" -import { createOllama } from "ollama-ai-provider-v2" +import { z } from "zod" +import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" +import { checkAdminAuth } from "@/lib/admin/auth" import { - AIHUBMIX_APP_CODE, - isAihubmixStandardBaseURL, - normalizeMiniMaxBaseURL, + edgeOneEndpoint, + getAIModel, + globalBaseUrl, + usesServerCredentials, + usesServerEndpoint, } from "@/lib/ai-providers" +import { + checkAndIncrementRequest, + isQuotaEnabled, +} from "@/lib/dynamo-quota-manager" +import { classifyLLMError } from "@/lib/llm-errors" import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" -import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" +import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config" +import { getUserIdFromRequest } from "@/lib/user-id" export const runtime = "nodejs" interface ValidateRequest { - provider: string + provider: ProviderName apiKey: string baseUrl?: string modelId: string @@ -29,21 +30,42 @@ interface ValidateRequest { awsAccessKeyId?: string awsSecretAccessKey?: string awsRegion?: string + awsSessionToken?: string // Vertex AI specific vertexApiKey?: string // Express Mode API key + // Set by the admin panel's Test: baseUrl is the server's

_BASE_URL + serverBaseUrl?: boolean } +const TEST_TIMEOUT_MS = 15_000 + +// Drawing works through tool calls, so the test asks for one +const PING_TOOL = tool({ + description: "Report that the connection works.", + inputSchema: z.object({}), +}) + +const NO_TOOL_CALL_WARNING = + "Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs." + export async function POST(req: Request) { + const crossSite = rejectCrossSite(req) + if (crossSite) return crossSite + // Lets the server send requests to arbitrary URLs, so require the access + // code, or the admin password (the admin panel's Test button) + const accessError = checkAccessCode(req) + if (accessError && checkAdminAuth(req)) return accessError + try { const body: ValidateRequest = await req.json() const { provider, apiKey, - baseUrl, modelId, awsAccessKeyId, awsSecretAccessKey, awsRegion, + awsSessionToken, // Note: Express Mode only needs vertexApiKey vertexApiKey, } = body @@ -54,9 +76,26 @@ export async function POST(req: Request) { { status: 400 }, ) } + // EdgeOne is this site's own function, as in the chat; the admin + // panel's Test sends no URL, and a relative one cannot be fetched + const baseUrl = + provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl + // The admin panel's Test of an entry without a URL sends the + // server's own

_BASE_URL, which chat uses as it is: not a URL a + // user chose, so no private-address or redirect rules + const serverUrl = + body.serverBaseUrl === true && + !!baseUrl && + baseUrl === globalBaseUrl(provider) && + !checkAdminAuth(req) // SECURITY: Block SSRF attacks via custom baseUrl - if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) { + if ( + baseUrl && + !serverUrl && + !allowPrivateUrls() && + (await isPrivateUrl(baseUrl)) + ) { return NextResponse.json( { valid: false, error: "Invalid base URL" }, { status: 400 }, @@ -90,369 +129,117 @@ export async function POST(req: Request) { { status: 400 }, ) } - - let model: any - - switch (provider) { - case "openai": { - const openai = createOpenAI({ - apiKey, - ...(baseUrl && { baseURL: baseUrl }), - }) - model = openai.chat(modelId) - break - } - - case "anthropic": { - const anthropic = createAnthropic({ - apiKey, - baseURL: baseUrl || "https://api.anthropic.com/v1", - }) - model = anthropic(modelId) - break - } - - case "google": { - const google = createGoogleGenerativeAI({ - apiKey, - ...(baseUrl && { baseURL: baseUrl }), - }) - model = google(modelId) - break - } - - case "vertexai": { - const vertex = createVertex({ - apiKey: vertexApiKey, - ...(baseUrl && { baseURL: baseUrl }), - }) - model = vertex(modelId) - break - } - - case "azure": { - const azure = createOpenAI({ - apiKey, - baseURL: baseUrl, - }) - model = azure.chat(modelId) - break - } - - case "bedrock": { - const bedrock = createAmazonBedrock({ - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - region: awsRegion, - }) - model = bedrock(modelId) - break - } - - case "openrouter": { - const openrouter = createOpenRouter({ - apiKey, - ...(baseUrl && { baseURL: baseUrl }), - }) - model = openrouter(modelId) - break - } - - case "aihubmix": { - const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl - - if ( - isAihubmixStandardBaseURL(baseUrl) || - baseUrl === defaultBaseURL - ) { - const aihubmix = createAihubmix({ - apiKey, - appCode: AIHUBMIX_APP_CODE, - }) - model = aihubmix(modelId) - } else { - const aihubmixCompatible = createOpenAI({ - apiKey, - baseURL: baseUrl, - }) - model = aihubmixCompatible.chat(modelId) - } - break - } - - case "deepseek": { - if (baseUrl || apiKey) { - const ds = createDeepSeek({ - apiKey, - ...(baseUrl && { baseURL: baseUrl }), - }) - model = ds(modelId) - } else { - model = deepseek(modelId) - } - break - } - - case "siliconflow": { - const sf = createOpenAI({ - apiKey, - baseURL: baseUrl || "https://api.siliconflow.cn/v1", - }) - model = sf.chat(modelId) - break - } - - case "ollama": { - // SECURITY: Mirror ai-providers.ts guard — only use server - // OLLAMA_API_KEY when the URL is also from server config. - const ollamaApiKey = baseUrl - ? apiKey || undefined - : apiKey || process.env.OLLAMA_API_KEY || undefined - const ollamaProvider = createOllama({ - baseURL: - baseUrl || - process.env.OLLAMA_BASE_URL || - "https://ollama.com/api", - ...(ollamaApiKey && { - headers: { Authorization: `Bearer ${ollamaApiKey}` }, - }), - }) - model = ollamaProvider(modelId) - break - } - - case "gateway": { - const gw = createGateway({ - apiKey, - ...(baseUrl && { baseURL: baseUrl }), - }) - model = gw(modelId) - break - } - - case "edgeone": { - // EdgeOne uses OpenAI-compatible API via Edge Functions - // Need to pass cookies for EdgeOne Pages authentication - const cookieHeader = req.headers.get("cookie") || "" - const edgeone = createOpenAI({ - apiKey: "edgeone", // EdgeOne doesn't require API key - baseURL: baseUrl || "/api/edgeai", - headers: { - cookie: cookieHeader, - }, - }) - model = edgeone.chat(modelId) - break - } - - case "sglang": { - // SGLang is OpenAI-compatible - const sglang = createOpenAI({ - apiKey: apiKey || "not-needed", - baseURL: baseUrl || "http://127.0.0.1:8000/v1", - }) - model = sglang.chat(modelId) - break - } - - case "doubao": { - // ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others - const doubaoBaseUrl = - baseUrl || "https://ark.cn-beijing.volces.com/api/v3" - const lowerModelId = modelId.toLowerCase() - if ( - lowerModelId.includes("deepseek") || - lowerModelId.includes("kimi") - ) { - const doubao = createDeepSeek({ - apiKey, - baseURL: doubaoBaseUrl, - }) - model = doubao(modelId) - } else { - const doubao = createOpenAI({ - apiKey, - baseURL: doubaoBaseUrl, - }) - model = doubao.chat(modelId) - } - break - } - - case "modelscope": { - const baseURL = - baseUrl || "https://api-inference.modelscope.cn/v1" - const startTime = Date.now() - - try { - // Initiate a streaming request (required for QwQ-32B and certain Qwen3 models) - const response = await fetch( - `${baseURL}/chat/completions`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: modelId, - messages: [ - { role: "user", content: "Say 'OK'" }, - ], - max_tokens: 20, - stream: true, - enable_thinking: false, - }), - }, - ) - - if (!response.ok) { - const errorText = await response.text() - throw new Error( - `ModelScope API error (${response.status}): ${errorText}`, - ) - } - - const contentType = - response.headers.get("content-type") || "" - const isValidStreamingResponse = - response.status === 200 && - (contentType.includes("text/event-stream") || - contentType.includes("application/json")) - - if (!isValidStreamingResponse) { - throw new Error( - `Unexpected response format: ${contentType}`, - ) - } - - const responseTime = Date.now() - startTime - - if (response.body) { - response.body.cancel().catch(() => { - /* Ignore cancellation errors */ - }) - } - - return NextResponse.json({ - valid: true, - responseTime, - note: "ModelScope model validated (using streaming API)", - }) - } catch (error) { - console.error( - "[validate-model] ModelScope validation failed:", - error, - ) - throw error - } - } - - case "minimax": { - const rawUrl = - baseUrl || - PROVIDER_INFO.minimax?.defaultBaseUrl || - "https://api.minimaxi.com/anthropic" - const { baseURL: minimaxBaseUrl, isAnthropicCompatible } = - normalizeMiniMaxBaseURL(rawUrl) - - if (isAnthropicCompatible) { - const minimax = createAnthropic({ - apiKey, - baseURL: minimaxBaseUrl, - }) - model = minimax.chat(modelId) - } else { - const minimax = createOpenAI({ - apiKey, - baseURL: minimaxBaseUrl, - }) - model = minimax.chat(modelId) - } - break - } - - // GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible - case "glm": - case "qwen": - case "kimi": - case "qiniu": - case "novita": - case "atlascloud": - case "mimo": { - const baseURL = - baseUrl || - PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl || - "" - - if (!baseURL) { - return NextResponse.json( - { - valid: false, - error: `No base URL configured for provider: ${provider}`, - }, - { status: 400 }, - ) - } - - const openai = createOpenAI({ - apiKey, - baseURL, - }) - model = openai.chat(modelId) - break - } - - default: - return NextResponse.json( - { valid: false, error: `Unknown provider: ${provider}` }, - { status: 400 }, - ) + // The Test button checks the user's own provider. On the server's + // keys (Ollama Cloud without a key or URL) anyone could run any model. + if ( + usesServerCredentials(provider, { + apiKey, + baseUrl, + awsAccessKeyId, + awsSecretAccessKey, + vertexApiKey, + }) + ) { + return NextResponse.json( + { valid: false, error: "API key is required" }, + { status: 400 }, + ) } - // Make a minimal test request - const startTime = Date.now() - await generateText({ - model, - prompt: "Say 'OK'", - maxOutputTokens: 20, + // On the deployment's own endpoints a Test runs a model as a chat + // does, so with the quota on it counts as a chat request (an + // admin's Test of the server's URL does not) + const userId = getUserIdFromRequest(req) + if ( + isQuotaEnabled() && + !serverUrl && + userId !== "anonymous" && + (await usesServerEndpoint( + provider, + normalizeBaseUrl(body.baseUrl ?? ""), + apiKey, + )) + ) { + const quotaCheck = await checkAndIncrementRequest(userId, { + requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10, + tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000, + tpm: Number(process.env.TPM_LIMIT) || 20000, + }) + if (!quotaCheck.allowed) { + return NextResponse.json( + { valid: false, error: quotaCheck.error }, + { status: 429 }, + ) + } + } + + // The same model the chat would use. A client base URL makes it + // refuse redirects to internal hosts. + const { model } = getAIModel({ + provider, + modelId, + apiKey, + baseUrl, + trustedBaseUrl: serverUrl, + awsAccessKeyId, + awsSecretAccessKey, + awsRegion, + // Temporary AWS credentials need it, as in the chat + awsSessionToken, + vertexApiKey, + // EdgeOne checks the Pages cookies and the access code + ...(provider === "edgeone" && { + headers: { + cookie: req.headers.get("cookie") || "", + "x-access-code": req.headers.get("x-access-code") || "", + }, + }), }) + + // Streaming, like the chat (some models only stream). Stop at the + // first tool call; a reasoning model that runs out of tokens first + // proves the connection but not tool support. + const startTime = Date.now() + const result = streamText({ + model, + prompt: "Call the ping tool.", + tools: { ping: PING_TOOL }, + maxOutputTokens: 1024, + maxRetries: 0, + abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS), + }) + let calledTool = false + let finishReason: string | undefined + for await (const part of result.fullStream) { + if (part.type === "error") throw part.error + // The timeout ends the stream with an abort part, not an error + if (part.type === "abort") { + const timeout = new Error( + `The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`, + ) + timeout.name = "TimeoutError" + throw timeout + } + if (part.type === "tool-call") { + calledTool = true + break + } + if (part.type === "finish") finishReason = part.finishReason + } const responseTime = Date.now() - startTime return NextResponse.json({ valid: true, responseTime, + ...(!calledTool && + finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }), }) } catch (error) { console.error("[validate-model] Error:", error) - let errorMessage = "Validation failed" - if (error instanceof Error) { - // Extract meaningful error message - if ( - error.message.includes("401") || - error.message.includes("Unauthorized") - ) { - errorMessage = "Invalid API key" - } else if ( - error.message.includes("404") || - error.message.includes("not found") - ) { - errorMessage = "Model not found" - } else if ( - error.message.includes("429") || - error.message.includes("rate limit") - ) { - errorMessage = "Rate limited - try again later" - } else if (error.message.includes("ECONNREFUSED")) { - errorMessage = "Cannot connect to server" - } else { - errorMessage = error.message.slice(0, 100) - } - } - + const { code, message } = classifyLLMError(error) return NextResponse.json( - { valid: false, error: errorMessage }, + { valid: false, code, error: message }, { status: 200 }, // Return 200 so client can read error message ) } diff --git a/app/api/verify-access-code/route.ts b/app/api/verify-access-code/route.ts index d69f59d1..55cbc94b 100644 --- a/app/api/verify-access-code/route.ts +++ b/app/api/verify-access-code/route.ts @@ -1,29 +1,9 @@ +import { checkAccessCode } from "@/lib/access-code" + export async function POST(req: Request) { - const accessCodes = - process.env.ACCESS_CODE_LIST?.split(",") - .map((code) => code.trim()) - .filter(Boolean) || [] - - // If no access codes configured, verification always passes - if (accessCodes.length === 0) { - return Response.json({ - valid: true, - message: "No access code required", - }) - } - - const accessCodeHeader = req.headers.get("x-access-code") - - if (!accessCodeHeader) { + if (checkAccessCode(req)) { return Response.json( - { valid: false, message: "Access code is required" }, - { status: 401 }, - ) - } - - if (!accessCodes.includes(accessCodeHeader)) { - return Response.json( - { valid: false, message: "Invalid access code" }, + { valid: false, message: "Invalid or missing access code" }, { status: 401 }, ) } diff --git a/biome.json b/biome.json index 0bdb72c8..bf56b8b0 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,12 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!public"] + "includes": [ + "**", + "!public", + "!packages/mcp-server/src/preview", + "!lib/model-catalog.json" + ] }, "formatter": { "enabled": true, diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 66635ef5..0f375925 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -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 - onUrlChange?: (data: Map) => void + onUrlChange?: Dispatch>> sessionId?: string error?: Error | null @@ -244,6 +257,11 @@ export const ChatInput = forwardRef( // 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( } 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( 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( 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( 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() - 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() - newUrlData.delete(url) - onUrlChange(newUrlData) + onUrlChange((prev) => { + const next = new Map(prev) + next.delete(url) + return next + }) showErrorToast( {error instanceof Error @@ -463,11 +486,12 @@ export const ChatInput = forwardRef( 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( 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( ) : ( + )} ) }, diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 66f44b61..c378a3e1 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -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(" { + // Waits for the reply to its own export, by its tag + const tag = saveToHistory ? onExport() : handleExportWithoutHistory() return Promise.race([ new Promise((resolve) => { - resolverRef.current = resolve - if (saveToHistory) { - onExport() - } else { - handleExportWithoutHistory() - } + if (tag) exportResolversRef.current[tag] = resolve }), new Promise((_, 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>(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>(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(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 + 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(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()) // 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) => { - 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) => { + 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 */}
setShowModelConfigDialog(true)} messages={messages} setInput={setInput} setFiles={handleFileChange} diff --git a/components/chat/ChatLobby.tsx b/components/chat/ChatLobby.tsx index 28f98ec8..9f6425bf 100644 --- a/components/chat/ChatLobby.tsx +++ b/components/chat/ChatLobby.tsx @@ -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 === " " diff --git a/components/chat/TemplateCreateDialog.tsx b/components/chat/TemplateCreateDialog.tsx index 5477b641..c88a5be9 100644 --- a/components/chat/TemplateCreateDialog.tsx +++ b/components/chat/TemplateCreateDialog.tsx @@ -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) { diff --git a/components/chat/TemplateEditDialog.tsx b/components/chat/TemplateEditDialog.tsx index 0ddcd21a..3a380034 100644 --- a/components/chat/TemplateEditDialog.tsx +++ b/components/chat/TemplateEditDialog.tsx @@ -39,16 +39,16 @@ export function TemplateEditDialog({ const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(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 diff --git a/components/chat/TemplatePanel.tsx b/components/chat/TemplatePanel.tsx index c91220fd..45e43da1 100644 --- a/components/chat/TemplatePanel.tsx +++ b/components/chat/TemplatePanel.tsx @@ -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 = ( + + ) + const importMessageBox = importMessage && ( +
+ {importMessage.text} +
+ ) + // Empty state: no templates at all if (!loading && templates.length === 0) { return ( @@ -332,6 +359,18 @@ export function TemplatePanel({ {dict.templates.createFirst} + + {importInput} + {importMessageBox && ( +
{importMessageBox}
+ )} {dict.templates.importTemplates} - + {importInput} {/* Import message */} - {importMessage && ( -
- {importMessage.text} -
- )} + {importMessageBox}
{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 === " " diff --git a/components/chat/ToolCallCard.tsx b/components/chat/ToolCallCard.tsx index ca0a4c17..8ada80e2 100644 --- a/components/chat/ToolCallCard.tsx +++ b/components/chat/ToolCallCard.tsx @@ -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 (
- {operations.map((op, index) => ( + {shown.map((op, index) => (
@@ -40,13 +46,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) { {op.operation} - cell_id: {op.cell_id} + cell_id: {text(op.cell_id)}
- {op.new_xml && ( + {text(op.new_xml) && (
-                                {op.new_xml}
+                                {text(op.new_xml)}
                             
)} @@ -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 ? ( - - Truncated - - ) : ( - - Error - - ) - })()} + (isTruncated ? ( + + Truncated + + ) : ( + + Error + + ))} {input && Object.keys(input).length > 0 && ( + )} + {/* modal: the dialog blocks the + wheel outside itself, and the + list is rendered outside it */} + + + + + + + + + + { + dict + .modelConfig + .noModelsFound } - value={ - modelId - } - className="font-mono text-xs" - > - {modelId} - - ), - )} - - + + {availableSuggestions.map( + (model) => ( + { + handleAddModel( + model.id, + ) + setModelPickerOpen( + false, + ) + }} + className="font-mono text-xs" + > + + { + model.id + } + + {supportsTools( + model, + ) === + false && ( + + { + dict + .modelConfig + .noTools + } + + )} + + ), + )} + + + +
} > + {fetchModelsError && ( +

+ {fetchModelsError} +

+ )} {/* Model List */}
{selectedProvider.models.length === @@ -940,7 +1191,7 @@ export function ModelConfigDialog({ ) : (
{selectedProvider.models.map( - (model, index) => ( + (model) => (
{/* Status icon */}
- {validatingModelIndex !== - null && - index === - validatingModelIndex ? ( + {validatingModelIds.has( + model.id, + ) ? ( // Currently validating
- ) : validatingModelIndex !== - null && - index > - validatingModelIndex && - model.validated === - undefined ? ( - // Queued -
- -
) : model.validated === true ? ( - // Valid -
+ // Valid, with the time the test took +
) : model.validated === @@ -989,7 +1236,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({ }

)} + {!model.validationWarning && + getModelInfo?.( + selectedProvider.provider, + model.modelId, + )?.tools === + false && ( +

+ { + dict + .modelConfig + .mayNotDraw + } +

+ )} + {model.validated && + model.validationWarning && ( +

+ { + model.validationWarning + } +

+ )} {/* Show edit error inline */} {editError?.modelId === model.id && ( diff --git a/components/model-selector.tsx b/components/model-selector.tsx index 3debb9ef..271e964c 100644 --- a/components/model-selector.tsx +++ b/components/model-selector.tsx @@ -264,9 +264,13 @@ export function ModelSelector({ (model) => ( handleSelect( model.id, @@ -351,9 +355,11 @@ export function ModelSelector({ (model) => ( handleSelect( model.id, diff --git a/components/provider-credentials-fields.tsx b/components/provider-credentials-fields.tsx index 8a9bf0d1..4b97716e 100644 --- a/components/provider-credentials-fields.tsx +++ b/components/provider-credentials-fields.tsx @@ -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 */}
- +
+ + {info.apiKeyUrl && ( + + {dict.modelConfig.getApiKey} + + )} +
{renderSecret({ field: "apiKey", id: "api-key" })}
@@ -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 && ( +

+ {formatMessage(dict.modelConfig.requestUrl, { + url: requestUrl, + })} +

+ )} {provider === "minimax" && (

{dict.modelConfig.minimaxBaseUrlHint} diff --git a/contexts/diagram-context.tsx b/contexts/diagram-context.tsx index 6a139a08..0295a98f 100644 --- a/contexts/diagram-context.tsx +++ b/contexts/diagram-context.tsx @@ -1,26 +1,34 @@ "use client" import type React from "react" -import { createContext, useContext, useEffect, useRef, useState } from "react" +import { createContext, useCallback, useContext, useRef, useState } from "react" import type { DrawIoEmbedRef, EventExport } from "react-drawio" import { toast } from "sonner" import type { ExportFormat } from "@/components/save-dialog" import { getApiEndpoint } from "@/lib/base-path" import { - extractDiagramXML, - isRealDiagram, - validateAndFixXml, -} from "../lib/utils" + BLANK_MXFILE, + normalizeToMxfile, +} from "@/packages/mcp-server/src/pages.ts" +import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts" +import { extractDiagramXML, isRealDiagram } from "../lib/utils" interface DiagramContextType { chartXML: string + // chartXML right away, before the re-render (loadDiagram sets both) + chartXMLRef: React.MutableRefObject latestSvg: string diagramHistory: { svg: string; xml: string }[] setDiagramHistory: (history: { svg: string; xml: string }[]) => void loadDiagram: (chart: string, skipValidation?: boolean) => string | null - handleExport: () => void - handleExportWithoutHistory: () => void - resolverRef: React.MutableRefObject<((value: string) => void) | null> + // Both return the export's tag (empty when draw.io is not there yet) + handleExport: () => string + handleExportWithoutHistory: () => string + // Pending exports by tag; a history or plain export's resolver gets the + // first page's XML + exportResolversRef: React.MutableRefObject< + Record void> + > drawioRef: React.MutableRefObject handleDiagramExport: (data: EventExport) => void handleDiagramAutoSave: (data: { xml?: string }) => void @@ -42,6 +50,12 @@ interface DiagramContextType { const DiagramContext = createContext(undefined) +// Every export carries a tag in the request's `message` field. draw.io +// echoes the request back in the export event, so each result reaches its +// own caller. Tags end in a request number, so a late result never answers +// a newer request. +type ExportTag = "thumbnail" | "validation" + export function DiagramProvider({ children }: { children: React.ReactNode }) { const [chartXML, setChartXML] = useState("") const [latestSvg, setLatestSvg] = useState("") @@ -52,11 +66,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { const [showSaveDialog, setShowSaveDialog] = useState(false) const hasCalledOnLoadRef = useRef(false) const drawioRef = useRef(null) - const resolverRef = useRef<((value: string) => void) | null>(null) - // Resolver for PNG export (used for VLM validation) - const pngResolverRef = useRef<((value: string) => void) | null>(null) - // Track if we're expecting an export for history (user-initiated) - const expectHistoryExportRef = useRef(false) + // Pending exports, keyed by their export tag + const exportResolversRef = useRef< + Record void> + >({}) + // Pending history exports: the document each one was asked for + const historyXmlRef = useRef(new Map()) + const exportSeqRef = useRef(0) // Track latest chartXML for restoration after remount const chartXMLRef = useRef("") @@ -76,94 +92,85 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { setIsDrawioReady(false) } - // Keep chartXMLRef in sync with state for restoration after remount - useEffect(() => { - chartXMLRef.current = chartXML - }, [chartXML]) - - // Track if we're expecting an export for file save (stores raw export data) - const saveResolverRef = useRef<{ - resolver: ((data: string, fullDiagramXML?: string) => void) | null - format: ExportFormat | null - }>({ resolver: null, format: null }) + // Update chartXML and its ref together, so callbacks that read the ref + // (export handler, autosave) see the new value right away + const updateChartXML = (xml: string) => { + chartXMLRef.current = xml + setChartXML(xml) + } const handleExport = () => { - if (drawioRef.current) { - // Mark that this export should be saved to history - expectHistoryExportRef.current = true - drawioRef.current.exportDiagram({ - format: "xmlsvg", - }) - } + if (!drawioRef.current) return "" + // Save this export to history, with the document shown now: + // chartXML can change before the result comes back + const tag = `history-${++exportSeqRef.current}` + historyXmlRef.current.set(tag, chartXMLRef.current) + drawioRef.current.exportDiagram({ + format: "xmlsvg", + message: tag, + }) + return tag } const handleExportWithoutHistory = () => { - if (drawioRef.current) { - // Export without saving to history (for edit_diagram fetching current state) - drawioRef.current.exportDiagram({ - format: "xmlsvg", - }) - } + if (!drawioRef.current) return "" + // Export without saving to history (for edit_diagram fetching current state) + const tag = `fetch-${++exportSeqRef.current}` + drawioRef.current.exportDiagram({ + format: "xmlsvg", + message: tag, + }) + return tag } - // Get current diagram as SVG for thumbnail (used by session storage) - const getThumbnailSvg = async (): Promise => { + // Export with a tag in `message` (draw.io echoes it back in the export + // event) and wait for that result. Resolves to null on timeout, which is + // expected occasionally. + // (Reads refs only, so it keeps one identity) + const requestTaggedExport = useCallback( + (tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) => + new Promise((resolve) => { + const id = `${tag}-${++exportSeqRef.current}` + const finish = (value: string | null) => { + clearTimeout(timer) + delete exportResolversRef.current[id] + resolve(value) + } + const timer = setTimeout(() => finish(null), timeoutMs) + exportResolversRef.current[id] = finish + drawioRef.current?.exportDiagram({ format, message: id }) + }), + [], + ) + + // Get current diagram as SVG for thumbnail (used by session storage). + // One identity: the chat's auto-save depends on it, and each thumbnail + // renders this provider again (latestSvg), which would otherwise start + // the next save + const getThumbnailSvg = useCallback(async (): Promise => { if (!drawioRef.current) return null // Don't export if diagram is empty - if (!isRealDiagram(chartXML)) return null - - try { - const svgData = await Promise.race([ - new Promise((resolve) => { - resolverRef.current = resolve - drawioRef.current?.exportDiagram({ format: "xmlsvg" }) - }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Export timeout")), 3000), - ), - ]) + if (!isRealDiagram(chartXMLRef.current)) return null + // xmlsvg exports return an SVG data URL + const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000) + if (svgData?.startsWith("data:image/svg")) { // Update latestSvg so it's available for future saves - if (svgData?.includes(" => { if (!drawioRef.current) return null // Don't export if diagram is empty - if (!isRealDiagram(chartXML)) return null + if (!isRealDiagram(chartXMLRef.current)) return null - try { - const pngData = await Promise.race([ - new Promise((resolve) => { - pngResolverRef.current = resolve - drawioRef.current?.exportDiagram({ format: "png" }) - }), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("PNG export timeout")), - 5000, - ), - ), - ]) - - // PNG data should be a base64 data URL - if (pngData?.startsWith("data:image/png")) { - return pngData - } - return null - } catch { - // Timeout is expected occasionally - don't log as error - return null - } + const pngData = await requestTaggedExport("validation", "png", 5000) + // PNG data should be a base64 data URL + return pngData?.startsWith("data:image/png") ? pngData : null } const loadDiagram = ( @@ -172,9 +179,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { ): string | null => { let xmlToLoad = chart - // Validate XML structure before loading (unless skipped for internal use) + // Validate XML structure before loading (unless skipped for internal + // use). Not strict: the XML may hold the user's own diagram, and the + // tool handlers check model XML strictly before it gets here. if (!skipValidation) { - const validation = validateAndFixXml(chart) + const validation = validateAndFixXml(chart, { strict: false }) if (!validation.valid) { console.warn( "[loadDiagram] Validation error:", @@ -193,7 +202,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { } // Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool) - setChartXML(xmlToLoad) + updateChartXML(xmlToLoad) if (drawioRef.current) { drawioRef.current.load({ @@ -205,26 +214,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { } const handleDiagramExport = (data: EventExport) => { - // Handle PNG export for VLM validation - if (pngResolverRef.current && data.data?.startsWith("data:image/png")) { - pngResolverRef.current(data.data) - pngResolverRef.current = null + // Thumbnail, validation PNG and file save exports go only to their + // own caller + const tag = data.message?.message + if (/^(thumbnail|validation|save)-/.test(tag ?? "")) { + exportResolversRef.current[tag as string]?.(data.data, data.xml) return } - // Handle save to file if requested (process raw data before extraction) - if (saveResolverRef.current.resolver) { - const format = saveResolverRef.current.format - saveResolverRef.current.resolver(data.data, data.xml) - saveResolverRef.current = { resolver: null, format: null } - // For non-xmlsvg formats, skip XML extraction as it will fail - // Only drawio (which uses xmlsvg internally) has the content attribute - // xmlsvg is saved directly as SVG file, no need for extraction - if (format === "png" || format === "svg" || format === "xmlsvg") { - return - } - } - // Don't write chartXML here: exports don't change the diagram, and // data.xml from xmlsvg exports has compressed payloads that // would break edit_diagram/display_diagram. Autosave keeps chartXML @@ -235,39 +232,49 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { // Only add to history if this was a user-initiated export // Limit to 20 entries to prevent memory leaks during long sessions const MAX_HISTORY_SIZE = 20 - if (expectHistoryExportRef.current) { + const askedXml = + tag !== undefined ? historyXmlRef.current.get(tag) : undefined + if (askedXml !== undefined) { + historyXmlRef.current.delete(tag as string) + // Store the full multi-page document (extractedXML is only the + // first page), so restoring a version keeps every page + const historyXml = askedXml || extractedXML setDiagramHistory((prev) => { const newHistory = [ ...prev, { svg: data.data, - xml: extractedXML, + xml: historyXml, }, ] // Keep only the last MAX_HISTORY_SIZE entries (circular buffer) return newHistory.slice(-MAX_HISTORY_SIZE) }) - expectHistoryExportRef.current = false } - if (resolverRef.current) { - resolverRef.current(extractedXML) - resolverRef.current = null + // The chat's own export (onFetchChart), not another one in flight + const resolve = + tag !== undefined ? exportResolversRef.current[tag] : undefined + if (resolve) { + delete exportResolversRef.current[tag as string] + resolve(extractedXML) } } + // react-drawio registers this callback once per iframe mount, so it must + // read refs: state captured in its closure would stay stale after a remount const handleDiagramAutoSave = (data: { xml?: string }) => { if (!data?.xml) return - // Don't overwrite a pending restore - if we have a real diagram in state - // but DrawIO isn't ready yet, it means we're waiting to restore - if (!isDrawioReady && isRealDiagram(chartXML)) { + // Don't overwrite a pending restore - if we have a real diagram but + // DrawIO hasn't loaded yet, it means we're waiting to restore + if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) { return } - setChartXML(data.xml) + updateChartXML(data.xml) } const clearDiagram = () => { - const emptyDiagram = `` + const emptyDiagram = BLANK_MXFILE // Skip validation for trusted internal template (loadDiagram also sets chartXML) loadDiagram(emptyDiagram, true) setLatestSvg("") @@ -289,83 +296,88 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { const drawioFormat = format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format - // Set up the resolver before triggering export - saveResolverRef.current = { - resolver: (exportData: string, fullDiagramXML?: string) => { - let fileContent: string | Blob - let mimeType: string - let extension: string + // Each save has its own tag, so two at once never swap results + const tag = `save-${++exportSeqRef.current}` + exportResolversRef.current[tag] = ( + exportData: string, + fullDiagramXML?: string, + ) => { + delete exportResolversRef.current[tag] + let fileContent: string | Blob + let mimeType: string + let extension: string - if (format === "drawio") { - // Prefer the complete document from the export event so all pages are saved. - const xml = fullDiagramXML?.trim() - ? fullDiagramXML - : extractDiagramXML(exportData) - let xmlContent = xml - if (!xml.includes("${xml}` - } - fileContent = xmlContent - mimeType = "application/xml" - extension = ".drawio" - } else if (format === "png") { - // PNG data comes as base64 data URL - fileContent = exportData - mimeType = "image/png" - extension = ".png" - } else if (format === "xmlsvg") { - // Editable SVG: pass data URL directly (like PNG) - fileContent = exportData - mimeType = "image/svg+xml" - extension = ".drawio.svg" - } else { - // SVG format (view-only) - fileContent = exportData - mimeType = "image/svg+xml" - extension = ".svg" - } + if (format === "drawio") { + // Prefer the complete document from the export event so all pages are saved. + const xml = fullDiagramXML?.trim() + ? fullDiagramXML + : extractDiagramXML(exportData) + fileContent = + normalizeToMxfile(xml, { + pageId: "page-1", + pageName: "Page-1", + }) ?? xml + mimeType = "application/xml" + extension = ".drawio" + } else if (format === "png") { + // PNG data comes as base64 data URL + fileContent = exportData + mimeType = "image/png" + extension = ".png" + } else if (format === "xmlsvg") { + // Editable SVG: pass data URL directly (like PNG) + fileContent = exportData + mimeType = "image/svg+xml" + extension = ".drawio.svg" + } else { + // SVG format (view-only) + fileContent = exportData + mimeType = "image/svg+xml" + extension = ".svg" + } - // Log save event to Langfuse (flags the trace) - logSaveToLangfuse(filename, format, sessionId) + // Log save event to Langfuse (flags the trace) + logSaveToLangfuse(filename, format, sessionId) - // Handle download - let url: string - if ( - typeof fileContent === "string" && - fileContent.startsWith("data:") - ) { - // Already a data URL (PNG) - url = fileContent - } else { - const blob = new Blob([fileContent], { type: mimeType }) - url = URL.createObjectURL(blob) - } + // Handle download + let url: string + if ( + typeof fileContent === "string" && + fileContent.startsWith("data:") + ) { + // Already a data URL (PNG) + url = fileContent + } else { + const blob = new Blob([fileContent], { type: mimeType }) + url = URL.createObjectURL(blob) + } - const a = document.createElement("a") - a.href = url - a.download = `${filename}${extension}` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + const a = document.createElement("a") + a.href = url + a.download = `${filename}${extension}` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) - // Show success toast after download is initiated - if (successMessage) { - toast.success(successMessage, { - position: "bottom-left", - duration: 2500, - }) - } + // Show success toast after download is initiated + if (successMessage) { + toast.success(successMessage, { + position: "bottom-left", + duration: 2500, + }) + } - // Delay URL revocation to ensure download completes - if (!url.startsWith("data:")) { - setTimeout(() => URL.revokeObjectURL(url), 100) - } - }, - format, + // Delay URL revocation to ensure download completes + if (!url.startsWith("data:")) { + setTimeout(() => URL.revokeObjectURL(url), 100) + } } // Export diagram - callback will be handled in handleDiagramExport - drawioRef.current.exportDiagram({ format: drawioFormat }) + drawioRef.current.exportDiagram({ + format: drawioFormat, + message: tag, + }) } // Log save event to Langfuse (just flags the trace, doesn't send content) @@ -389,13 +401,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) { = { "deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324", } -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -} - /** - * Create standardized response with CORS headers + * Create standardized JSON response */ function createResponse(body: any, status = 200, extraHeaders = {}): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", - ...CORS_HEADERS, ...extraHeaders, }, }) } -/** - * Handle OPTIONS request for CORS preflight - */ -function handleOptionsRequest(): Response { - return new Response(null, { - headers: { - ...CORS_HEADERS, - "Access-Control-Max-Age": "86400", - }, - }) +// Only the app's own server (/api/chat, /api/validate-model) calls this +// function, so no CORS headers are sent: other sites' pages can't call it +// from a browser and spend the deployment's Edge AI quota. +// Same rule as lib/access-code.ts, but reading the edge function's env. +// No codes configured (or env unavailable) means no check. +function hasValidAccessCode(request: Request, env: any): boolean { + const accessCodes: string[] = + env?.ACCESS_CODE_LIST?.split(",") + .map((code: string) => code.trim()) + .filter(Boolean) || [] + if (accessCodes.length === 0) return true + const accessCode = request.headers.get("x-access-code") + return !!accessCode && accessCodes.includes(accessCode) } -export async function onRequest({ request, env: _env }: any) { - if (request.method === "OPTIONS") { - return handleOptionsRequest() +export async function onRequest({ request, env }: any) { + // Requiring JSON also makes any cross-site browser request need a CORS + // preflight, which fails without CORS headers. Only the type before any + // parameters counts: "text/plain; x=application/json" needs none. + const mediaType = (request.headers.get("content-type") ?? "") + .split(";")[0] + .trim() + .toLowerCase() + if (request.method !== "POST" || mediaType !== "application/json") { + return createResponse( + { + error: { + message: "Expected a POST request with a JSON body", + type: "invalid_request_error", + }, + }, + 400, + ) + } + + if (!hasValidAccessCode(request, env)) { + return createResponse( + { + error: { + message: "Invalid or missing access code", + type: "invalid_request_error", + }, + }, + 401, + ) } request.headers.delete("accept-encoding") @@ -153,7 +176,7 @@ export async function onRequest({ request, env: _env }: any) { type: "invalid_request_error", }, }, - 429, + 400, ) } @@ -216,7 +239,6 @@ export async function onRequest({ request, env: _env }: any) { "Cache-Control": "no-cache, no-store, no-transform", "X-Accel-Buffering": "no", Connection: "keep-alive", - ...CORS_HEADERS, }, }) } catch (error: any) { diff --git a/electron/electron.d.ts b/electron/electron.d.ts index e268b100..4c54bdcc 100644 --- a/electron/electron.d.ts +++ b/electron/electron.d.ts @@ -60,10 +60,6 @@ declare global { maximize: () => void /** Close the window */ close: () => void - /** Open file dialog and return file path */ - openFile: () => Promise - /** Save data to file via save dialog */ - saveFile: (data: string) => Promise /** Get proxy configuration */ getProxy: () => Promise /** Set proxy configuration (saves and restarts server) */ @@ -74,6 +70,15 @@ declare global { > /** Set user's preferred locale */ setUserLocale: (locale: string) => Promise + /** + * Call back after the server restarted on the same port (another + * preset); returns a function that stops the calls + */ + onServerRestarted?: (callback: () => void) => () => void + /** A chat was saved: open this port next launch */ + chatSaved?: () => Promise + /** The page loaded with this many chats */ + chatsLoaded?: (count: number) => Promise } /** Settings window Electron API */ diff --git a/electron/main/app-menu.ts b/electron/main/app-menu.ts index bd778294..bb38ab9d 100644 --- a/electron/main/app-menu.ts +++ b/electron/main/app-menu.ts @@ -32,6 +32,68 @@ export function rebuildAppMenu(): void { buildAppMenu() } +// Number of the latest preset switch +let lastSwitch = 0 + +/** + * Apply a preset and restart the server so it takes effect. + * If the restart fails, go back to the previous preset and restart again, + * so the running server always matches the saved current preset. + * Throws an error describing the outcome on failure. + */ +export async function switchPreset( + id: string, +): Promise> { + const previousPresetId = getCurrentPresetId() + const env = applyPresetToEnv(id) + if (!env) { + throw new Error("Preset not found") + } + const switchNumber = ++lastSwitch + rebuildAppMenu() + + // In development, scripts/electron-dev.mjs restarts the Next.js dev server + if (!app.isPackaged) { + return env + } + + try { + await restartNextServer() + return env + } catch (error) { + console.error("Failed to restart server:", error) + const reason = error instanceof Error ? error.message : String(error) + + // A newer switch started meanwhile (also of this same preset), or + // the preset was deleted: its own restart follows, and undoing + // would lose that choice + if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) { + throw new Error( + `The server could not be restarted.\n\nError: ${reason}`, + ) + } + + // Revert to previous preset on failure + if (!previousPresetId || !applyPresetToEnv(previousPresetId)) { + setCurrentPreset(null) + } + // Rebuild menu to restore previous checkmark state + rebuildAppMenu() + + try { + await restartNextServer() + } catch (retryError) { + console.error("Failed to restart server again:", retryError) + throw new Error( + `The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`, + ) + } + throw new Error( + `The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`, + ) + } +} + /** * Get the menu template with translations */ @@ -192,32 +254,14 @@ function buildConfigMenu( type: "radio", checked: preset.id === currentPresetId, click: async () => { - const previousPresetId = getCurrentPresetId() - const env = applyPresetToEnv(preset.id) - - if (env) { - try { - await restartNextServer() - rebuildAppMenu() // Rebuild menu to update checkmarks - } catch (error) { - console.error("Failed to restart server:", error) - - // Revert to previous preset on failure - if (previousPresetId) { - applyPresetToEnv(previousPresetId) - } else { - setCurrentPreset(null) - } - - // Rebuild menu to restore previous checkmark state - rebuildAppMenu() - - // Show error dialog to notify user - dialog.showErrorBox( - "Configuration Error", - `Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`, - ) - } + try { + await switchPreset(preset.id) + } catch (error) { + // Show error dialog to notify user + dialog.showErrorBox( + "Configuration Error", + `Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`, + ) } }, })) diff --git a/electron/main/config-manager.ts b/electron/main/config-manager.ts index 234c79cc..1c3499f7 100644 --- a/electron/main/config-manager.ts +++ b/electron/main/config-manager.ts @@ -1,5 +1,11 @@ import { randomUUID } from "node:crypto" -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs" import path from "node:path" import { app, safeStorage } from "electron" @@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false * Warns if encryption is not available (API key stored in plaintext) */ function encryptValue(value: string): string { - if (!value) { + // Already encrypted (a value that could not be decrypted): keep it as is + // instead of wrapping it in a second layer of encryption + if (!value || value.startsWith(ENCRYPTED_PREFIX)) { return value } @@ -61,6 +69,7 @@ function encryptValue(value: string): string { /** * Decrypt a sensitive value using safeStorage * Returns the original value if it's not encrypted or decryption fails + * (so saving writes the stored ciphertext back unchanged) */ function decryptValue(value: string): string { if (!value || !value.startsWith(ENCRYPTED_PREFIX)) { @@ -150,6 +159,10 @@ function getConfigFilePath(): string { return path.join(userDataPath, CONFIG_FILE_NAME) } +// The presets file exists but the last read failed: a save now would +// replace the user's presets with the empty list that read returned +let presetsUnreadable = false + /** * Load presets from the config file * Decrypts sensitive fields automatically @@ -158,6 +171,25 @@ export function loadPresets(): ConfigPresetsFile { const configPath = getConfigFilePath() if (!existsSync(configPath)) { + // Nothing left that a save could overwrite + presetsUnreadable = false + return { + version: 1, + currentPresetId: null, + presets: [], + userLocale: undefined, + } + } + + let content: string + try { + content = readFileSync(configPath, "utf-8") + presetsUnreadable = false + } catch (error) { + // Often only for now (on Windows an antivirus scanner can hold the + // file): keep the file, and refuse saves based on this empty list + console.error("Failed to read config presets:", error) + presetsUnreadable = true return { version: 1, currentPresetId: null, @@ -167,7 +199,6 @@ export function loadPresets(): ConfigPresetsFile { } try { - const content = readFileSync(configPath, "utf-8") const data = JSON.parse(content) as ConfigPresetsFile // Decrypt sensitive fields in each preset @@ -179,6 +210,17 @@ export function loadPresets(): ConfigPresetsFile { return data } catch (error) { console.error("Failed to load config presets:", error) + // Move the unreadable file aside so the next save can't overwrite + // the user's presets with an empty list + const backupPath = `${configPath}.corrupt-${Date.now()}` + try { + renameSync(configPath, backupPath) + console.error(`Unreadable config presets moved to ${backupPath}`) + } catch (renameError) { + // Still there: refuse saves that would overwrite it + console.error("Failed to back up config presets:", renameError) + presetsUnreadable = true + } return { version: 1, currentPresetId: null, @@ -193,6 +235,11 @@ export function loadPresets(): ConfigPresetsFile { * Encrypts sensitive fields automatically */ export function savePresets(data: ConfigPresetsFile): void { + if (presetsUnreadable) { + throw new Error( + "The presets file could not be read, so it was not overwritten. Please try again.", + ) + } const configPath = getConfigFilePath() const userDataPath = app.getPath("userData") @@ -211,7 +258,11 @@ export function savePresets(data: ConfigPresetsFile): void { } try { - writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8") + // Write a temp file and rename it, so a crash mid-write can't leave + // a truncated config file + const tempPath = `${configPath}.tmp` + writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8") + renameSync(tempPath, configPath) } catch (error) { console.error("Failed to save config presets:", error) throw error @@ -307,9 +358,10 @@ export function deletePreset(id: string): boolean { data.presets.splice(index, 1) - // Clear current preset if it was deleted + // Clear current preset (and its env vars) if it was deleted if (data.currentPresetId === id) { data.currentPresetId = null + setPresetEnv(null) } savePresets(data) @@ -322,13 +374,15 @@ export function deletePreset(id: string): boolean { export function setCurrentPreset(id: string | null): boolean { const data = loadPresets() + let preset: ConfigPreset | null = null if (id !== null) { - const preset = data.presets.find((p) => p.id === id) + preset = data.presets.find((p) => p.id === id) || null if (!preset) { return false } } + setPresetEnv(preset) data.currentPresetId = id savePresets(data) return true @@ -365,78 +419,23 @@ const PROVIDER_ENV_MAP: Record = { } /** - * Apply preset environment variables to the current process - * Returns the environment variables that were applied - */ -export function applyPresetToEnv(id: string): Record | null { - const data = loadPresets() - const preset = data.presets.find((p) => p.id === id) - - if (!preset) { - return null - } - - const appliedEnv: Record = {} - const provider = preset.config.AI_PROVIDER?.toLowerCase() - - for (const [key, value] of Object.entries(preset.config)) { - if (value !== undefined && value !== "") { - // Map generic AI_API_KEY to provider-specific key - if ( - key === "AI_API_KEY" && - provider && - PROVIDER_ENV_MAP[provider] - ) { - const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey - if (providerApiKey) { - process.env[providerApiKey] = value - appliedEnv[providerApiKey] = value - } - } - // Map generic AI_BASE_URL to provider-specific key - else if ( - key === "AI_BASE_URL" && - provider && - PROVIDER_ENV_MAP[provider] - ) { - const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl - if (providerBaseUrl) { - process.env[providerBaseUrl] = value - appliedEnv[providerBaseUrl] = value - } - } - // Apply other env vars directly - else { - process.env[key] = value - appliedEnv[key] = value - } - } - } - - // Set as current preset - data.currentPresetId = id - savePresets(data) - - return appliedEnv -} - -/** - * Get environment variables from current preset + * Map a preset's config to environment variables * Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys */ -export function getCurrentPresetEnv(): Record { - const preset = getCurrentPreset() - if (!preset) { - return {} - } - +function presetToEnv(preset: ConfigPreset): Record { const env: Record = {} const provider = preset.config.AI_PROVIDER?.toLowerCase() for (const [key, value] of Object.entries(preset.config)) { if (value !== undefined && value !== "") { + // A key that could not be decrypted is useless to the server + if (value.startsWith(ENCRYPTED_PREFIX)) { + console.warn( + `Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`, + ) + } // Map generic AI_API_KEY to provider-specific key - if ( + else if ( key === "AI_API_KEY" && provider && PROVIDER_ENV_MAP[provider] @@ -466,6 +465,90 @@ export function getCurrentPresetEnv(): Record { return env } +/** + * Values that env vars had before a preset first set them + * (from the system or .env files), and the keys the active preset set + */ +const originalEnv: Record = {} +let presetEnvKeys: string[] = [] + +/** + * Replace the env vars of the previous preset with those of the given preset + * (null leaves no preset applied). Restoring first means switching presets + * never leaves the previous preset's base URL, model or key behind. + */ +function setPresetEnv(preset: ConfigPreset | null): Record { + for (const key of presetEnvKeys) { + if (originalEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = originalEnv[key] + } + } + + const env = preset ? presetToEnv(preset) : {} + for (const [key, value] of Object.entries(env)) { + if (!(key in originalEnv)) { + originalEnv[key] = process.env[key] + } + process.env[key] = value + } + presetEnvKeys = Object.keys(env) + + writeDevPresetEnv(env) + return env +} + +const DEV_ENV_FILE_NAME = "dev-preset-env.json" + +/** + * Development only: write the active preset's env vars (decrypted and mapped) + * for scripts/electron-dev.mjs, which restarts the Next.js dev server when + * this file changes. The dev server can't decrypt the config file itself. + */ +function writeDevPresetEnv(env: Record): void { + if (app.isPackaged) { + return + } + try { + const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME) + writeFileSync(filePath, JSON.stringify(env, null, 2), { + encoding: "utf-8", + mode: 0o600, + }) + } catch (error) { + console.error("Failed to write dev preset env:", error) + } +} + +/** + * Apply preset environment variables to the current process + * Returns the environment variables that were applied + */ +export function applyPresetToEnv(id: string): Record | null { + const data = loadPresets() + const preset = data.presets.find((p) => p.id === id) + + if (!preset) { + return null + } + + const appliedEnv = setPresetEnv(preset) + + // Set as current preset + data.currentPresetId = id + savePresets(data) + + return appliedEnv +} + +/** + * Apply the saved current preset's environment variables (used at startup) + */ +export function applyCurrentPresetToEnv(): void { + setPresetEnv(getCurrentPreset()) +} + /** * Get user's preferred locale from config * Returns undefined if not set diff --git a/electron/main/env-loader.ts b/electron/main/env-loader.ts index 8182b11a..563b3729 100644 --- a/electron/main/env-loader.ts +++ b/electron/main/env-loader.ts @@ -28,6 +28,25 @@ export function loadEnvFile(): void { console.log("No .env file found, using system environment variables") } +/** + * Index of the quote that closes a value starting with a quote, or -1. A + * backslash before the quote character escapes it, as in dotenv; the + * backslash stays in the value. As in dotenv, an escaped quote with only a + * comment or nothing after it still closes the value when no other quote + * does ("C:\dir\" keeps its trailing backslash). + */ +function findClosingQuote(value: string): number { + const quote = value[0] + let lastEscaped = -1 + for (let i = 1; i < value.length; i++) { + if (value[i] === "\\" && value[i + 1] === quote) { + i++ + if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i + } else if (value[i] === quote) return i + } + return lastEscaped +} + /** * Parse and load environment variables from a file */ @@ -48,12 +67,28 @@ function loadEnvFromFile(filePath: string): void { const key = trimmed.slice(0, equalIndex).trim() let value = trimmed.slice(equalIndex + 1).trim() - // Remove surrounding quotes + const quote = value[0] + const closingQuote = + quote === '"' || quote === "'" ? findClosingQuote(value) : -1 if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) + closingQuote > 0 && + /^\s*(#.*)?$/.test(value.slice(closingQuote + 1)) ) { - value = value.slice(1, -1) + // Quoted value, then nothing or a comment: keep what is + // inside the quotes, as dotenv reads it + value = value.slice(1, closingQuote) + } else { + // Unquoted value: drop an inline comment ("value # comment"). + // A value quoted from start to end with quotes inside (JSON + // with an apostrophe) loses only the outer two, as in dotenv. + value = value.replace(/\s+#.*$/, "") + if ( + closingQuote > 0 && + value.length > 1 && + value.endsWith(quote) + ) { + value = value.slice(1, -1) + } } // Don't override existing environment variables diff --git a/electron/main/index.ts b/electron/main/index.ts index 6c613da2..08313b1a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,12 +1,17 @@ import { app, BrowserWindow, dialog, shell } from "electron" import { buildAppMenu } from "./app-menu" -import { getCurrentPresetEnv } from "./config-manager" +import { applyCurrentPresetToEnv } from "./config-manager" import { loadEnvFile } from "./env-loader" import { registerIpcHandlers } from "./ipc-handlers" import { startNextServer, stopNextServer } from "./next-server" import { applyProxyToEnv } from "./proxy-manager" import { registerSettingsWindowHandlers } from "./settings-window" -import { createWindow, getMainWindow } from "./window-manager" +import { + createWindow, + getAppUrl, + getMainWindow, + isAppUrl, +} from "./window-manager" // Single instance lock const gotTheLock = app.requestSingleInstanceLock() @@ -28,16 +33,14 @@ if (!gotTheLock) { // Apply proxy settings from saved config applyProxyToEnv() - // Apply saved preset environment variables (overrides .env) - const presetEnv = getCurrentPresetEnv() - for (const [key, value] of Object.entries(presetEnv)) { - process.env[key] = value - } - - const isDev = process.env.NODE_ENV === "development" - let serverUrl: string | null = null + const isDev = !app.isPackaged app.whenReady().then(async () => { + // Apply saved preset environment variables (overrides .env). + // Must run after ready: on Windows and Linux safeStorage can't + // decrypt the API key before that. + applyCurrentPresetToEnv() + // Register IPC handlers registerIpcHandlers() registerSettingsWindowHandlers() @@ -46,6 +49,7 @@ if (!gotTheLock) { buildAppMenu() try { + let serverUrl: string if (isDev) { // Development: use the dev server URL serverUrl = @@ -69,8 +73,9 @@ if (!gotTheLock) { app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { - if (serverUrl) { - createWindow(serverUrl) + const appUrl = getAppUrl() + if (appUrl) { + createWindow(appUrl) } } }) @@ -87,24 +92,49 @@ if (!gotTheLock) { stopNextServer() }) + // Pages allowed inside app windows: the app server and draw.io + const isInAppUrl = (url: string): boolean => { + if (isAppUrl(url)) return true + try { + const { hostname } = new URL(url) + return ["diagrams.net", "draw.io"].some( + (domain) => + hostname === domain || hostname.endsWith(`.${domain}`), + ) + } catch { + return false + } + } + + const isWebUrl = (url: string): boolean => + url.startsWith("http://") || url.startsWith("https://") + // Open external links in default browser app.on("web-contents-created", (_, contents) => { contents.setWindowOpenHandler(({ url }) => { - // Allow diagrams.net iframe - if ( - url.includes("diagrams.net") || - url.includes("draw.io") || - url.startsWith("http://localhost") || - url.startsWith("http://127.0.0.1") - ) { + if (isInAppUrl(url)) { return { action: "allow" } } // Open other links in external browser - if (url.startsWith("http://") || url.startsWith("https://")) { + if (isWebUrl(url)) { shell.openExternal(url) return { action: "deny" } } return { action: "allow" } }) + + // Clicking a plain link would otherwise replace the app page with + // an external site that keeps the preload API. Only the page + // itself may navigate there; draw.io stays in its frame (this event + // is for the main frame only) + contents.on("will-navigate", (event) => { + if (isAppUrl(event.url)) { + return + } + event.preventDefault() + if (isWebUrl(event.url)) { + shell.openExternal(event.url) + } + }) }) } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index cc4155f6..8ccddd60 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1,7 +1,6 @@ -import { app, BrowserWindow, dialog, ipcMain } from "electron" -import { rebuildAppMenu } from "./app-menu" +import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron" +import { rebuildAppMenu, switchPreset } from "./app-menu" import { - applyPresetToEnv, type ConfigPreset, createPreset, deletePreset, @@ -14,12 +13,14 @@ import { updatePreset, } from "./config-manager" import { restartNextServer } from "./next-server" +import { noteNoChats, rememberChatPort } from "./port-manager" import { applyProxyToEnv, getProxyConfig, type ProxyConfig, saveProxyConfig, } from "./proxy-manager" +import { isAppUrl } from "./window-manager" /** * Allowed configuration keys for presets @@ -48,16 +49,44 @@ function sanitizePresetConfig( return sanitized } +/** + * Register an IPC handler that only answers the app's own pages + * (the main window on the app server, or the local settings page). + * A main window that somehow ends up on an external site still gets the + * preload API, so its calls must be rejected here. + */ +function handle( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown, +): void { + ipcMain.handle(channel, (event, ...args) => { + const url = event.senderFrame?.url + if (!isAppUrl(url) && !url?.startsWith("file://")) { + throw new Error(`Blocked "${channel}" from untrusted page: ${url}`) + } + return listener(event, ...(args as Args)) + }) +} + /** * Register all IPC handlers */ export function registerIpcHandlers(): void { // ==================== App Info ==================== - ipcMain.handle("get-version", () => { + handle("get-version", () => { return app.getVersion() }) + // ==================== Where the chats are ==================== + + // The page saved a chat, or loaded without any: decides which port + // (and so which origin's chats) the next launch opens + handle("chat-saved", () => rememberChatPort()) + handle("chats-loaded", (_event, count: unknown) => { + if (count === 0) noteNoChats() + }) + // ==================== Window Controls ==================== ipcMain.on("window-minimize", (event) => { @@ -79,84 +108,30 @@ export function registerIpcHandlers(): void { win?.close() }) - // ==================== File Dialogs ==================== - - ipcMain.handle("dialog-open-file", async (event) => { - const win = BrowserWindow.fromWebContents(event.sender) - if (!win) return null - - const result = await dialog.showOpenDialog(win, { - properties: ["openFile"], - filters: [ - { name: "Draw.io Files", extensions: ["drawio", "xml"] }, - { name: "All Files", extensions: ["*"] }, - ], - }) - - if (result.canceled || result.filePaths.length === 0) { - return null - } - - // Read the file content - const fs = await import("node:fs/promises") - try { - const content = await fs.readFile(result.filePaths[0], "utf-8") - return content - } catch (error) { - console.error("Failed to read file:", error) - return null - } - }) - - ipcMain.handle("dialog-save-file", async (event, data: string) => { - const win = BrowserWindow.fromWebContents(event.sender) - if (!win) return false - - const result = await dialog.showSaveDialog(win, { - filters: [ - { name: "Draw.io Files", extensions: ["drawio"] }, - { name: "XML Files", extensions: ["xml"] }, - ], - }) - - if (result.canceled || !result.filePath) { - return false - } - - const fs = await import("node:fs/promises") - try { - await fs.writeFile(result.filePath, data, "utf-8") - return true - } catch (error) { - console.error("Failed to save file:", error) - return false - } - }) - // ==================== Config Presets ==================== - ipcMain.handle("config-presets:get-all", () => { + handle("config-presets:get-all", () => { return getAllPresets() }) - ipcMain.handle("config-presets:get-current", () => { + handle("config-presets:get-current", () => { return getCurrentPreset() }) - ipcMain.handle("config-presets:get-current-id", () => { + handle("config-presets:get-current-id", () => { return getCurrentPresetId() }) - ipcMain.handle( + handle( "config-presets:save", - ( + async ( _event, preset: Omit & { id?: string }, ) => { // Validate preset name - if (typeof preset.name !== "string" || !preset.name.trim()) { + if (typeof preset?.name !== "string" || !preset.name.trim()) { throw new Error("Invalid preset name") } @@ -165,42 +140,48 @@ export function registerIpcHandlers(): void { if (preset.id) { // Update existing preset - return updatePreset(preset.id, { + const updated = updatePreset(preset.id, { name: preset.name.trim(), config: sanitizedConfig, }) + // Re-apply the active preset so the edit takes effect + if (updated && updated.id === getCurrentPresetId()) { + await switchPreset(updated.id) + } else { + rebuildAppMenu() + } + return updated } // Create new preset - return createPreset({ + const created = createPreset({ name: preset.name.trim(), config: sanitizedConfig, }) + rebuildAppMenu() + return created }, ) - ipcMain.handle("config-presets:delete", (_event, id: string) => { - return deletePreset(id) + handle("config-presets:delete", async (_event, id: string) => { + const wasCurrent = id === getCurrentPresetId() + // Deleting the active preset also clears its env vars + const deleted = deletePreset(id) + rebuildAppMenu() + + // Restart so the server stops using the deleted preset + if (deleted && wasCurrent && app.isPackaged) { + await restartNextServer() + } + return deleted }) - ipcMain.handle("config-presets:apply", async (_event, id: string) => { - const env = applyPresetToEnv(id) - if (!env) { - return { success: false, error: "Preset not found" } - } - - const isDev = process.env.NODE_ENV === "development" - - if (isDev) { - // In development mode, the config file change will trigger - // the file watcher in electron-dev.mjs to restart Next.js - // We just need to save the preset (already done in applyPresetToEnv) - return { success: true, env, devMode: true } - } - - // Production mode: restart the Next.js server to apply new environment variables + handle("config-presets:apply", async (_event, id: string) => { try { - await restartNextServer() - return { success: true, env } + const env = await switchPreset(id) + // In development mode, electron-dev.mjs restarts Next.js + return app.isPackaged + ? { success: true, env } + : { success: true, env, devMode: true } } catch (error) { return { success: false, @@ -212,30 +193,39 @@ export function registerIpcHandlers(): void { } }) - ipcMain.handle( - "config-presets:set-current", - (_event, id: string | null) => { - return setCurrentPreset(id) - }, - ) + handle("config-presets:set-current", (_event, id: string | null) => { + return setCurrentPreset(id) + }) // ==================== Proxy Settings ==================== - ipcMain.handle("get-proxy", () => { + handle("get-proxy", () => { return getProxyConfig() }) - ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => { + handle("set-proxy", async (_event, config: ProxyConfig) => { + const isOptionalString = (value: unknown) => + value === undefined || typeof value === "string" + if ( + typeof config !== "object" || + config === null || + !isOptionalString(config.httpProxy) || + !isOptionalString(config.httpsProxy) + ) { + return { success: false, error: "Invalid proxy settings" } + } + try { // Save config to file - saveProxyConfig(config) + saveProxyConfig({ + httpProxy: config.httpProxy, + httpsProxy: config.httpsProxy, + }) // Apply to current process environment applyProxyToEnv() - const isDev = process.env.NODE_ENV === "development" - - if (isDev) { + if (!app.isPackaged) { // In development, env vars are already applied // Next.js dev server may need manual restart return { success: true, devMode: true } @@ -257,11 +247,11 @@ export function registerIpcHandlers(): void { // ==================== User Locale ==================== - ipcMain.handle("get-user-locale", () => { + handle("get-user-locale", () => { return getUserLocale() }) - ipcMain.handle("set-user-locale", (_event, locale: string) => { + handle("set-user-locale", (_event, locale: string) => { // Validate locale is one of the supported values if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) { return { success: false, error: "Invalid locale" } diff --git a/electron/main/next-server.ts b/electron/main/next-server.ts index 7ca82fc2..0a09ed9f 100644 --- a/electron/main/next-server.ts +++ b/electron/main/next-server.ts @@ -7,9 +7,20 @@ import { getServerUrl, isPortAvailable, } from "./port-manager" +import { setAppUrl } from "./window-manager" let serverProcess: UtilityProcess | null = null +// Start and restart run one at a time, so overlapping calls (e.g. two quick +// preset switches) can't leave two servers running +let serverQueue: Promise = Promise.resolve() + +function runExclusive(task: () => Promise): Promise { + const result = serverQueue.then(task) + serverQueue = result.catch(() => {}) + return result +} + /** * Get the path to the standalone server resources * In packaged app: resources/standalone @@ -45,7 +56,11 @@ async function waitForServer(url: string, timeout = 30000): Promise { * Start the Next.js standalone server using Electron's utilityProcess * This API is designed for running Node.js code in the background */ -export async function startNextServer(): Promise { +export function startNextServer(): Promise { + return runExclusive(startServer) +} + +async function startServer(): Promise { const resourcePath = getResourcePath() const serverPath = path.join(resourcePath, "server.js") @@ -71,6 +86,13 @@ export async function startNextServer(): Promise { HOSTNAME: "127.0.0.1", // Enable Node.js built-in proxy support for fetch (Node.js 24+) NODE_USE_ENV_PROXY: "1", + // The preset keys are the user's own, not a server's + NEXT_AI_DRAWIO_DESKTOP: "1", + } + + // Keep requests to local model servers (e.g. Ollama) off the proxy + if (!process.env.NO_PROXY && !process.env.no_proxy) { + env.NO_PROXY = "localhost,127.0.0.1,[::1]" } // Set cache directory to a writable location (user's app data folder) @@ -96,23 +118,27 @@ export async function startNextServer(): Promise { // Use Electron's utilityProcess API for running Node.js in background // This is the recommended way to run Node.js code in Electron - serverProcess = utilityProcess.fork(serverPath, [], { + const proc = utilityProcess.fork(serverPath, [], { cwd: resourcePath, env, stdio: "pipe", }) + serverProcess = proc - serverProcess.stdout?.on("data", (data) => { + proc.stdout?.on("data", (data) => { console.log(`[Next.js] ${data.toString().trim()}`) }) - serverProcess.stderr?.on("data", (data) => { + proc.stderr?.on("data", (data) => { console.error(`[Next.js Error] ${data.toString().trim()}`) }) - serverProcess.on("exit", (code) => { + proc.on("exit", (code) => { console.log(`Next.js server exited with code ${code}`) - serverProcess = null + // An old server can exit after a new one started; keep the new one + if (serverProcess === proc) { + serverProcess = null + } }) const url = getServerUrl() @@ -126,39 +152,36 @@ export async function startNextServer(): Promise { * Stop the Next.js server process and wait for it to exit */ export async function stopNextServer(): Promise { - if (serverProcess) { - console.log("Stopping Next.js server...") + const proc = serverProcess + if (!proc) { + return + } + console.log("Stopping Next.js server...") + serverProcess = null - // Create a promise that resolves when the process exits - const exitPromise = new Promise((resolve) => { - const proc = serverProcess - if (!proc) { - resolve() - return - } - - const onExit = () => { - resolve() - } - - proc.once("exit", onExit) - - // Timeout after 5 seconds - setTimeout(() => { - proc.removeListener("exit", onExit) - resolve() - }, 5000) + // Resolves true when the process exits, false after the timeout + const waitForExit = (ms: number) => + new Promise((resolve) => { + proc.once("exit", () => resolve(true)) + setTimeout(() => resolve(false), ms) }) - serverProcess.kill() - serverProcess = null + proc.kill() - // Wait for process to exit - await exitPromise - - // Additional wait for OS to release port - await new Promise((resolve) => setTimeout(resolve, 500)) + // Next.js waits for open requests (e.g. a streaming reply) before it + // exits, so force kill it if it is still running after 5 seconds + if (!(await waitForExit(5000)) && proc.pid) { + console.warn("Next.js server did not exit in time, force killing it") + try { + process.kill(proc.pid, "SIGKILL") + } catch (error) { + console.error("Failed to force kill Next.js server:", error) + } + await waitForExit(2000) } + + // Additional wait for OS to release port + await new Promise((resolve) => setTimeout(resolve, 500)) } /** @@ -184,15 +207,19 @@ async function waitForServerStop(timeout = 5000): Promise { /** * Restart the Next.js server with new environment variables */ -export async function restartNextServer(): Promise { - console.log("Restarting Next.js server...") +export function restartNextServer(): Promise { + return runExclusive(async () => { + console.log("Restarting Next.js server...") - // Stop the current server and wait for it to exit - await stopNextServer() + // Stop the current server and wait for it to exit + await stopNextServer() - // Wait for the port to be released - await waitForServerStop() + // Wait for the port to be released + await waitForServerStop() - // Start the server again - return startNextServer() + // Start the server again, and follow it if it moved to another port + const url = await startServer() + setAppUrl(url) + return url + }) } diff --git a/electron/main/port-manager.ts b/electron/main/port-manager.ts index 299ff427..509bc9ab 100644 --- a/electron/main/port-manager.ts +++ b/electron/main/port-manager.ts @@ -1,4 +1,6 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs" import net from "node:net" +import path from "node:path" import { app } from "electron" /** @@ -23,6 +25,69 @@ const PORT_CONFIG = { */ let allocatedPort: number | null = null +/** + * Whether chats are saved under http://127.0.0.1:: Electron keeps + * each origin's IndexedDB in its own folder + */ +function hasStoredData(port: number): boolean { + return existsSync( + path.join( + app.getPath("userData"), + "IndexedDB", + `http_127.0.0.1_${port}.indexeddb.leveldb`, + ), + ) +} + +// The two fixed production ports, the only ones whose origin (and so its +// chats and settings) is the same at every launch +const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production] + +const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json") + +/** The fixed port where a chat was last saved, if known */ +function readChatPort(): number | null { + try { + const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8")) + return HOME_PORTS.includes(port) ? port : null + } catch { + return null + } +} + +function writeChatPort(port: number): void { + try { + writeFileSync(chatPortFile(), JSON.stringify({ port })) + } catch (error) { + console.warn("Could not save the chat port:", error) + } +} + +/** + * The page saved a chat: open on this port next time. Chats of the two + * ports cannot be shown together (each origin has its own storage), so the + * app opens where the user last worked. A launch that had to use the other + * port and saved nothing does not move it. + */ +export function rememberChatPort(): void { + const port = allocatedPort + if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return + if (readChatPort() !== port) writeChatPort(port) +} + +/** + * The page loaded without any chats. Before any chat was saved under this + * version (no file yet), the user's chats may be on the other fixed port, + * where an older version opened: try it first next time. + */ +export function noteNoChats(): void { + const port = allocatedPort + if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return + if (existsSync(chatPortFile())) return + const other = HOME_PORTS.find((p) => p !== port) + if (other !== undefined && hasStoredData(other)) writeChatPort(other) +} + /** * Check if a specific port is available */ @@ -44,7 +109,8 @@ export function isPortAvailable(port: number): Promise { /** * Find an available port * - In development: uses fixed port (6002) - * - In production: uses fixed port (13370) to preserve localStorage + * - In production: uses the legacy port (61337), then 13370, to preserve + * localStorage; 13370 first when only it has saved chats * - Falls back to sequential ports if preferred port is unavailable * - Last resort: lets the OS assign a port (port 0) * @@ -69,21 +135,26 @@ export async function findAvailablePort(reuseExisting = true): Promise { allocatedPort = null } - // In production, try legacy port first to preserve existing users' localStorage - if (!isDev) { - const legacyPort = PORT_CONFIG.legacyProduction - if (await isPortAvailable(legacyPort)) { - allocatedPort = legacyPort - return legacyPort + // In production, first the port where a chat was last saved. Without + // one, the legacy port first to preserve existing users' data, unless + // only the new port has data: their app started on 13370 while Windows + // reserved 61337, and 61337 being free now would hide it + const chatPort = isDev ? null : readChatPort() + const candidates = isDev + ? [preferredPort] + : chatPort !== null + ? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)] + : hasStoredData(PORT_CONFIG.production) && + !hasStoredData(PORT_CONFIG.legacyProduction) + ? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction] + : [PORT_CONFIG.legacyProduction, PORT_CONFIG.production] + for (const port of candidates) { + if (await isPortAvailable(port)) { + allocatedPort = port + return port } } - // Try preferred port - if (await isPortAvailable(preferredPort)) { - allocatedPort = preferredPort - return preferredPort - } - console.warn( `Preferred port ${preferredPort} is in use, finding alternative...`, ) diff --git a/electron/main/proxy-manager.ts b/electron/main/proxy-manager.ts index cb41de56..3f4c2f8a 100644 --- a/electron/main/proxy-manager.ts +++ b/electron/main/proxy-manager.ts @@ -13,18 +13,22 @@ function getConfigPath(): string { /** * Load proxy configuration from JSON file + * Returns null if the user never saved proxy settings (or the file is invalid) */ -export function loadProxyConfig(): ProxyConfig { +export function loadProxyConfig(): ProxyConfig | null { try { const configPath = getConfigPath() if (fs.existsSync(configPath)) { - const data = fs.readFileSync(configPath, "utf-8") - return JSON.parse(data) as ProxyConfig + const data = JSON.parse(fs.readFileSync(configPath, "utf-8")) + if (data && typeof data === "object" && !Array.isArray(data)) { + return data as ProxyConfig + } + console.error("Ignoring invalid proxy config:", data) } } catch (error) { console.error("Failed to load proxy config:", error) } - return {} + return null } /** @@ -33,7 +37,11 @@ export function loadProxyConfig(): ProxyConfig { export function saveProxyConfig(config: ProxyConfig): void { try { const configPath = getConfigPath() - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8") + // Write a temp file and rename it, so a crash mid-write can't leave + // a truncated file + const tempPath = `${configPath}.tmp` + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8") + fs.renameSync(tempPath, configPath) } catch (error) { console.error("Failed to save proxy config:", error) throw error @@ -47,6 +55,11 @@ export function saveProxyConfig(config: ProxyConfig): void { export function applyProxyToEnv(): void { const config = loadProxyConfig() + // No saved settings: keep proxy vars inherited from the system or .env + if (!config) { + return + } + if (config.httpProxy) { process.env.HTTP_PROXY = config.httpProxy process.env.http_proxy = config.httpProxy diff --git a/electron/main/window-manager.ts b/electron/main/window-manager.ts index 7b515715..e533f500 100644 --- a/electron/main/window-manager.ts +++ b/electron/main/window-manager.ts @@ -3,6 +3,9 @@ import { app, BrowserWindow, screen } from "electron" let mainWindow: BrowserWindow | null = null +// URL of the app server the main window loads +let appUrl: string | null = null + /** * Get the icon path based on platform * Note: electron-builder converts icon.png during packaging, @@ -28,6 +31,7 @@ function getIconPath(): string | undefined { * Create the main application window */ export function createWindow(serverUrl: string): BrowserWindow { + appUrl = serverUrl const { width, height } = screen.getPrimaryDisplay().workAreaSize mainWindow = new BrowserWindow({ @@ -56,7 +60,7 @@ export function createWindow(serverUrl: string): BrowserWindow { }) // Open DevTools in development - if (process.env.NODE_ENV === "development") { + if (!app.isPackaged) { mainWindow.webContents.openDevTools() } @@ -93,3 +97,39 @@ export function createWindow(serverUrl: string): BrowserWindow { export function getMainWindow(): BrowserWindow | null { return mainWindow } + +/** + * Get the app server URL the main window loads + */ +export function getAppUrl(): string | null { + return appUrl +} + +/** + * Point the main window at the restarted app server (it can come up on a + * different port). On the same port the page fetches the new preset's + * server models instead of sending the old preset's choice; it is not + * reloaded, which would drop unsent attachments. + */ +export function setAppUrl(url: string): void { + if (url === appUrl) { + mainWindow?.webContents.send("server-restarted") + return + } + appUrl = url + mainWindow?.loadURL(url) +} + +/** + * Check if a URL belongs to the app server (same origin) + */ +export function isAppUrl(url: string | undefined): boolean { + if (!url || !appUrl) { + return false + } + try { + return new URL(url).origin === new URL(appUrl).origin + } catch { + return false + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b648ca13..f24249f7 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -18,10 +18,6 @@ contextBridge.exposeInMainWorld("electronAPI", { maximize: () => ipcRenderer.send("window-maximize"), close: () => ipcRenderer.send("window-close"), - // File operations - openFile: () => ipcRenderer.invoke("dialog-open-file"), - saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data), - // Proxy settings getProxy: () => ipcRenderer.invoke("get-proxy"), setProxy: (config: { httpProxy?: string; httpsProxy?: string }) => @@ -31,4 +27,18 @@ contextBridge.exposeInMainWorld("electronAPI", { getUserLocale: () => ipcRenderer.invoke("get-user-locale"), setUserLocale: (locale: string) => ipcRenderer.invoke("set-user-locale", locale), + + // A chat was saved, or the page loaded with this many chats: the next + // launch opens the port where the chats are + chatSaved: () => ipcRenderer.invoke("chat-saved"), + chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count), + + // The server restarted on the same port (another preset) + onServerRestarted: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on("server-restarted", listener) + return () => { + ipcRenderer.removeListener("server-restarted", listener) + } + }, }) diff --git a/electron/settings/settings.js b/electron/settings/settings.js index 235b2fe5..b71928de 100644 --- a/electron/settings/settings.js +++ b/electron/settings/settings.js @@ -213,6 +213,9 @@ async function savePreset() { } }) + // closeModal() clears editingPresetId, so remember it for the toast + const isEdit = Boolean(editingPresetId) + try { saveBtn.disabled = true saveBtn.innerHTML = '' @@ -220,10 +223,7 @@ async function savePreset() { await window.settingsAPI.savePreset(preset) await loadPresets() closeModal() - showToast( - editingPresetId ? "Preset updated" : "Preset created", - "success", - ) + showToast(isEdit ? "Preset updated" : "Preset created", "success") } catch (error) { console.error("Failed to save preset:", error) showToast("Failed to save preset", "error") @@ -265,8 +265,6 @@ async function applyPreset(id) { const result = await window.settingsAPI.applyPreset(id) if (result.success) { - currentPresetId = id - renderPresets() showToast("Preset applied, server restarting...", "success") } else { showToast(result.error || "Failed to apply preset", "error") @@ -274,6 +272,9 @@ async function applyPreset(id) { } catch (error) { console.error("Failed to apply preset:", error) showToast("Failed to apply preset", "error") + } finally { + // Reload to show the active preset and reset the Apply button + await loadPresets() } } diff --git a/env.example b/env.example index df8d9118..bf22cff9 100644 --- a/env.example +++ b/env.example @@ -12,7 +12,8 @@ AI_PROVIDER=bedrock AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML, -# so a thinking model can spend it all before the tool call. Users can override it in Settings. +# so a thinking model can spend it all before the tool call. Users can lower it in Settings, +# and raise it only when they use their own API key, so this also caps cost on server keys. # If a model's own ceiling is lower, the request is retried with that ceiling automatically. # MAX_OUTPUT_TOKENS=64000 @@ -43,7 +44,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Google Generative AI Configuration # GOOGLE_GENERATIVE_AI_API_KEY=... # GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint -# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate # GOOGLE_TOP_K=40 # Optional: Top K sampling parameter # GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter # Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true) @@ -70,7 +70,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # AZURE_REASONING_SUMMARY=detailed # Ollama Configuration (Local or Cloud) -# OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud +# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434) # OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances # OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3) @@ -123,9 +123,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Temperature (Optional) # Controls randomness in AI responses. Lower = more deterministic. -# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models) +# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models). +# Claude 4.7 and later reject it; the request is then retried without it. # TEMPERATURE=0 +# Debug Logging (Optional) +# Log the structure of the messages each chat request sends to the model +# DEBUG_LLM_PAYLOAD=true + # Access Control (Optional) # ACCESS_CODE_LIST=your-secret-code,another-code diff --git a/hooks/use-diagram-tool-handlers.ts b/hooks/use-diagram-tool-handlers.ts index d1970d85..ad67ec28 100644 --- a/hooks/use-diagram-tool-handlers.ts +++ b/hooks/use-diagram-tool-handlers.ts @@ -1,5 +1,4 @@ import type { MutableRefObject } from "react" -import { useRef } from "react" import type { DiagramOperation } from "@/components/chat/types" import type { ValidationState, @@ -7,10 +6,15 @@ import type { } from "@/components/chat/ValidationCard" import type { ValidationResult } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator" -import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" +import { isMxCellXmlComplete } from "@/lib/utils" +import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts" +import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" const DEBUG = process.env.NODE_ENV === "development" +// display_diagram replaces the document with this one page +const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" } + interface ToolCall { toolCallId: string toolName: string @@ -48,6 +52,10 @@ type ValidateDiagramFn = ( interface UseDiagramToolHandlersParams { partialXmlRef: MutableRefObject editDiagramOriginalXmlRef: MutableRefObject> + // Tool calls the streaming preview must leave alone (shared with it) + processedToolCallsRef: MutableRefObject> + // Failed VLM validations in the current user turn (reset on each user message) + validationRetryCountRef: MutableRefObject chartXMLRef: MutableRefObject onDisplayChart: (xml: string, skipValidation?: boolean) => string | null onFetchChart: (saveToHistory?: boolean) => Promise @@ -56,6 +64,11 @@ interface UseDiagramToolHandlersParams { validateDiagram?: ValidateDiagramFn enableVlmValidation?: boolean sessionId?: string + // Called when a screenshot check begins; the function it returns + // tells whether the user pressed Stop in this turn, also after the next + // message was sent. A check that has not started then is skipped (one + // already running is cancelled by the caller). + watchStop?: () => () => boolean onValidationStateChange?: ( toolCallId: string, state: ValidationState, @@ -72,6 +85,8 @@ interface UseDiagramToolHandlersParams { export function useDiagramToolHandlers({ partialXmlRef, editDiagramOriginalXmlRef, + processedToolCallsRef, + validationRetryCountRef, chartXMLRef, onDisplayChart, onFetchChart, @@ -80,11 +95,9 @@ export function useDiagramToolHandlers({ validateDiagram, enableVlmValidation = true, sessionId, + watchStop, onValidationStateChange, }: UseDiagramToolHandlersParams) { - // Track validation retry count per tool call - const validationRetryCountRef = useRef>(new Map()) - // Helper to update validation state const updateValidationState = ( toolCallId: string, @@ -114,18 +127,39 @@ export function useDiagramToolHandlers({ ) } + processedToolCallsRef.current.add(toolCall.toolCallId) + // Only display_diagram, edit_diagram and a completing append_diagram + // put their result on the canvas. Other tools (get_shape_library, + // which the server runs, still arrives here) leave the stored + // originals for the preview code to undo. if (toolCall.toolName === "display_diagram") { - await handleDisplayDiagram(toolCall, addToolOutput) + await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals()) } else if (toolCall.toolName === "edit_diagram") { - await handleEditDiagram(toolCall, addToolOutput) + await handleEditDiagram(toolCall, addToolOutput, takeOriginals()) } else if (toolCall.toolName === "append_diagram") { handleAppendDiagram(toolCall, addToolOutput) } } + // Stored originals belong to previews not handled yet: this call's, and + // those of earlier calls with invalid input, which never get to the + // handler. The first is the diagram before all of them. A call that + // draws its result replaces those previews, so the preview code must + // neither draw them again nor undo them later. Returns that first one. + const takeOriginals = (): string | undefined => { + const [originalXml] = editDiagramOriginalXmlRef.current.values() + for (const id of editDiagramOriginalXmlRef.current.keys()) { + processedToolCallsRef.current.add(id) + } + editDiagramOriginalXmlRef.current.clear() + return originalXml + } + + // originalXml: the diagram before the streamed previews, if any were drawn const handleDisplayDiagram = async ( toolCall: ToolCall, addToolOutput: AddToolOutputFn, + originalXml: string | undefined, ) => { const { xml } = toolCall.input as { xml: string } @@ -174,14 +208,18 @@ NEXT STEP: Call append_diagram with the continuation XML. const finalXml = xml partialXmlRef.current = "" // Reset any partial from previous truncation - // Wrap raw XML with full mxfile structure for draw.io - const fullXml = wrapWithMxFile(finalXml) - - // loadDiagram validates and returns error if invalid - const validationError = onDisplayChart(fullXml) + // Wrap, validate and auto-fix the model's XML like the MCP server's + // create_new_diagram, then load it + const prepared = prepareNewDiagram(finalXml, NEW_PAGE) + const validationError = prepared.ok + ? onDisplayChart(prepared.xml, true) + : prepared.error if (validationError) { console.warn("[display_diagram] Validation error:", validationError) + // Undo the streamed preview, as a failed edit does: the canvas + // keeps the diagram from before this failed call + if (originalXml) onDisplayChart(originalXml, true) // Return error to model - sendAutomaticallyWhen will trigger retry if (DEBUG) { console.log( @@ -213,9 +251,12 @@ ${finalXml} if ( enableVlmValidation && captureValidationPng && - validateDiagram + validateDiagram && + // At most this many checks per user turn, passed or not + validationRetryCountRef.current < MAX_VALIDATION_RETRIES ) { let capturedPngData: string | null = null + const stopped = watchStop?.() try { // Notify UI that we're starting capture updateValidationState(toolCall.toolCallId, "capturing") @@ -225,24 +266,28 @@ ${finalXml} await new Promise((resolve) => setTimeout(resolve, 100)) capturedPngData = await captureValidationPng() - if (capturedPngData) { + // Stopped while the screenshot was taken: no check. The + // chat waits for this handler, so it must end now. + if (stopped?.()) { + updateValidationState(toolCall.toolCallId, "skipped") + } else if (capturedPngData) { if (DEBUG) { console.log( "[display_diagram] Captured PNG for validation", ) } - const retryCount = - validationRetryCountRef.current.get( - toolCall.toolCallId, - ) || 0 + // Each retry is a new tool call, so count attempts + // per user turn (the chat resets it when the user sends) + const attempt = validationRetryCountRef.current + 1 + validationRetryCountRef.current = attempt // Notify UI that we're validating (include the image) updateValidationState( toolCall.toolCallId, "validating", { - attempt: retryCount + 1, + attempt, maxAttempts: MAX_VALIDATION_RETRIES, imageData: capturedPngData, }, @@ -254,17 +299,12 @@ ${finalXml} ) if (!result.valid) { - if (retryCount < MAX_VALIDATION_RETRIES) { - validationRetryCountRef.current.set( - toolCall.toolCallId, - retryCount + 1, - ) - + if (attempt < MAX_VALIDATION_RETRIES) { const feedback = formatValidationFeedback(result) if (DEBUG) { console.log( - `[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`, + `[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`, result.issues, ) } @@ -274,7 +314,7 @@ ${finalXml} toolCall.toolCallId, "failed", { - attempt: retryCount + 1, + attempt, maxAttempts: MAX_VALIDATION_RETRIES, result, imageData: capturedPngData, @@ -285,20 +325,16 @@ ${finalXml} tool: "display_diagram", toolCallId: toolCall.toolCallId, state: "output-error", - errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`, + errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`, }) return } else { - // Max retries reached - accept the diagram with warning + // Last attempt - accept the diagram with warning if (DEBUG) { console.log( "[display_diagram] Max validation retries reached, accepting diagram", ) } - validationRetryCountRef.current.delete( - toolCall.toolCallId, - ) - // Notify UI that we're accepting with issues (include the image) updateValidationState( toolCall.toolCallId, @@ -314,10 +350,6 @@ ${finalXml} return } } else { - // Validation passed - clean up retry count - validationRetryCountRef.current.delete( - toolCall.toolCallId, - ) if (DEBUG) { console.log( "[display_diagram] Validation passed!", @@ -340,6 +372,16 @@ ${finalXml} updateValidationState(toolCall.toolCallId, "skipped") } } catch (error) { + // Cancelled by Stop: the diagram stays, unchecked + if ((error as Error)?.name === "AbortError") { + updateValidationState(toolCall.toolCallId, "skipped") + addToolOutput({ + tool: "display_diagram", + toolCallId: toolCall.toolCallId, + output: "Successfully displayed the diagram.", + }) + return + } // VLM validation error - log but don't block the user console.warn( "[display_diagram] VLM validation error:", @@ -373,21 +415,24 @@ ${finalXml} } } + // originalXml: the diagram before the streamed previews, if any were drawn. + // Operations apply to it, the same base XML that streaming used. const handleEditDiagram = async ( toolCall: ToolCall, addToolOutput: AddToolOutputFn, + originalXml: string | undefined, ) => { const { operations } = toolCall.input as { operations: DiagramOperation[] } let currentXml = "" + // On failure, undo the streaming preview so the canvas matches the XML + // reported back to the model + const restoreOriginal = () => { + if (originalXml) onDisplayChart(originalXml, true) + } try { - // Use the original XML captured during streaming (shared with chat-message-display) - // This ensures we apply operations to the same base XML that streaming used - const originalXml = editDiagramOriginalXmlRef.current.get( - toolCall.toolCallId, - ) if (originalXml) { currentXml = originalXml } else { @@ -401,26 +446,19 @@ ${finalXml} } } - const { applyDiagramOperations } = await import("@/lib/utils") - const { result: editedXml, errors } = applyDiagramOperations( - currentXml, - operations, - ) - - // Check for operation errors - if (errors.length > 0) { - const errorMessages = errors - .map( - (e) => - `- ${e.type} on cell_id="${e.cellId}": ${e.message}`, - ) - .join("\n") - + // All or nothing, checked like the MCP server's edit_diagram. + // The model sees the first page, so edits target it. + const outcome = editDiagram(currentXml, operations, {}) + if (!outcome.ok) { + const reason = outcome.pageError + ? outcome.errors[0] + : `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}` + restoreOriginal() addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, state: "output-error", - errorText: `Some operations failed:\n${errorMessages} + errorText: `${reason} Current diagram XML: \`\`\`xml @@ -429,49 +467,23 @@ ${currentXml} Please check the cell IDs and retry.`, }) - // Clean up the shared original XML ref - editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) return } - // loadDiagram validates and returns error if invalid - const validationError = onDisplayChart(editedXml) - if (validationError) { - console.warn( - "[edit_diagram] Validation error:", - validationError, - ) - addToolOutput({ - tool: "edit_diagram", - toolCallId: toolCall.toolCallId, - state: "output-error", - errorText: `Edit produced invalid XML: ${validationError} - -Current diagram XML: -\`\`\`xml -${currentXml} -\`\`\` - -Please fix the operations to avoid structural issues.`, - }) - // Clean up the shared original XML ref - editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) - return - } + onDisplayChart(outcome.xml, true) onExport() addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, - output: `Successfully applied ${operations.length} operation(s) to the diagram.`, + output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`, }) - // Clean up the shared original XML ref - editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) } catch (error) { console.error("[edit_diagram] Failed:", error) const errorMessage = error instanceof Error ? error.message : String(error) + restoreOriginal() addToolOutput({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, @@ -485,8 +497,6 @@ ${currentXml || "No XML available"} Please check cell IDs and retry, or use display_diagram to regenerate.`, }) - // Clean up the shared original XML ref even on error - editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId) } } @@ -496,6 +506,19 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`, ) => { const { xml } = toolCall.input as { xml: string } + // Nothing to continue: loading the fragment alone would replace the whole diagram + if (!partialXmlRef.current) { + addToolOutput({ + tool: "append_diagram", + toolCallId: toolCall.toolCallId, + state: "output-error", + errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now. + +Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`, + }) + return + } + // Detect if LLM incorrectly started fresh instead of continuing // LLM should only output bare mxCells now, so wrapper tags indicate error const trimmed = xml.trim() @@ -534,10 +557,18 @@ Start your continuation with the NEXT character after where it stopped.`, const finalXml = partialXmlRef.current partialXmlRef.current = "" // Reset - const fullXml = wrapWithMxFile(finalXml) - const validationError = onDisplayChart(fullXml) + const prepared = prepareNewDiagram(finalXml, NEW_PAGE) + // It draws now: it takes the stored originals, as display_diagram + const originalXml = prepared.ok ? takeOriginals() : undefined + const validationError = prepared.ok + ? onDisplayChart(prepared.xml, true) + : prepared.error if (validationError) { + // Loading failed: back to the diagram before the previews + if (prepared.ok && originalXml) { + onDisplayChart(originalXml, true) + } addToolOutput({ tool: "append_diagram", toolCallId: toolCall.toolCallId, diff --git a/hooks/use-model-config.ts b/hooks/use-model-config.ts index 7c918f05..e05f680f 100644 --- a/hooks/use-model-config.ts +++ b/hooks/use-model-config.ts @@ -13,6 +13,7 @@ import { flattenModels, type ModelConfig, type MultiModelConfig, + PROVIDER_INFO, type ProviderConfig, type ProviderName, } from "@/lib/types/model-config" @@ -63,6 +64,28 @@ function migrateOldConfig(): MultiModelConfig | null { return config } +const isKnownProvider = (p: { provider: string }) => + Object.hasOwn(PROVIDER_INFO, p.provider) + +/** + * The stored config without providers this version does not know (saved + * by another version, or edited by hand): they would break every list of + * models. They stay in storage (saveConfig keeps them). Throws on bad JSON. + */ +function parseStoredConfig(stored: string): MultiModelConfig { + const config = JSON.parse(stored) as MultiModelConfig + const known = config.providers.filter(isKnownProvider) + if (known.length < config.providers.length) { + console.warn( + "Skipped saved providers this version does not know:", + config.providers + .filter((p) => !isKnownProvider(p)) + .map((p) => p.provider), + ) + } + return { ...config, providers: known } +} + /** * Load config from localStorage */ @@ -73,7 +96,7 @@ function loadConfig(): MultiModelConfig { const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) if (stored) { try { - return JSON.parse(stored) as MultiModelConfig + return parseStoredConfig(stored) } catch { console.error("Failed to parse model config") } @@ -98,7 +121,35 @@ function loadConfig(): MultiModelConfig { */ function saveConfig(config: MultiModelConfig): void { if (typeof window === "undefined") return - localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config)) + // Providers this version does not know are not in config: keep them, + // with their keys, for the version that saved them + let unknown: MultiModelConfig["providers"] = [] + try { + const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) + if (stored) { + unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter( + (p) => !isKnownProvider(p), + ) + } + } catch { + // Unreadable: nothing to keep + } + localStorage.setItem( + STORAGE_KEYS.modelConfigs, + JSON.stringify({ + ...config, + providers: [...config.providers, ...unknown], + }), + ) +} + +/** + * Server model to fall back to: the one marked default, else the first one + */ +function defaultServerModelId( + serverModels: FlattenedServerModel[], +): string | undefined { + return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id } export interface UseModelConfigReturn { @@ -144,10 +195,25 @@ export function useModelConfig(): UseModelConfigReturn { setIsLoaded(true) }, []) - // Load server models on mount (if any) + // Pick up config changes saved by other tabs, so this tab neither shows a + // stale model nor overwrites their changes on its next save + useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig()) + } + window.addEventListener("storage", handleStorage) + return () => window.removeEventListener("storage", handleStorage) + }, []) + + // Load server models on mount (if any), and again when the desktop app + // restarted its server for another preset useEffect(() => { if (typeof window === "undefined") return + loadServerModels() + return window.electronAPI?.onServerRestarted?.(loadServerModels) + }, []) + function loadServerModels() { fetch(getApiEndpoint("/api/server-models")) .then((res) => { if (!res.ok) { @@ -165,24 +231,37 @@ export function useModelConfig(): UseModelConfigReturn { setServerModels(raw) setServerLoaded(true) - // Auto-select default server model if no model is currently selected + // Auto-select the default server model if no model is selected, + // or if the saved server model is gone (renamed or removed) setConfig((prev) => { - if (!prev.selectedModelId && raw.length > 0) { - const defaultModel = raw.find((m) => m.isDefault) - if (defaultModel) { - return { ...prev, selectedModelId: defaultModel.id } - } - // If no default marked, use first server model - return { ...prev, selectedModelId: raw[0].id } - } - return prev + const id = prev.selectedModelId + const isStale = + id?.startsWith("server:") && + !raw.some((m) => m.id === id) + if (id && !isStale) return prev + // Saved before non-ASCII characters in provider names + // got into the id: they were dropped from it + const renamed = raw.filter( + (m) => + `server:${m.providerLabel + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "")}:${m.modelId}` === id, + ) + const fallback = + renamed.length === 1 + ? renamed[0].id + : defaultServerModelId(raw) + return fallback === id + ? prev + : { ...prev, selectedModelId: fallback } }) }) .catch((error) => { console.error("Error while loading server models:", error) setServerLoaded(true) }) - }, []) + } // Save config whenever it changes (after initial load) useEffect(() => { @@ -260,24 +339,31 @@ export function useModelConfig(): UseModelConfigReturn { [], ) - const deleteProvider = useCallback((providerId: string) => { - setConfig((prev) => { - const provider = prev.providers.find((p) => p.id === providerId) - const modelIds = provider?.models.map((m) => m.id) || [] + const deleteProvider = useCallback( + (providerId: string) => { + setConfig((prev) => { + const provider = prev.providers.find((p) => p.id === providerId) + const modelIds = provider?.models.map((m) => m.id) || [] - // Clear selected model if it belongs to deleted provider - const newSelectedId = - prev.selectedModelId && modelIds.includes(prev.selectedModelId) - ? undefined - : prev.selectedModelId + // Fall back to the default server model if the selected model + // belongs to the deleted provider + const newSelectedId = + prev.selectedModelId && + modelIds.includes(prev.selectedModelId) + ? defaultServerModelId(serverModels) + : prev.selectedModelId - return { - ...prev, - providers: prev.providers.filter((p) => p.id !== providerId), - selectedModelId: newSelectedId, - } - }) - }, []) + return { + ...prev, + providers: prev.providers.filter( + (p) => p.id !== providerId, + ), + selectedModelId: newSelectedId, + } + }) + }, + [serverModels], + ) const addModel = useCallback( (providerId: string, modelId: string): ModelConfig => { @@ -334,14 +420,15 @@ export function useModelConfig(): UseModelConfigReturn { } : p, ), - // Clear selected model if it was deleted + // Fall back to the default server model if the selected model + // was deleted selectedModelId: prev.selectedModelId === modelConfigId - ? undefined + ? defaultServerModelId(serverModels) : prev.selectedModelId, })) }, - [], + [serverModels], ) const resetConfig = useCallback(() => { @@ -428,7 +515,8 @@ export function getSelectedAIConfig(): { let config: MultiModelConfig try { - config = JSON.parse(stored) + // Unknown providers would break the model lookup below + config = parseStoredConfig(stored) } catch { return { ...empty, accessCode } } diff --git a/hooks/use-session-manager.ts b/hooks/use-session-manager.ts index 9243e2c3..e950644a 100644 --- a/hooks/use-session-manager.ts +++ b/hooks/use-session-manager.ts @@ -1,6 +1,8 @@ "use client" import { useCallback, useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { useDictionary } from "@/hooks/use-dictionary" import { type ChatSession, createEmptySession, @@ -11,10 +13,12 @@ import { getSession, isIndexedDBAvailable, migrateFromLocalStorage, + readSessionCount, type SessionMetadata, type StoredMessage, saveSession, } from "@/lib/session-storage" +import { STORAGE_KEYS } from "@/lib/storage" export interface SessionData { messages: StoredMessage[] @@ -24,6 +28,13 @@ export interface SessionData { diagramHistory?: { svg: string; xml: string }[] } +// Taken right before a save's data is read: the chat on screen then, and +// the order of the reads +export interface SaveTicket { + generation: number + seq: number +} + export interface UseSessionManagerReturn { // State sessions: SessionMetadata[] @@ -35,13 +46,27 @@ export interface UseSessionManagerReturn { // Actions switchSession: (id: string) => Promise deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }> - // forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes) + // ticket: getSaveTicket() before the data was read (by default, now). + // The save is dropped if another chat is on screen when its turn comes, + // or if a copy of this chat read later was saved already. + // Resolves to false when the save failed (the user was told) saveCurrentSession: ( data: SessionData, - forSessionId?: string | null, - ) => Promise + ticket?: SaveTicket, + ) => Promise refreshSessions: () => Promise clearCurrentSession: () => void + getChatGeneration: () => number + getSaveTicket: () => SaveTicket +} + +// Reading the session list loads every stored session in full, and window +// focus also fires each time the user clicks back from the draw.io iframe +const FOCUS_REFRESH_INTERVAL_MS = 30_000 + +function notifySaveFailed(message: string) { + // Same id, so repeated failures update one toast instead of stacking + toast.error(message, { id: "session-save-failed", duration: 8000 }) } interface UseSessionManagerOptions { @@ -53,6 +78,7 @@ export function useSessionManager( options: UseSessionManagerOptions = {}, ): UseSessionManagerReturn { const { initialSessionId } = options + const dict = useDictionary() const [sessions, setSessions] = useState([]) const [currentSessionId, setCurrentSessionId] = useState( null, @@ -66,6 +92,23 @@ export function useSessionManager( const isInitializedRef = useRef(false) // Sequence guard for URL changes - prevents out-of-order async resolution const urlChangeSequenceRef = useRef(0) + // The chat on screen, read by saves that run after a render or a wait + const currentSessionRef = useRef(null) + // Goes up each time another chat is put on screen (creating the + // session of the chat on screen does not count) + const chatGenerationRef = useRef(0) + // Saves run one at a time, so two saves of a new chat create it once + const saveQueueRef = useRef>(Promise.resolve()) + // The last ticket number, and that of the newest data saved + const saveSeqRef = useRef(0) + const savedSeqRef = useRef(0) + + const changeChat = useCallback((session: ChatSession | null) => { + chatGenerationRef.current++ + currentSessionRef.current = session + setCurrentSession(session) + setCurrentSessionId(session?.id ?? null) + }, []) // Load sessions list const refreshSessions = useCallback(async () => { @@ -101,14 +144,36 @@ export function useSessionManager( // Load sessions list const metadata = await getAllSessionMetadata() setSessions(metadata) + // The desktop app may try its other port next launch, where + // an older version may have saved the chats: only when this + // origin surely has none (a failed read is not "none") and + // keeps no model settings or keys either + if (window.electronAPI?.chatsLoaded) { + const count = await readSessionCount() + // The app saves an empty config on its first load; the + // providers are what holds the keys, besides an access + // code + let hasSettings = true + try { + const config = JSON.parse( + localStorage.getItem(STORAGE_KEYS.modelConfigs) ?? + "{}", + ) + hasSettings = + (config.providers?.length ?? 0) > 0 || + !!localStorage.getItem(STORAGE_KEYS.accessCode) + } catch { + // Unreadable: treat as settings, and stay + } + if (count !== null && !hasSettings) { + window.electronAPI.chatsLoaded(count).catch(() => {}) + } + } // Only load a session if initialSessionId is provided (from URL param) if (initialSessionId) { const session = await getSession(initialSessionId) - if (session) { - setCurrentSession(session) - setCurrentSessionId(session.id) - } + if (session) changeChat(session) // If session not found, stay in blank state (URL has invalid session ID) } // If no initialSessionId, start with blank state (no auto-restore) @@ -120,7 +185,7 @@ export function useSessionManager( } init() - }, [initialSessionId]) + }, [initialSessionId, changeChat]) // Handle URL session ID changes after initialization // Note: intentionally NOT including currentSessionId in deps to avoid race conditions @@ -135,6 +200,7 @@ export function useSessionManager( async function handleSessionIdChange() { if (initialSessionId) { + const generation = chatGenerationRef.current // URL has session ID - load it const session = await getSession(initialSessionId) @@ -143,16 +209,13 @@ export function useSessionManager( if (currentSequence !== urlChangeSequenceRef.current) { return } + // Another chat was put on screen meanwhile (New Chat right + // after this one got its session id in the URL): keep it + if (generation !== chatGenerationRef.current) return - if (session) { - // Only update if the session is different from current - setCurrentSessionId((current) => { - if (current !== session.id) { - setCurrentSession(session) - return session.id - } - return current - }) + // Only update if the session is different from current + if (session && currentSessionRef.current?.id !== session.id) { + changeChat(session) } } // Removed: else clause that clears session @@ -161,11 +224,17 @@ export function useSessionManager( } handleSessionIdChange() - }, [initialSessionId, isAvailable]) + }, [initialSessionId, isAvailable, changeChat]) - // Refresh sessions on window focus (multi-tab sync) + // Refresh sessions on window focus (multi-tab sync), at most once per interval + const lastFocusRefreshRef = useRef(0) useEffect(() => { const handleFocus = () => { + const now = Date.now() + if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) { + return + } + lastFocusRefreshRef.current = now refreshSessions() } window.addEventListener("focus", handleFocus) @@ -177,9 +246,11 @@ export function useSessionManager( async (id: string): Promise => { if (id === currentSessionId) return null - // Save current session first if it has messages - if (currentSession && currentSession.messages.length > 0) { - await saveSession(currentSession) + // Save current session first if it has messages (as saved + // last: the caller may have just saved it) + const current = currentSessionRef.current + if (current && current.messages.length > 0) { + await saveSession(current) } // Load the target session @@ -189,9 +260,7 @@ export function useSessionManager( return null } - // Update state - setCurrentSession(session) - setCurrentSessionId(session.id) + changeChat(session) return { messages: session.messages, @@ -201,7 +270,7 @@ export function useSessionManager( diagramHistory: session.diagramHistory, } }, - [currentSessionId, currentSession], + [currentSessionId, changeChat], ) // Delete a session @@ -211,101 +280,137 @@ export function useSessionManager( await deleteSessionFromDB(id) // If deleting current session, clear state (caller will show new empty session) - if (wasCurrentSession) { - setCurrentSession(null) - setCurrentSessionId(null) - } + if (wasCurrentSession) changeChat(null) await refreshSessions() return { wasCurrentSession } }, - [currentSessionId, refreshSessions], + [currentSessionId, refreshSessions, changeChat], ) // Save current session data (debounced externally by caller) - // forSessionId: if provided, verify save targets correct session (prevents stale debounce writes) const saveCurrentSession = useCallback( - async ( - data: SessionData, - forSessionId?: string | null, - ): Promise => { - // If forSessionId is provided, verify it matches current session - // This prevents stale debounced saves from overwriting a newly switched session - if ( - forSessionId !== undefined && - forSessionId !== currentSessionId - ) { - return + (data: SessionData, ticket?: SaveTicket): Promise => { + // The data is of the chat on screen when it was read + const { generation, seq } = ticket ?? { + generation: chatGenerationRef.current, + seq: ++saveSeqRef.current, } + const run = async (): Promise => { + // That chat is no longer on screen (leaving it saved it) + if (generation !== chatGenerationRef.current) return true + // A copy read later was saved already (one that waited for + // its thumbnail must not undo it) + if (seq < savedSeqRef.current) return true + // Nothing can be stored without IndexedDB + if (!isIndexedDBAvailable()) return true + // The user may put another chat on screen while this one is + // written; the stored copy is still right, the state is not + const stillOnScreen = () => + chatGenerationRef.current === generation + const currentSession = currentSessionRef.current - if (!currentSession) { - // Create a new session if none exists - const newSession: ChatSession = { - ...createEmptySession(), + if (!currentSession) { + // Create a new session if none exists + const newSession: ChatSession = { + ...createEmptySession(), + messages: data.messages, + xmlSnapshots: data.xmlSnapshots, + diagramXml: data.diagramXml, + thumbnailDataUrl: data.thumbnailDataUrl, + diagramHistory: data.diagramHistory, + title: extractTitle(data.messages), + } + // Without a stored session, keep no session id (it would end + // up in the URL and point to nothing after a reload) + if (!(await saveSession(newSession))) { + notifySaveFailed(dict.errors.sessionSaveFailed) + return false + } + savedSeqRef.current = seq + await enforceSessionLimit() + if (stillOnScreen()) { + currentSessionRef.current = newSession + setCurrentSession(newSession) + setCurrentSessionId(newSession.id) + } + await refreshSessions() + return true + } + + // Update existing session + const updatedSession: ChatSession = { + ...currentSession, messages: data.messages, xmlSnapshots: data.xmlSnapshots, diagramXml: data.diagramXml, - thumbnailDataUrl: data.thumbnailDataUrl, - diagramHistory: data.diagramHistory, - title: extractTitle(data.messages), + thumbnailDataUrl: + data.thumbnailDataUrl ?? + currentSession.thumbnailDataUrl, + diagramHistory: + data.diagramHistory ?? currentSession.diagramHistory, + updatedAt: Date.now(), + // Update title if it's still default and we have messages + title: + currentSession.title === "New Chat" && + data.messages.length > 0 + ? extractTitle(data.messages) + : currentSession.title, } - await saveSession(newSession) - await enforceSessionLimit() - setCurrentSession(newSession) - setCurrentSessionId(newSession.id) - await refreshSessions() - return + + if (!(await saveSession(updatedSession))) { + notifySaveFailed(dict.errors.sessionSaveFailed) + return false + } + savedSeqRef.current = seq + if (stillOnScreen()) { + currentSessionRef.current = updatedSession + setCurrentSession(updatedSession) + } + + // Update sessions list metadata + setSessions((prev) => + prev.map((s) => + s.id === updatedSession.id + ? { + ...s, + title: updatedSession.title, + updatedAt: updatedSession.updatedAt, + messageCount: updatedSession.messages.length, + hasDiagram: + !!updatedSession.diagramXml && + updatedSession.diagramXml.trim().length > + 0, + thumbnailDataUrl: + updatedSession.thumbnailDataUrl, + } + : s, + ), + ) + return true } - - // Update existing session - const updatedSession: ChatSession = { - ...currentSession, - messages: data.messages, - xmlSnapshots: data.xmlSnapshots, - diagramXml: data.diagramXml, - thumbnailDataUrl: - data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl, - diagramHistory: - data.diagramHistory ?? currentSession.diagramHistory, - updatedAt: Date.now(), - // Update title if it's still default and we have messages - title: - currentSession.title === "New Chat" && - data.messages.length > 0 - ? extractTitle(data.messages) - : currentSession.title, - } - - await saveSession(updatedSession) - setCurrentSession(updatedSession) - - // Update sessions list metadata - setSessions((prev) => - prev.map((s) => - s.id === updatedSession.id - ? { - ...s, - title: updatedSession.title, - updatedAt: updatedSession.updatedAt, - messageCount: updatedSession.messages.length, - hasDiagram: - !!updatedSession.diagramXml && - updatedSession.diagramXml.trim().length > 0, - thumbnailDataUrl: updatedSession.thumbnailDataUrl, - } - : s, - ), - ) + const result = saveQueueRef.current.then(run) + saveQueueRef.current = result.catch(() => {}) + return result }, - [currentSession, currentSessionId, refreshSessions], + [refreshSessions, dict], ) // Clear current session state (for starting fresh without loading another session) const clearCurrentSession = useCallback(() => { - setCurrentSession(null) - setCurrentSessionId(null) - }, []) + changeChat(null) + }, [changeChat]) + + const getChatGeneration = useCallback(() => chatGenerationRef.current, []) + + const getSaveTicket = useCallback( + (): SaveTicket => ({ + generation: chatGenerationRef.current, + seq: ++saveSeqRef.current, + }), + [], + ) return { sessions, @@ -318,5 +423,7 @@ export function useSessionManager( saveCurrentSession, refreshSessions, clearCurrentSession, + getChatGeneration, + getSaveTicket, } } diff --git a/hooks/use-validate-diagram.ts b/hooks/use-validate-diagram.ts index b9cea01b..d3402400 100644 --- a/hooks/use-validate-diagram.ts +++ b/hooks/use-validate-diagram.ts @@ -6,6 +6,7 @@ import { experimental_useObject as useObject } from "@ai-sdk/react" import { useCallback, useRef } from "react" +import { getSelectedAIConfig } from "@/hooks/use-model-config" import { getApiEndpoint } from "@/lib/base-path" import { type ValidationResult, @@ -39,6 +40,8 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) { const { object, submit, isLoading, error, stop } = useObject({ api: getApiEndpoint("/api/validate-diagram"), schema: ValidationResultSchema, + // Resolved per request so a changed access code is picked up + headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }), onFinish: ({ object, error: finishError, @@ -100,9 +103,22 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) { [submit], ) + /** + * End a running check (the user pressed Stop): its promise rejects with + * an AbortError, so the tool handler can finish at once. + */ + const cancel = useCallback(() => { + const pending = pendingValidationRef.current + if (!pending) return + pendingValidationRef.current = null + stop() + pending.reject(new DOMException("Validation cancelled", "AbortError")) + }, [stop]) + /** * Validate with fallback - returns default valid result on error. * Use this to avoid blocking the user on validation failures. + * A cancelled check is passed on as its AbortError. */ const validateWithFallback = useCallback( async ( @@ -112,6 +128,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) { try { return await validate(imageData, sessionId) } catch (error) { + if ((error as Error)?.name === "AbortError") throw error console.warn( "[useValidateDiagram] Validation failed, using fallback:", error, @@ -127,6 +144,7 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) { validate, validateWithFallback, stop, + cancel, // State isValidating: isLoading, diff --git a/lib/access-code.ts b/lib/access-code.ts new file mode 100644 index 00000000..ec6cb52c --- /dev/null +++ b/lib/access-code.ts @@ -0,0 +1,50 @@ +/** + * Refuse a POST that a page on another website could have sent. A browser + * sends a cross-site POST without asking first (CORS preflight) only with a + * text or form body, so the routes take JSON only. In the desktop app also + * refuse a foreign Host: a site that points its own domain name at + * 127.0.0.1 (DNS rebinding) is same-origin with the local server, but its + * requests carry that domain. A request the server builds itself has no + * Host. Returns the response to send, or null when the request may go on. + */ +export function rejectCrossSite(req: Request): Response | null { + const contentType = req.headers.get("content-type") ?? "" + if (!/^\s*application\/json\b/i.test(contentType)) { + return Response.json( + { error: "Content-Type must be application/json" }, + { status: 415 }, + ) + } + const host = req.headers.get("host") + if ( + process.env.NEXT_AI_DRAWIO_DESKTOP === "1" && + host && + !/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host) + ) { + return Response.json({ error: "Forbidden" }, { status: 403 }) + } + return null +} + +/** + * Check the x-access-code header against ACCESS_CODE_LIST. + * Returns a 401 response to send back when the check fails, or null when the + * request may continue (including when no access codes are configured). + */ +export function checkAccessCode(req: Request): Response | null { + const accessCodes = + process.env.ACCESS_CODE_LIST?.split(",") + .map((code) => code.trim()) + .filter(Boolean) || [] + if (accessCodes.length === 0) return null + + const accessCodeHeader = req.headers.get("x-access-code") + if (accessCodeHeader && accessCodes.includes(accessCodeHeader)) return null + + return Response.json( + { + error: "Invalid or missing access code. Please configure it in Settings.", + }, + { status: 401 }, + ) +} diff --git a/lib/admin/providers.ts b/lib/admin/providers.ts index 67d50344..39a48cae 100644 --- a/lib/admin/providers.ts +++ b/lib/admin/providers.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { ProviderNameSchema, type ServerModelsConfig, + slugify, } from "@/lib/server-model-config" import { FIXED_CRED_PROVIDERS, @@ -182,12 +183,15 @@ export function validateAdminProviders( return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.` } } + // Server model ids are built from the slugified name, so names must + // stay distinct after slugifying ("OpenAI" and "openai" would collide) const names = list.map((p) => displayName(p)) - if (new Set(names).size !== names.length) { - return "Provider display names must be unique." + const slugs = names.map(slugify) + if (new Set(slugs).size !== slugs.length) { + return "Provider display names must be unique (ignoring case and punctuation)." } - const envNames = new Set(envProviders.map((p) => p.name)) - const clash = names.find((n) => envNames.has(n)) + const envSlugs = new Set(envProviders.map((p) => slugify(p.name))) + const clash = names.find((_, i) => envSlugs.has(slugs[i])) if (clash) { return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.` } @@ -240,10 +244,14 @@ export function deriveEnvUpdates( indexByProvider.set(p.provider, index + 1) if (p.provider === "bedrock") { - if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId + // ADMIN_ names keep the standard AWS_* vars untouched, so other + // AWS clients (e.g. the DynamoDB quota table) keep their own + // credentials instead of picking up the panel's Bedrock keys + if (p.awsAccessKeyId) + updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId if (p.awsSecretAccessKey) - updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey - if (p.awsRegion) updates.AWS_REGION = p.awsRegion + updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey + if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion } else if (p.provider === "vertexai") { if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl @@ -284,6 +292,10 @@ function derivedEnvKeys(list: StoredAdminProvider[]): string[] { const index = indexByProvider.get(p.provider) ?? 0 indexByProvider.set(p.provider, index + 1) if (p.provider === "bedrock") { + keys.add("ADMIN_AWS_ACCESS_KEY_ID") + keys.add("ADMIN_AWS_SECRET_ACCESS_KEY") + keys.add("ADMIN_AWS_REGION") + // Written by older versions; listed so the next save clears them keys.add("AWS_ACCESS_KEY_ID") keys.add("AWS_SECRET_ACCESS_KEY") keys.add("AWS_REGION") diff --git a/lib/admin/settings.ts b/lib/admin/settings.ts index 664c370e..f2ecce40 100644 --- a/lib/admin/settings.ts +++ b/lib/admin/settings.ts @@ -10,13 +10,27 @@ interface SettingsFile { values: Record } -// Original env values snapshotted before the first overlay, so removing a -// key from the settings file restores the env default. null = was unset. -const originalEnv: Record = {} -// Keys currently overlaid, so we can restore ones removed from the file. -let overlaidKeys = new Set() +interface SettingsState { + // Original env values snapshotted before the first overlay, so removing + // a key from the settings file restores the env default. null = was unset. + originalEnv: Record + // Keys currently overlaid, so we can restore ones removed from the file. + overlaidKeys: Set + cachedSettings: Record | null +} -let cachedSettings: Record | null = null +// Kept on globalThis because the build can load this module more than once +// (instrumentation.ts and the API routes get separate copies); per-module +// state would make a route forget what instrumentation overlaid at startup. +const globalState = globalThis as typeof globalThis & { + __adminSettingsState?: SettingsState +} +globalState.__adminSettingsState ??= { + originalEnv: {}, + overlaidKeys: new Set(), + cachedSettings: null, +} +const state = globalState.__adminSettingsState export function getSettingsPath(): string { const custom = process.env.SETTINGS_FILE @@ -25,7 +39,7 @@ export function getSettingsPath(): string { } export function loadSettings(): Record { - if (cachedSettings) return cachedSettings + if (state.cachedSettings) return state.cachedSettings try { const raw = fs.readFileSync(getSettingsPath(), "utf8") const parsed = JSON.parse(raw) as SettingsFile @@ -43,21 +57,22 @@ export function loadSettings(): Record { for (const [key, value] of Object.entries(rawValues)) { if (typeof value === "string") values[key] = value } - cachedSettings = values + state.cachedSettings = values } catch (err: any) { if (err?.code !== "ENOENT") { console.error("[admin-settings] Failed to read settings file:", err) } - cachedSettings = {} + state.cachedSettings = {} } - return cachedSettings + return state.cachedSettings } export function applyToEnv(): void { const values = loadSettings() + const { originalEnv } = state // Restore env for keys that were overlaid before but are now gone - for (const key of overlaidKeys) { + for (const key of state.overlaidKeys) { if (!(key in values)) { const original = originalEnv[key] if (original === null) delete process.env[key] @@ -72,12 +87,12 @@ export function applyToEnv(): void { process.env[key] = value } - overlaidKeys = new Set(Object.keys(values)) + state.overlaidKeys = new Set(Object.keys(values)) } // The effective env value if the file entry were removed (for fallback display) export function getEnvFallback(key: string): string | null { - if (overlaidKeys.has(key)) return originalEnv[key] ?? null + if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null return process.env[key] ?? null } @@ -101,7 +116,7 @@ export function saveSettings(updates: Record): void { fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 }) fs.renameSync(tmpPath, filePath) - cachedSettings = current + state.cachedSettings = current applyToEnv() } @@ -122,13 +137,13 @@ export function isSettingsWritable(): boolean { // Test-only: reset module state export function _resetForTests(): void { - cachedSettings = null + state.cachedSettings = null writableCache = null - for (const key of overlaidKeys) { - const original = originalEnv[key] + for (const key of state.overlaidKeys) { + const original = state.originalEnv[key] if (original === null) delete process.env[key] else if (original !== undefined) process.env[key] = original } - overlaidKeys = new Set() - for (const key of Object.keys(originalEnv)) delete originalEnv[key] + state.overlaidKeys = new Set() + state.originalEnv = {} } diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index eb45831a..bf040440 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -1,16 +1,33 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" import { createAnthropic } from "@ai-sdk/anthropic" -import { azure, createAzure } from "@ai-sdk/azure" -import { createDeepSeek, deepseek } from "@ai-sdk/deepseek" -import { createGateway, gateway } from "@ai-sdk/gateway" -import { createGoogleGenerativeAI, google } from "@ai-sdk/google" +import { createAzure } from "@ai-sdk/azure" +import { createDeepSeek } from "@ai-sdk/deepseek" +import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createVertex } from "@ai-sdk/google-vertex" -import { createOpenAI, openai } from "@ai-sdk/openai" -import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider" +import { createOpenAI } from "@ai-sdk/openai" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { createAihubmix } from "@aihubmix/ai-sdk-provider" import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { createOpenRouter } from "@openrouter/ai-sdk-provider" -import { createOllama, ollama } from "ollama-ai-provider-v2" -import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" +import { + createGateway, + defaultSettingsMiddleware, + extractReasoningMiddleware, + type LanguageModel, + wrapLanguageModel, +} from "ai" +import { createOllama } from "ollama-ai-provider-v2" +import { + adminProvidersToConfig, + loadAdminProviders, +} from "@/lib/admin/providers" +import { getEnvFallback } from "@/lib/admin/settings" +import { isPrivateUrl, redirectGuardedFetch } from "@/lib/ssrf-protection" +import { + normalizeBaseUrl, + PROVIDER_INFO, + type ProviderName, +} from "@/lib/types/model-config" export type { ProviderName } @@ -19,7 +36,6 @@ export const AIHUBMIX_APP_CODE = "MSBS9675" interface ModelConfig { model: any providerOptions?: any - headers?: Record modelId: string provider: ProviderName } @@ -85,6 +101,9 @@ export interface ClientOverrides { awsSessionToken?: string | null // Vertex AI config vertexApiKey?: string | null // Express Mode API key + // baseUrl is the server's own

_BASE_URL (the admin panel's Test), + // not one a user chose: no redirect guard + trustedBaseUrl?: boolean // Custom headers (e.g., for EdgeOne cookie auth) headers?: Record // Custom env var name(s) for server models @@ -93,34 +112,6 @@ export interface ClientOverrides { baseUrlEnv?: string } -// Providers that can be selected from client settings -const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [ - "openai", - "anthropic", - "google", - "vertexai", - "azure", - "bedrock", - "openrouter", - "aihubmix", - "deepseek", - "siliconflow", - "sglang", - "gateway", - "edgeone", - "ollama", - "doubao", - "modelscope", - "glm", - "qwen", - "qiniu", - "kimi", - "minimax", - "novita", - "mimo", - "atlascloud", -] - // Bedrock provider options for Anthropic beta features const BEDROCK_ANTHROPIC_BETA = { bedrock: { @@ -128,11 +119,6 @@ const BEDROCK_ANTHROPIC_BETA = { }, } -// Direct Anthropic API headers for beta features -const ANTHROPIC_BETA_HEADERS = { - "anthropic-beta": "fine-grained-tool-streaming-2025-05-14", -} - /** * Resolve baseURL based on whether user is providing their own API key. * When user provides their own API key, we should NOT fall back to server's @@ -237,13 +223,33 @@ function parseIntSafe( return parsed } +/** + * GOOGLE_TOP_K and GOOGLE_TOP_P. They are call settings, so they go on the + * model through a middleware: as Google provider options they were dropped. + */ +function googleSamplingSettings(): { topK?: number; topP?: number } { + const settings: { topK?: number; topP?: number } = {} + const topK = parseIntSafe(process.env.GOOGLE_TOP_K, "GOOGLE_TOP_K", 1, 100) + if (topK) settings.topK = topK + if (process.env.GOOGLE_TOP_P) { + const topP = Number.parseFloat(process.env.GOOGLE_TOP_P) + if (Number.isNaN(topP) || topP < 0 || topP > 1) { + throw new Error( + `GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`, + ) + } + settings.topP = topP + } + return settings +} + /** * Build provider-specific options from environment variables * Supports various AI SDK providers with their unique configuration options * * Environment variables: - * - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/o4/gpt-5 - * - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for o1/o3/o4/gpt-5 + * - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for the o-series and gpt-5 or later + * - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (auto/detailed) - auto-enabled for the o-series and gpt-5 or later * - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000) * - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled) * - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000) @@ -267,14 +273,9 @@ function buildProviderOptions( const reasoningEffort = process.env.OPENAI_REASONING_EFFORT const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY - // OpenAI reasoning models (o1, o3, o4, gpt-5) need reasoningSummary to return thoughts - if ( - modelId && - (modelId.includes("o1") || - modelId.includes("o3") || - modelId.includes("o4") || - modelId.includes("gpt-5")) - ) { + // Reasoning models (the o-series, gpt-5 and later) need + // reasoningSummary to return thoughts + if (modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)) { options.openai = { // Auto-enable reasoning summary for reasoning models // Use 'auto' as default since not all models support 'detailed' @@ -331,7 +332,6 @@ function buildProviderOptions( } case "google": { - const reasoningEffort = process.env.GOOGLE_REASONING_EFFORT const thinkingBudgetVal = parseIntSafe( process.env.GOOGLE_THINKING_BUDGET, "GOOGLE_THINKING_BUDGET", @@ -370,47 +370,6 @@ function buildProviderOptions( } options.google = { thinkingConfig } - } else if (reasoningEffort) { - options.google = { - reasoningEffort: reasoningEffort as - | "low" - | "medium" - | "high", - } - } - - // Keep existing Google options - const options_obj: Record = {} - const candidateCount = parseIntSafe( - process.env.GOOGLE_CANDIDATE_COUNT, - "GOOGLE_CANDIDATE_COUNT", - 1, - 8, - ) - if (candidateCount) { - options_obj.candidateCount = candidateCount - } - const topK = parseIntSafe( - process.env.GOOGLE_TOP_K, - "GOOGLE_TOP_K", - 1, - 100, - ) - if (topK) { - options_obj.topK = topK - } - if (process.env.GOOGLE_TOP_P) { - const topP = Number.parseFloat(process.env.GOOGLE_TOP_P) - if (Number.isNaN(topP) || topP < 0 || topP > 1) { - throw new Error( - `GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`, - ) - } - options_obj.topP = topP - } - - if (Object.keys(options_obj).length > 0) { - options.google = { ...options.google, ...options_obj } } break } @@ -530,27 +489,6 @@ function buildProviderOptions( break } - case "deepseek": - case "openrouter": - case "aihubmix": - case "siliconflow": - case "sglang": - case "gateway": - case "modelscope": - case "doubao": - case "minimax": - case "glm": - case "qwen": - case "kimi": - case "qiniu": - case "novita": - case "atlascloud": - case "mimo": { - // These providers don't have reasoning configs in AI SDK yet - // Gateway passes through to underlying providers which handle their own configs - break - } - default: break } @@ -635,6 +573,7 @@ function detectProvider(): ProviderName | null { function validateProviderCredentials( provider: ProviderName, customApiKeyEnv?: string | string[], + customBaseUrlEnv?: string, ): void { // Handle array of env var names - at least one must be set if (Array.isArray(customApiKeyEnv)) { @@ -670,9 +609,12 @@ function validateProviderCredentials( } } - // Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition to API key + // Azure requires either AZURE_BASE_URL or AZURE_RESOURCE_NAME in addition + // to API key, or a server model's own URL variable (an admin panel entry) if (provider === "azure") { - const hasBaseUrl = !!process.env.AZURE_BASE_URL + const hasBaseUrl = + !!process.env.AZURE_BASE_URL || + !!(customBaseUrlEnv && process.env[customBaseUrlEnv]) const hasResourceName = !!process.env.AZURE_RESOURCE_NAME if (!hasBaseUrl && !hasResourceName) { throw new Error( @@ -683,41 +625,189 @@ function validateProviderCredentials( } } +/** AWS's Bedrock endpoint for a region, as the Bedrock SDK builds it */ +function bedrockRuntimeUrl(region: string): string { + const suffix = + [ + ["cn-", "amazonaws.com.cn"], + ["us-iso-", "c2s.ic.gov"], + ["us-isob-", "sc2s.sgov.gov"], + ["eu-isoe-", "cloud.adc-e.uk"], + ["us-isof-", "csp.hci.ic.gov"], + ["eusc-", "amazonaws.eu"], + ].find(([prefix]) => region.startsWith(prefix))?.[1] ?? "amazonaws.com" + return `https://bedrock-runtime.${region}.${suffix}` +} + /** - * Get the AI model based on environment variables - * - * Environment variables: - * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope) - * - AI_MODEL: The model ID/name for the selected provider - * - * Provider-specific env vars: - * - OPENAI_API_KEY: OpenAI API key - * - OPENAI_BASE_URL: Custom OpenAI-compatible endpoint (optional) - * - ANTHROPIC_API_KEY: Anthropic API key - * - GOOGLE_GENERATIVE_AI_API_KEY: Google API key - * - AZURE_RESOURCE_NAME, AZURE_API_KEY: Azure OpenAI credentials - * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials - * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api) - * - OPENROUTER_API_KEY: OpenRouter API key - * - AIHUBMIX_API_KEY: AIHubMix API key - * - DEEPSEEK_API_KEY: DeepSeek API key - * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) - * - SILICONFLOW_API_KEY: SiliconFlow API key - * - SILICONFLOW_BASE_URL: SiliconFlow endpoint (optional, defaults to https://api.siliconflow.cn/v1) - * - SGLANG_API_KEY: SGLang API key - * - SGLANG_BASE_URL: SGLang endpoint (optional) - * - MODELSCOPE_API_KEY: ModelScope API key - * - MODELSCOPE_BASE_URL: ModelScope endpoint (optional) + * Providers whose SDK has the official endpoint built in. The others are + * OpenAI-compatible APIs (or Anthropic) that are called at + * PROVIDER_INFO.defaultBaseUrl unless a base URL is configured. */ -export function getAIModel(overrides?: ClientOverrides): ModelConfig { +const SDK_KNOWS_ENDPOINT = new Set([ + "openai", + "google", + "azure", + "openrouter", + "gateway", + "deepseek", +]) + +/** Where and how to call a provider, once credentials are resolved */ +interface Endpoint { + apiKey?: string + baseURL?: string + headers?: Record + fetch?: typeof fetch + authToken?: string // Anthropic Bearer auth + resourceName?: string // Azure + // baseURL comes from the settings or env, not the provider's default + configuredBaseURL?: boolean +} + +/** + * An OpenAI-compatible chat model. includeUsage asks for token usage in the + * stream, which quota tracking needs. Some of these models write their + * reasoning inside tags; that text becomes reasoning, not reply. + */ +function compatibleModel( + provider: ProviderName, + modelId: string, + e: Endpoint, +): LanguageModel { + const model = createOpenAICompatible({ + name: provider, + apiKey: e.apiKey, + baseURL: e.baseURL ?? "", + ...(e.headers && { headers: e.headers }), + ...(e.fetch && { fetch: e.fetch }), + includeUsage: true, + })(modelId) + return wrapLanguageModel({ + model, + middleware: extractReasoningMiddleware({ tagName: "think" }), + }) +} + +/** Create the model for a provider. Credentials are already resolved. */ +function createModel( + provider: ProviderName, + modelId: string, + e: Endpoint, +): LanguageModel { + const opts = { + apiKey: e.apiKey, + ...(e.baseURL && { baseURL: e.baseURL }), + ...(e.fetch && { fetch: e.fetch }), + } + switch (provider) { + case "openai": { + const openaiProvider = createOpenAI(opts) + // A configured base URL is usually a proxy that only has Chat + // Completions; without one the Responses API is used, which + // returns reasoning for the o-series and gpt-5 or later + return e.configuredBaseURL + ? openaiProvider.chat(modelId) + : openaiProvider(modelId) + } + case "anthropic": + // The provider streams tool input per tool (eager_input_streaming), + // which replaced the fine-grained-tool-streaming beta header + return createAnthropic({ + ...(e.authToken + ? { authToken: e.authToken } + : { apiKey: e.apiKey }), + baseURL: e.baseURL, + ...(e.fetch && { fetch: e.fetch }), + })(modelId) + case "google": { + const model = createGoogleGenerativeAI(opts)(modelId) + const sampling = googleSamplingSettings() + return Object.keys(sampling).length > 0 + ? wrapLanguageModel({ + model, + middleware: defaultSettingsMiddleware({ + settings: sampling, + }), + }) + : model + } + case "azure": + // baseURL takes precedence over resourceName per SDK behavior + return createAzure({ + ...opts, + ...(!e.baseURL && + e.resourceName && { resourceName: e.resourceName }), + })(modelId) + case "openrouter": + return createOpenRouter(opts)(modelId) + case "gateway": + // Without a key or URL the SDK uses Vercel's endpoint and OIDC + return createGateway(opts)(modelId) + case "deepseek": + case "kimi": + case "mimo": + // Kimi and MiMo return reasoning_content like DeepSeek and need it + // passed back in multi-turn tool calls (MiMo answers 400 otherwise) + return createDeepSeek(opts)(modelId) + case "doubao": { + // DeepSeek and Kimi models on Doubao use reasoning_content too + const lower = modelId.toLowerCase() + return lower.includes("deepseek") || lower.includes("kimi") + ? createDeepSeek(opts)(modelId) + : compatibleModel(provider, modelId, e) + } + case "aihubmix": + return isAihubmixStandardBaseURL(e.baseURL) + ? createAihubmix({ + apiKey: e.apiKey, + appCode: AIHUBMIX_APP_CODE, + })(modelId) + : compatibleModel(provider, modelId, e) + case "minimax": { + const { baseURL, isAnthropicCompatible } = normalizeMiniMaxBaseURL( + e.baseURL as string, + ) + return isAnthropicCompatible + ? createAnthropic({ + apiKey: e.apiKey, + baseURL, + ...(e.fetch && { fetch: e.fetch }), + })(modelId) + : compatibleModel(provider, modelId, { ...e, baseURL }) + } + default: + // siliconflow, sglang, modelscope, glm, qwen, qiniu, novita, + // atlascloud, edgeone + return compatibleModel(provider, modelId, e) + } +} + +/** + * Get the AI model for a chat request: the client's own provider and + * credentials, or the server's (AI_PROVIDER, AI_MODEL and each provider's + * _API_KEY / _BASE_URL, see env.example). The settings test + * button uses the same function, so a passing test means the chat works. + */ +export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig { + // Drop an endpoint path pasted along with the client's base URL + const overrides = clientOverrides?.baseUrl + ? { + ...clientOverrides, + baseUrl: normalizeBaseUrl(clientOverrides.baseUrl), + } + : clientOverrides // SECURITY: Prevent SSRF attacks (GHSA-9qf7-mprq-9qgm) // If a custom baseUrl is provided, an API key MUST also be provided. // This prevents attackers from redirecting server API keys to malicious endpoints. // Exception: EdgeOne doesn't require API keys. // Ollama is exempt only when no server OLLAMA_API_KEY is configured; // when it IS configured, the outer guard also enforces client apiKey for custom baseUrls. + // A trusted URL is the server's own (the admin Test of an entry without + // one), not a user's if ( overrides?.baseUrl && + !overrides?.trustedBaseUrl && !overrides?.apiKey && !(overrides?.provider === "vertexai" && overrides?.vertexApiKey) && overrides?.provider !== "edgeone" && @@ -756,13 +846,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { let provider: ProviderName if (overrides?.provider) { // Validate client-provided provider - if ( - !ALLOWED_CLIENT_PROVIDERS.includes( - overrides.provider as ProviderName, - ) - ) { + if (!Object.hasOwn(PROVIDER_INFO, overrides.provider)) { throw new Error( - `Invalid provider: ${overrides.provider}. Allowed providers: ${ALLOWED_CLIENT_PROVIDERS.join(", ")}`, + `Invalid provider: ${overrides.provider}. Allowed providers: ${Object.keys(PROVIDER_INFO).join(", ")}`, ) } provider = overrides.provider as ProviderName @@ -780,52 +866,75 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { .map(([p]) => p) if (configured.length === 0) { + const keys = Object.entries(PROVIDER_ENV_VARS) + .filter(([, envVar]) => envVar) + .map(([p, envVar]) => `- ${envVar} for ${p}`) throw new Error( `No AI provider configured. Please set one of the following API keys in your .env.local file:\n` + - `- AI_GATEWAY_API_KEY for Vercel AI Gateway\n` + - `- DEEPSEEK_API_KEY for DeepSeek\n` + - `- OPENAI_API_KEY for OpenAI\n` + - `- ANTHROPIC_API_KEY for Anthropic\n` + - `- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` + - `- AWS_ACCESS_KEY_ID for Bedrock\n` + - `- OPENROUTER_API_KEY for OpenRouter\n` + - `- AIHUBMIX_API_KEY for AIHubMix\n` + - `- AZURE_API_KEY for Azure\n` + - `- SILICONFLOW_API_KEY for SiliconFlow\n` + - `- SGLANG_API_KEY for SGLang\n` + - `- MODELSCOPE_API_KEY for ModelScope\n` + + `${keys.join("\n")}\n` + + `- AWS_ACCESS_KEY_ID for bedrock\n` + `Or set AI_PROVIDER=ollama for local Ollama.`, ) - } else { - throw new Error( - `Multiple AI providers configured (${configured.join(", ")}). ` + - `Please set AI_PROVIDER to specify which one to use.`, - ) } + throw new Error( + `Multiple AI providers configured (${configured.join(", ")}). ` + + `Please set AI_PROVIDER to specify which one to use.`, + ) } } + if (!Object.hasOwn(PROVIDER_INFO, provider)) { + throw new Error( + `Unknown AI provider: ${provider}. Supported providers: ${Object.keys(PROVIDER_INFO).join(", ")}`, + ) + } // Only validate server credentials if client isn't providing their own API key if (!isClientOverride) { - validateProviderCredentials(provider, overrides?.apiKeyEnv) + validateProviderCredentials( + provider, + overrides?.apiKeyEnv, + overrides?.baseUrlEnv, + ) } console.log(`[AI Provider] Initializing ${provider} with model: ${modelId}`) - let model: any - let providerOptions: any - let headers: Record | undefined - + // Requests to a base URL the client chose must not follow redirects + const guardedFetch = + overrides?.baseUrl && !overrides.trustedBaseUrl + ? redirectGuardedFetch() + : undefined // Build provider-specific options from environment variables - const customProviderOptions = buildProviderOptions(provider, modelId) + let providerOptions = buildProviderOptions(provider, modelId) + let model: LanguageModel switch (provider) { case "bedrock": { // Use client-provided credentials if available, otherwise fall back to IAM/env vars const hasClientCredentials = overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey + // Keys from the admin panel. The ADMIN_ names keep them out of the + // default AWS credential chain, which other clients such as the + // DynamoDB quota manager use with their own credentials. + const adminAccessKeyId = process.env.ADMIN_AWS_ACCESS_KEY_ID + const adminSecretAccessKey = process.env.ADMIN_AWS_SECRET_ACCESS_KEY + // The region becomes part of the endpoint's host name, so a + // request's region must be a region name, or it could send the + // server's credentials to another host + if ( + overrides?.awsRegion && + !/^[a-z]{2,4}(-[a-z]+)+-\d{1,2}$/.test(overrides.awsRegion) + ) { + throw Object.assign( + new Error(`Invalid AWS region "${overrides.awsRegion}"`), + { statusCode: 400 }, + ) + } const bedrockRegion = - overrides?.awsRegion || process.env.AWS_REGION || "us-west-2" + overrides?.awsRegion || + process.env.ADMIN_AWS_REGION || + process.env.AWS_REGION || + "us-west-2" const bedrockProvider = hasClientCredentials ? createAmazonBedrock({ @@ -835,11 +944,25 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { ...(overrides?.awsSessionToken && { sessionToken: overrides.awsSessionToken, }), + // Without an apiKey the SDK reads the server's + // AWS_BEARER_TOKEN_BEDROCK, which wins over the keys + apiKey: "", + // Without a baseURL it reads the server's + // AWS_ENDPOINT_URL_BEDROCK_RUNTIME / AWS_ENDPOINT_URL + baseURL: bedrockRuntimeUrl(bedrockRegion), }) - : createAmazonBedrock({ - region: bedrockRegion, - credentialProvider: fromNodeProviderChain(), - }) + : adminAccessKeyId && adminSecretAccessKey + ? createAmazonBedrock({ + region: bedrockRegion, + accessKeyId: adminAccessKeyId, + secretAccessKey: adminSecretAccessKey, + // The keys the admin panel's Test button checked + apiKey: "", + }) + : createAmazonBedrock({ + region: bedrockRegion, + credentialProvider: fromNodeProviderChain(), + }) model = bedrockProvider(modelId) // Add Anthropic beta options if using Claude models via Bedrock if (modelId.includes("anthropic.claude")) { @@ -847,102 +970,20 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { providerOptions = { bedrock: { ...BEDROCK_ANTHROPIC_BETA.bedrock, - ...(customProviderOptions?.bedrock || {}), + ...(providerOptions?.bedrock || {}), }, } - } else if (customProviderOptions) { - providerOptions = customProviderOptions } break } - case "openai": { - const apiKey = resolveApiKey(overrides, "OPENAI_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "OPENAI_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - if (baseURL) { - // Custom base URL = third-party proxy, use Chat Completions API - // for compatibility (most proxies don't support /responses endpoint) - const customOpenAI = createOpenAI({ apiKey, baseURL }) - model = customOpenAI.chat(modelId) - } else if (overrides?.apiKey) { - // Custom API key but official OpenAI endpoint, use Responses API - // to support reasoning for gpt-5, o1, o3, o4 models - const customOpenAI = createOpenAI({ apiKey }) - model = customOpenAI(modelId) - } else { - model = openai(modelId) - } - break - } - - case "anthropic": { - const apiKey = resolveApiKey(overrides, "ANTHROPIC_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "ANTHROPIC_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - "https://api.anthropic.com/v1", - ) - // Anthropic supports two auth methods (mutually exclusive): - // - apiKey: sends as `x-api-key` header - // - authToken: sends as `Authorization: Bearer ` header - // Prefer apiKey if present (including client overrides); fall back - // to ANTHROPIC_AUTH_TOKEN env var only when no apiKey is available. - const authToken = !apiKey - ? process.env.ANTHROPIC_AUTH_TOKEN - : undefined - const customProvider = createAnthropic({ - ...(authToken ? { authToken } : { apiKey }), - baseURL, - headers: ANTHROPIC_BETA_HEADERS, - }) - model = customProvider(modelId) - // Add beta headers for fine-grained tool streaming - headers = ANTHROPIC_BETA_HEADERS - break - } - - case "google": { - const apiKey = resolveApiKey( - overrides, - "GOOGLE_GENERATIVE_AI_API_KEY", - ) - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "GOOGLE_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - if (baseURL || overrides?.apiKey) { - const customGoogle = createGoogleGenerativeAI({ - apiKey, - ...(baseURL && { baseURL }), - }) - model = customGoogle(modelId) - } else { - model = google(modelId) - } - break - } case "vertexai": { // Express Mode: Use API key for authentication - const vertexApiKey = - overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY + // SECURITY: a client base URL only ever gets the client's key, so the + // server's GOOGLE_VERTEX_API_KEY is never sent to a client-chosen host + const vertexApiKey = overrides?.baseUrl + ? overrides.vertexApiKey + : overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY if (!vertexApiKey) { throw new Error( @@ -951,491 +992,241 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig { ) } - // Support custom base URL from env or client override - const baseURL = - overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL - - const vertexProvider = createVertex({ + // Support custom base URL from env or client override. + // A client key only goes to the client's URL or the official one. + const baseURL = resolveBaseURL( + overrides?.vertexApiKey, + overrides?.baseUrl, + process.env.GOOGLE_VERTEX_BASE_URL, + ) + model = createVertex({ apiKey: vertexApiKey, ...(baseURL && { baseURL }), - }) - model = vertexProvider(modelId) - break - } - - case "azure": { - const apiKey = resolveApiKey(overrides, "AZURE_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv(overrides, "AZURE_BASE_URL") - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - // Only use server's resourceName if user is NOT providing their own API key - const resourceName = overrides?.apiKey - ? undefined - : process.env.AZURE_RESOURCE_NAME - // Azure requires either baseURL or resourceName to construct the endpoint - // resourceName constructs: https://{resourceName}.openai.azure.com/openai/v1{path} - if (baseURL || resourceName || overrides?.apiKey) { - const customAzure = createAzure({ - apiKey, - // baseURL takes precedence over resourceName per SDK behavior - ...(baseURL && { baseURL }), - ...(!baseURL && resourceName && { resourceName }), - }) - model = customAzure(modelId) - } else { - model = azure(modelId) - } + ...(guardedFetch && { fetch: guardedFetch }), + })(modelId) break } case "ollama": { - const baseURL = overrides?.baseUrl || process.env.OLLAMA_BASE_URL // SECURITY: When client provides a custom base URL, only use // client-provided API key. Never fall back to server OLLAMA_API_KEY // to prevent leaking server credentials to user-controlled endpoints. const apiKey = overrides?.baseUrl ? overrides?.apiKey || undefined : resolveApiKey(overrides, "OLLAMA_API_KEY") - if (baseURL || apiKey) { - const customOllama = createOllama({ - ...(baseURL && { baseURL }), - ...(apiKey && { - headers: { Authorization: `Bearer ${apiKey}` }, - }), - }) - model = customOllama(modelId) - } else { - model = ollama(modelId) - } - break - } - - case "openrouter": { - const apiKey = resolveApiKey(overrides, "OPENROUTER_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "OPENROUTER_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - const openrouter = createOpenRouter({ - apiKey, + // Like other providers, a user's key never goes to the server's + // base URL: without a URL of their own it goes to Ollama Cloud. + // The server's key goes to OLLAMA_BASE_URL (or a server model's + // own variable), else to the SDK's local default: the desktop + // app's "Ollama (Local)" preset puts its key field there too. + const baseURL = + overrides?.baseUrl || + (overrides?.apiKey + ? PROVIDER_INFO.ollama.defaultBaseUrl + : resolveBaseUrlEnv(overrides, "OLLAMA_BASE_URL")) + model = createOllama({ ...(baseURL && { baseURL }), - }) - model = openrouter(modelId) + ...(apiKey && { + headers: { Authorization: `Bearer ${apiKey}` }, + }), + ...(guardedFetch && { fetch: guardedFetch }), + })(modelId) break } - case "aihubmix": { - const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( + case "edgeone": + // EdgeOne Pages Edge AI, an OpenAI-compatible API without a key. + // The SDK appends /chat/completions to the base URL. Cookies + // (eo_token, eo_time) and the access code authenticate the call. + model = compatibleModel(provider, modelId, { + apiKey: "edgeone", + baseURL: overrides?.baseUrl || "/api/edgeai", + headers: overrides?.headers, + fetch: guardedFetch, + }) + break + + default: { + // Every other provider takes an API key and a base URL from + // _API_KEY / _BASE_URL (or a server model's apiKeyEnv) + const apiKey = resolveApiKey( overrides, - "AIHUBMIX_BASE_URL", + PROVIDER_ENV_VARS[provider] as string, ) - const baseURL = resolveBaseURL( + const baseUrlEnv = + provider === "gateway" + ? "AI_GATEWAY_BASE_URL" + : `${provider.toUpperCase()}_BASE_URL` + // A local default (SGLang's 127.0.0.1) only fills the settings + // form; the server must not call its own machine for it. With a + // user's key the OpenAI SDK would read the server's + // OPENAI_BASE_URL, so name the official endpoint. + const defaultUrl = PROVIDER_INFO[provider].defaultBaseUrl + const publicDefault = defaultUrl?.startsWith("https://") + ? defaultUrl + : undefined + const configuredBaseURL = resolveBaseURL( overrides?.apiKey, overrides?.baseUrl, - serverBaseUrl, - PROVIDER_INFO.aihubmix.defaultBaseUrl, + resolveBaseUrlEnv(overrides, baseUrlEnv), ) - const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl - + const baseURL = + configuredBaseURL || + (SDK_KNOWS_ENDPOINT.has(provider) && + !(provider === "openai" && overrides?.apiKey) + ? undefined + : publicDefault) + // With a user's Azure key the SDK would read the server's + // AZURE_RESOURCE_NAME if ( - isAihubmixStandardBaseURL(baseURL) || - baseURL === defaultBaseURL + !baseURL && + (!SDK_KNOWS_ENDPOINT.has(provider) || + (provider === "azure" && overrides?.apiKey)) ) { - const aihubmixProvider = - overrides?.apiKey || apiKey - ? createAihubmix({ - apiKey, - appCode: AIHUBMIX_APP_CODE, - }) - : aihubmix - model = aihubmixProvider(modelId) - } else { - const aihubmixCompatibleProvider = createOpenAI({ - apiKey, - baseURL, - }) - model = aihubmixCompatibleProvider.chat(modelId) - } - break - } - - case "deepseek": { - const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "DEEPSEEK_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - if (baseURL || overrides?.apiKey) { - const customDeepSeek = createDeepSeek({ - apiKey, - ...(baseURL && { baseURL }), - }) - model = customDeepSeek(modelId) - } else { - model = deepseek(modelId) - } - break - } - - case "siliconflow": { - const apiKey = resolveApiKey(overrides, "SILICONFLOW_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "SILICONFLOW_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - "https://api.siliconflow.cn/v1", - ) - const siliconflowProvider = createOpenAI({ - apiKey, - baseURL, - }) - model = siliconflowProvider.chat(modelId) - break - } - - case "sglang": { - const apiKey = resolveApiKey(overrides, "SGLANG_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "SGLANG_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - - const sglangProvider = createOpenAI({ - apiKey, - ...(baseURL && { baseURL }), - // Add a custom fetch wrapper to intercept and fix the stream from sglang - fetch: async (url, options) => { - const response = await fetch(url, options) - if (!response.body) { - return response - } - - // Create a transform stream to fix the non-compliant sglang stream - let buffer = "" - const decoder = new TextDecoder() - - const transformStream = new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }) - // Process all complete messages in the buffer - let messageEndPos - while ( - (messageEndPos = buffer.indexOf("\n\n")) !== -1 - ) { - const message = buffer.substring( - 0, - messageEndPos, - ) - buffer = buffer.substring(messageEndPos + 2) // Move past the '\n\n' - - if (message.startsWith("data: ")) { - const jsonStr = message.substring(6).trim() - if (jsonStr === "[DONE]") { - controller.enqueue( - new TextEncoder().encode( - message + "\n\n", - ), - ) - continue - } - try { - const data = JSON.parse(jsonStr) - const delta = data.choices?.[0]?.delta - - if (delta) { - // Fix 1: remove invalid empty role - if (delta.role === "") { - delete delta.role - } - // Fix 2: remove non-standard reasoning_content field - if ("reasoning_content" in delta) { - delete delta.reasoning_content - } - } - - // Re-serialize and forward the corrected data with the correct SSE format - controller.enqueue( - new TextEncoder().encode( - `data: ${JSON.stringify(data)}\n\n`, - ), - ) - } catch (_e) { - // If parsing fails, forward the original message to avoid breaking the stream. - controller.enqueue( - new TextEncoder().encode( - message + "\n\n", - ), - ) - } - } else if (message.trim() !== "") { - // Pass through other message types (e.g., 'event: ...') - controller.enqueue( - new TextEncoder().encode( - message + "\n\n", - ), - ) - } - } - }, - flush(controller) { - // If there's anything left in the buffer, forward it. - if (buffer.trim()) { - controller.enqueue( - new TextEncoder().encode(buffer), - ) - } - }, - }) - - const transformedBody = - response.body.pipeThrough(transformStream) - - // Return a new response with the transformed body - return new Response(transformedBody, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) - }, - }) - model = sglangProvider.chat(modelId) - break - } - - case "gateway": { - // Vercel AI Gateway - unified access to multiple AI providers - // Model format: "provider/model" e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-5" - // See: https://vercel.com/ai-gateway - const apiKey = resolveApiKey(overrides, "AI_GATEWAY_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "AI_GATEWAY_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - ) - // Only use custom configuration if explicitly set (local dev or custom Gateway) - // Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC - if (baseURL || overrides?.apiKey) { - const customGateway = createGateway({ - apiKey, - ...(baseURL && { baseURL }), - }) - model = customGateway(modelId) - } else { - model = gateway(modelId) - } - break - } - - case "edgeone": { - // EdgeOne Pages Edge AI - uses OpenAI-compatible API - // AI SDK appends /chat/completions to baseURL - // /api/edgeai + /chat/completions = /api/edgeai/chat/completions - const baseURL = overrides?.baseUrl || "/api/edgeai" - const edgeoneProvider = createOpenAI({ - apiKey: "edgeone", // Dummy key - EdgeOne doesn't require API key - baseURL, - // Pass cookies for EdgeOne Pages authentication (eo_token, eo_time) - ...(overrides?.headers && { headers: overrides.headers }), - }) - model = edgeoneProvider.chat(modelId) - break - } - - case "doubao": { - const apiKey = resolveApiKey(overrides, "DOUBAO_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "DOUBAO_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - "https://ark.cn-beijing.volces.com/api/v3", - ) - const lowerModelId = modelId.toLowerCase() - // Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support) - if ( - lowerModelId.includes("deepseek") || - lowerModelId.includes("kimi") - ) { - const doubaoProvider = createDeepSeek({ - apiKey, - baseURL, - }) - model = doubaoProvider(modelId) - } else { - const doubaoProvider = createOpenAI({ - apiKey, - baseURL, - }) - model = doubaoProvider.chat(modelId) - } - break - } - - case "modelscope": { - const apiKey = resolveApiKey(overrides, "MODELSCOPE_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "MODELSCOPE_BASE_URL", - ) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - "https://api-inference.modelscope.cn/v1", - ) - const modelscopeProvider = createOpenAI({ - apiKey, - baseURL, - }) - model = modelscopeProvider.chat(modelId) - break - } - - case "minimax": { - const apiKey = resolveApiKey(overrides, "MINIMAX_API_KEY") - const serverBaseUrl = resolveBaseUrlEnv( - overrides, - "MINIMAX_BASE_URL", - ) - const rawBaseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - serverBaseUrl, - PROVIDER_INFO.minimax.defaultBaseUrl, - ) - - if (!rawBaseURL) { throw new Error( - "MiniMax base URL could not be resolved. Set MINIMAX_BASE_URL or configure a base URL in settings.", + `${PROVIDER_INFO[provider].label} needs a base URL. Add it in the model settings.`, ) } - - const { baseURL, isAnthropicCompatible } = - normalizeMiniMaxBaseURL(rawBaseURL) - - if (isAnthropicCompatible) { - const minimax = createAnthropic({ apiKey, baseURL }) - model = minimax.chat(modelId) - } else { - const minimax = createOpenAI({ apiKey, baseURL }) - model = minimax.chat(modelId) - } - break - } - - case "mimo": { - const apiKey = resolveApiKey(overrides, "MIMO_API_KEY") - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"), - PROVIDER_INFO.mimo?.defaultBaseUrl, - ) - // Use createDeepSeek to properly handle reasoning_content for MiMo - // thinking models (e.g., mimo-v2.5-pro). MiMo's API requires - // reasoning_content to be passed back during multi-turn tool calls - // (returns 400 otherwise), same convention as DeepSeek and Kimi. - const mimoProvider = createDeepSeek({ apiKey, baseURL }) - model = mimoProvider(modelId) - break - } - - case "glm": - case "qwen": - case "qiniu": - case "novita": - case "atlascloud": { - const envVar = PROVIDER_ENV_VARS[provider] - if (!envVar) { - throw new Error( - `API key environment variable not defined for provider: ${provider}`, - ) - } - const apiKey = resolveApiKey(overrides, envVar) - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - resolveBaseUrlEnv( - overrides, - `${provider.toUpperCase()}_BASE_URL`, - ), - PROVIDER_INFO[provider]?.defaultBaseUrl, - ) - const customProvider = createOpenAI({ + model = createModel(provider, modelId, { apiKey, baseURL, + configuredBaseURL: !!configuredBaseURL, + fetch: guardedFetch, + // Bearer auth for Anthropic when there is no API key + authToken: + provider === "anthropic" && !apiKey + ? process.env.ANTHROPIC_AUTH_TOKEN + : undefined, + // Only the server's own resource; a client key needs its URL + resourceName: + provider === "azure" && !overrides?.apiKey + ? process.env.AZURE_RESOURCE_NAME + : undefined, }) - model = customProvider.chat(modelId) - break } - - case "kimi": { - const apiKey = resolveApiKey(overrides, "KIMI_API_KEY") - const baseURL = resolveBaseURL( - overrides?.apiKey, - overrides?.baseUrl, - resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"), - PROVIDER_INFO.kimi?.defaultBaseUrl, - ) - // Use createDeepSeek to properly handle reasoning_content for Kimi - // thinking models (e.g., kimi-k2.6). Kimi's API uses the same - // reasoning_content field as DeepSeek, so this provider correctly - // captures and replays reasoning in multi-turn conversations. - const customProvider = createDeepSeek({ apiKey, baseURL }) - model = customProvider(modelId) - break - } - - default: - throw new Error( - `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`, - ) } - // Apply provider-specific options for all providers except bedrock (which has special handling) - if (customProviderOptions && provider !== "bedrock" && !providerOptions) { - providerOptions = customProviderOptions - } - - return { model, providerOptions, headers, modelId, provider } + return { model, providerOptions, modelId, provider } } /** - * Check if a model supports prompt caching. - * Currently only Claude models on Bedrock support prompt caching. + * The deployment's EdgeOne Pages function, as an absolute URL (the SDK + * needs one). EdgeOne serves functions by their folder from the site root, + * so Next's base path does not apply. + */ +export function edgeOneEndpoint(req: Request): string { + const origin = req.headers.get("origin") || new URL(req.url).origin + return `${origin}/api/edgeai` +} + +/** + * The server's

_BASE_URL for a provider, which getAIModel uses for a + * server model without a URL variable of its own (an admin panel entry + * without a URL). None for Bedrock and EdgeOne. Ollama and Vertex AI share + * one variable with the panel, which writes an entry's URL into it: an + * entry without a URL gets the environment's value once saved (before a + * save the variable may still hold the entry's previous URL), and Ollama + * without one goes to the SDK's local default. + */ +export function globalBaseUrl(provider: ProviderName): string | undefined { + if (provider === "ollama") { + return getEnvFallback("OLLAMA_BASE_URL") || "http://127.0.0.1:11434/api" + } + if (provider === "vertexai") { + return getEnvFallback("GOOGLE_VERTEX_BASE_URL") || undefined + } + if (provider === "bedrock" || provider === "edgeone") return undefined + // Azure set up by resource name only: the URL the SDK builds from it + if ( + provider === "azure" && + !process.env.AZURE_BASE_URL && + process.env.AZURE_RESOURCE_NAME + ) { + return `https://${process.env.AZURE_RESOURCE_NAME}.openai.azure.com/openai` + } + const name = + provider === "gateway" + ? "AI_GATEWAY_BASE_URL" + : `${provider.toUpperCase()}_BASE_URL` + return process.env[name] || undefined +} + +/** The provider of the server's own config: AI_PROVIDER, or the one with a key */ +export function getServerProvider(): ProviderName | null { + return (process.env.AI_PROVIDER as ProviderName) || detectProvider() +} + +/** + * Whether a call made with the caller's own settings runs on an endpoint of + * the deployment: its EdgeOne function, the server's keyless Ollama, or an + * address on the server's network (which ignores a dummy key header). + * Bedrock and EdgeOne never use a client base URL. Never in the desktop + * app, where every endpoint is the user's. clientBaseUrl: normalized. + */ +export async function usesServerEndpoint( + provider: ProviderName | null | undefined, + clientBaseUrl: string, + apiKey: string | null | undefined, +): Promise { + if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false + if (provider === "edgeone") return true + if (provider === "ollama" && !clientBaseUrl && !apiKey) return true + return ( + provider !== "bedrock" && + !!clientBaseUrl && + (await isPrivateUrl(clientBaseUrl)) + ) +} + +/** + * Whether the call is paid for by the server's own credentials (env keys or + * IAM role) rather than credentials sent with the request. Mirrors which key + * each branch of getAIModel ends up using. + */ +export function usesServerCredentials( + provider: ProviderName, + overrides?: ClientOverrides, +): boolean { + // The desktop app's local server holds the user's own preset keys + if (process.env.NEXT_AI_DRAWIO_DESKTOP === "1") return false + // Cleaned like getAIModel does: "/" means no base URL + const baseUrl = normalizeBaseUrl(overrides?.baseUrl ?? "") + switch (provider) { + case "bedrock": + return !(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey) + case "vertexai": + return !overrides?.vertexApiKey + case "edgeone": + // The platform's own endpoint, no key involved + return false + case "ollama": + // Only a server key costs money; a keyless local server or the + // client's own server does not + return ( + !baseUrl && + !overrides?.apiKey && + !!(overrides?.apiKeyEnv || process.env.OLLAMA_API_KEY) + ) + default: + return !overrides?.apiKey + } +} + +/** + * Prompt cache breakpoint for Claude, set on a message's providerOptions. + * Each provider reads only its own key; OpenRouter also reads the + * anthropic one. + */ +export const CACHE_POINT = { + bedrock: { cachePoint: { type: "default" } }, + anthropic: { cacheControl: { type: "ephemeral" } }, +} + +/** + * Check if a model supports prompt caching: Claude models, on Bedrock, + * the Anthropic API or OpenRouter (see CACHE_POINT). */ export function supportsPromptCaching(modelId: string): boolean { - // Bedrock prompt caching is supported for Claude models return ( modelId.includes("claude") || modelId.includes("anthropic") || @@ -1464,6 +1255,17 @@ export function getValidationModel(): ReturnType["model"] { ) } - const { model } = getAIModel({ modelId }) + // A default set in the admin panel becomes AI_PROVIDER/AI_MODEL, but its key + // lives in an ADMIN_-prefixed env var. Point at it the way the chat route + // does for server models, or the standard env var is required instead. + const panelDefault = adminProvidersToConfig( + loadAdminProviders(), + ).providers.find((p) => p.default && p.provider === process.env.AI_PROVIDER) + + const { model } = getAIModel({ + modelId, + apiKeyEnv: panelDefault?.apiKeyEnv, + baseUrlEnv: panelDefault?.baseUrlEnv, + }) return model } diff --git a/lib/aihubmix-models.ts b/lib/aihubmix-models.ts deleted file mode 100644 index f52bae5c..00000000 --- a/lib/aihubmix-models.ts +++ /dev/null @@ -1,79 +0,0 @@ -export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models" - -const NON_CHAT_MODEL_TYPES = new Set([ - "embedding", - "image_generation", - "rerank", - "transcription", - "tts", - "video", -]) - -type AihubmixModelListPayload = { - data?: unknown -} - -type AihubmixModelRecord = { - model_id?: unknown - types?: unknown -} - -function getModelTypes(types: unknown): Set { - if (typeof types !== "string") { - return new Set() - } - - return new Set( - types - .split(",") - .map((type) => type.trim()) - .filter(Boolean), - ) -} - -function isChatModel(record: AihubmixModelRecord): record is { - model_id: string - types: string -} { - if (typeof record.model_id !== "string" || !record.model_id.trim()) { - return false - } - - const types = getModelTypes(record.types) - if (!types.has("llm")) { - return false - } - - return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type)) -} - -export function extractAihubmixModelIds(payload: unknown): string[] { - const data = (payload as AihubmixModelListPayload)?.data - if (!Array.isArray(data)) { - return [] - } - - const seen = new Set() - const modelIds: string[] = [] - - for (const item of data) { - if (!item || typeof item !== "object") { - continue - } - - const record = item as AihubmixModelRecord - if (!isChatModel(record)) { - continue - } - - const modelId = record.model_id.trim() - if (seen.has(modelId)) { - continue - } - - seen.add(modelId) - modelIds.push(modelId) - } - - return modelIds -} diff --git a/lib/cached-responses.ts b/lib/cached-responses.ts index 8b8375f3..c2b474f7 100644 --- a/lib/cached-responses.ts +++ b/lib/cached-responses.ts @@ -1,6 +1,8 @@ export interface CachedResponse { promptText: string hasImage: boolean + // Name of the bundled example file the prompt is sent with + fileName?: string xml: string } @@ -254,6 +256,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ { promptText: "Replicate this in aws style", hasImage: true, + fileName: "architecture.png", xml: ` @@ -318,6 +321,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ { promptText: "Replicate this flowchart.", hasImage: true, + fileName: "example.png", xml: ` @@ -379,6 +383,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ { promptText: "Summarize this paper as a diagram", hasImage: true, + fileName: "chain-of-thought.txt", xml: ` @@ -879,14 +884,19 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [ }, ] +// Examples that come with a file only match when that exact example file is +// attached, so a user's own file with the same prompt still goes to the model. +// Callers that can't tell file names (the server) only get text-only examples. export function findCachedResponse( promptText: string, hasImage: boolean, + fileName?: string, ): CachedResponse | undefined { return CACHED_EXAMPLE_RESPONSES.find( (c) => c.promptText === promptText && c.hasImage === hasImage && + (!c.fileName || c.fileName === fileName) && c.xml !== "", ) } diff --git a/lib/chat-helpers.ts b/lib/chat-helpers.ts index e9c054f1..ddd8a730 100644 --- a/lib/chat-helpers.ts +++ b/lib/chat-helpers.ts @@ -6,25 +6,37 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB export const MAX_FILES = 5 // Helper function to validate file parts in messages +// Checks every message, since history is sent to the model too export function validateFileParts(messages: any[]): { valid: boolean error?: string } { - const lastMessage = messages[messages.length - 1] - const fileParts = - lastMessage?.parts?.filter((p: any) => p.type === "file") || [] + for (const message of messages) { + const fileParts = + message?.parts?.filter((p: any) => p.type === "file") || [] - if (fileParts.length > MAX_FILES) { - return { - valid: false, - error: `Too many files. Maximum ${MAX_FILES} allowed.`, + if (fileParts.length > MAX_FILES) { + return { + valid: false, + error: `Too many files. Maximum ${MAX_FILES} allowed.`, + } } - } - for (const filePart of fileParts) { - // Data URLs format: data:image/png;base64, - // Base64 increases size by ~33%, so we check the decoded size - if (filePart.url?.startsWith("data:")) { + for (const filePart of fileParts) { + // The client sends files inline. Any other URL would be downloaded + // by the server (AI SDK does that for models without URL support). + if ( + typeof filePart.url !== "string" || + !filePart.url.startsWith("data:") + ) { + return { + valid: false, + error: "Files must be uploaded inline as data URLs.", + } + } + + // Data URLs format: data:image/png;base64, + // Base64 increases size by ~33%, so we check the decoded size const base64Data = filePart.url.split(",")[1] if (base64Data) { const sizeInBytes = Math.ceil((base64Data.length * 3) / 4) @@ -41,49 +53,83 @@ export function validateFileParts(messages: any[]): { return { valid: true } } -// Helper function to check if diagram is minimal/empty -export function isMinimalDiagram(xml: string): boolean { - const stripped = xml.replace(/\s/g, "") - return !stripped.includes('id="2"') +// A tool-call input providers accept: a non-empty JSON object +function isValidToolInput(input: unknown): boolean { + return !!input && typeof input === "object" && Object.keys(input).length > 0 } // Helper function to replace historical tool call XML with placeholders // This reduces token usage and forces LLM to rely on the current diagram XML (source of truth) -// Also fixes invalid/undefined inputs from interrupted streaming +// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove export function replaceHistoricalToolInputs(messages: any[]): any[] { return messages.map((msg) => { if (msg.role !== "assistant" || !Array.isArray(msg.content)) { return msg } - const replacedContent = msg.content - .map((part: any) => { - if (part.type === "tool-call") { - const toolName = part.toolName - // Fix invalid/undefined inputs from interrupted streaming - if ( - !part.input || - typeof part.input !== "object" || - Object.keys(part.input).length === 0 - ) { - // Skip tool calls with invalid inputs entirely - return null - } - if ( - toolName === "display_diagram" || - toolName === "edit_diagram" - ) { - return { - ...part, - input: { - placeholder: - "[XML content replaced - see current diagram XML in system context]", - }, - } - } + const replacedContent = msg.content.map((part: any) => { + if ( + part.type === "tool-call" && + isValidToolInput(part.input) && + (part.toolName === "display_diagram" || + part.toolName === "edit_diagram") + ) { + return { + ...part, + input: { + placeholder: + "[XML content replaced - see current diagram XML in system context]", + }, } - return part - }) - .filter(Boolean) // Remove null entries (invalid tool calls) + } + return part + }) return { ...msg, content: replacedContent } }) } + +// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming), +// together with their tool-results: providers reject a result whose call is missing. +// Messages left empty are removed too (Bedrock rejects empty content arrays). +export function dropInvalidToolCalls(messages: any[]): any[] { + const droppedIds = new Set() + return messages + .map((msg) => { + if (!Array.isArray(msg.content)) return msg + const content = msg.content.filter((part: any) => { + if ( + msg.role === "assistant" && + part.type === "tool-call" && + !isValidToolInput(part.input) + ) { + console.warn( + `[chat-helpers] Dropping tool-call with invalid input:`, + { toolName: part.toolName, input: part.input }, + ) + droppedIds.add(part.toolCallId) + return false + } + // Results always come after their call, so the id is known by now + return !( + part.type === "tool-result" && + droppedIds.has(part.toolCallId) + ) + }) + return { ...msg, content } + }) + .filter((msg) => !Array.isArray(msg.content) || msg.content.length > 0) +} + +// Fix common LLM JSON mistakes in tool-call input before jsonrepair runs +export function fixToolInputJson(input: string): string { + return ( + input + // Inconsistent quote escaping in XML attributes inside JSON strings: + // y="-20\" (opening quote unescaped, closing escaped) becomes y=\"-20\". + // Must run before the key fix below, which would rewrite the `="`. + .replace(/(\w+)="([^"]*?)\\"/g, '$1=\\"$2\\"') + // `:=` instead of `: ` + .replace(/:=/g, ": ") + // `"key"= "` instead of `"key": "`, only for JSON keys + .replace(/"(\w+)"\s*=\s*"/g, '"$1": "') + ) +} diff --git a/lib/deprecated-params.ts b/lib/deprecated-params.ts new file mode 100644 index 00000000..2d9d2fda --- /dev/null +++ b/lib/deprecated-params.ts @@ -0,0 +1,94 @@ +import { wrapLanguageModel } from "ai" +import { rejectionText } from "@/lib/output-token-limit" + +type WrappedModel = ReturnType + +/** + * Claude 4.7 and later answer a non-default temperature, top_p or top_k, + * and the extended thinking budget (thinking type "enabled"), with a 400. + * TEMPERATURE and the *_THINKING_BUDGET_TOKENS settings send exactly these. + */ +const DEPRECATED_PARAM = + /`?(?:temperature|top_p|top_k)`? is deprecated for this model|"?thinking\.type\.enabled"? is not supported/i + +interface CallParams { + temperature?: number + topP?: number + topK?: number + providerOptions?: Record | undefined> +} + +// What these models take instead of a budget. Without display "summarized" +// they think but send no thinking text to show. +const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" } + +/** Turn a thinking config of type "enabled" stored under key into adaptive */ +function adaptiveThinking( + options: Record | undefined, + key: string, +): Record | undefined { + const config = options?.[key] as { type?: string } | undefined + if (config?.type !== "enabled") return options + return { ...options, [key]: ADAPTIVE_THINKING } +} + +/** + * The params without the settings newer Claude models reject, or null when + * the error is about something else or there is nothing to change. The + * model then runs with its default sampling, and a thinking budget becomes + * adaptive thinking. + */ +export function withoutDeprecatedParams( + error: unknown, + params: T, +): T | null { + const text = rejectionText(error) + if (!text || !DEPRECATED_PARAM.test(text)) return null + + const { temperature, topP, topK, ...rest } = params + const options = params.providerOptions + const anthropic = adaptiveThinking(options?.anthropic, "thinking") + const bedrock = adaptiveThinking(options?.bedrock, "reasoningConfig") + const changed = + temperature !== undefined || + topP !== undefined || + topK !== undefined || + anthropic !== options?.anthropic || + bedrock !== options?.bedrock + if (!changed) return null + + return { + ...rest, + ...(options && { + providerOptions: { + ...options, + ...(anthropic && { anthropic }), + ...(bedrock && { bedrock }), + }, + }), + } as T +} + +/** Retry the stream once without the settings newer Claude models reject. */ +export function withDeprecatedParamsFallback( + model: WrappedModel, +): WrappedModel { + return wrapLanguageModel({ + model, + middleware: { + specificationVersion: "v3", + async wrapStream({ doStream, params, model: inner }) { + try { + return await doStream() + } catch (error) { + const retry = withoutDeprecatedParams(error, params) + if (!retry) throw error + console.warn( + "[model params] Rejected sampling or thinking settings, retrying with default sampling and adaptive thinking", + ) + return await inner.doStream(retry) + } + }, + }, + }) +} diff --git a/lib/dynamo-quota-manager.ts b/lib/dynamo-quota-manager.ts index 981868b4..f423c8ed 100644 --- a/lib/dynamo-quota-manager.ts +++ b/lib/dynamo-quota-manager.ts @@ -64,10 +64,13 @@ interface QuotaCheckResult { * Check all quotas and increment request count atomically. * Uses composite key (PK=user, SK=date) for per-day tracking. * Each day automatically gets a new item - no explicit reset needed. + * A request limit of 0 means none; increment 0 checks the limits without + * counting a request (the screenshot check). */ export async function checkAndIncrementRequest( ip: string, limits: QuotaLimits, + increment = 1, ): Promise { // Skip if quota tracking not enabled if (!client || !TABLE) { @@ -99,7 +102,7 @@ export async function checkAndIncrementRequest( attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit) `, ExpressionAttributeValues: { - ":one": { N: "1" }, + ":one": { N: String(increment) }, ":minute": { S: currentMinute }, ":reqLimit": { N: String(limits.requests || 999999) }, ":tokenLimit": { N: String(limits.tokens || 999999) }, diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 61cf73fc..fa5ee712 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -178,9 +178,6 @@ "networkError": "Network error. Please check your connection.", "retryLimit": "Auto-retry limit reached ({max}). Please try again manually.", "continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.", - "validationFailed": "Diagram validation failed. Please try regenerating.", - "malformedXml": "AI generated invalid diagram XML. Please try regenerating.", - "failedToProcess": "Failed to process diagram. Please try regenerating.", "sessionCorrupted": "Session data was corrupted. Starting fresh.", "failedToSave": "Failed to save messages to localStorage", "failedToRestore": "Failed to restore from localStorage", @@ -188,7 +185,25 @@ "failedToExport": "Error fetching chart data", "failedToLoadExample": "Error loading example image", "failedToRecordFeedback": "Failed to record your feedback. Please try again.", - "storageUpdateFailed": "Chat cleared but browser storage could not be updated" + "storageUpdateFailed": "Chat cleared but browser storage could not be updated", + "sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.", + "sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.", + "continueWithoutSaving": "Continue without saving", + "llm": { + "invalid_api_key": "The provider rejected the API key. Check it in model settings.", + "forbidden": "The provider refused the request. The key may not have access to this model or region.", + "model_not_found": "The provider does not know this model. Check the model ID in model settings.", + "insufficient_quota": "The provider account has no credit or quota left.", + "rate_limited": "The provider is limiting requests. Wait a moment and try again.", + "context_too_long": "The conversation is too long for this model. Start a new chat or pick a model with a larger context.", + "images_unsupported": "This model doesn't support image input.", + "tools_unsupported": "This model doesn't support tool calls, which drawing needs. Pick another model.", + "output_truncated": "The output was cut off before the diagram was complete. Try a simpler request or raise the output limit in settings.", + "provider_unavailable": "The provider is having problems. Try again later.", + "cannot_connect": "Could not reach the provider. Check the base URL and your network.", + "timeout": "The provider did not answer in time.", + "openModelSettings": "Open model settings" + } }, "quota": { "dailyLimit": "Daily Quota Reached", @@ -235,6 +250,7 @@ "reasoning": { "thinking": "Thinking...", "thoughtFor": "Thought for {duration} seconds", + "thoughtForOne": "Thought for 1 second", "thoughtBrief": "Thought for a few seconds" }, "dev": { @@ -371,6 +387,11 @@ "enterSecretKey": "Enter your secret access key", "baseUrl": "Base URL", "optional": "(optional)", + "getApiKey": "Get API key", + "fetchModels": "Fetch models from the provider", + "noTools": "no tool calls", + "mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.", + "requestUrl": "Requests go to {url}", "baseUrlWithExample": "Base URL (optional, e.g. {example})", "customEndpoint": "Custom endpoint URL", "minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API", diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index c4393bb6..97c5c754 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -178,9 +178,6 @@ "networkError": "ネットワークエラー。接続を確認してください。", "retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。", "continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。", - "validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。", - "malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。", - "failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。", "sessionCorrupted": "セッションデータが破損しました。最初からやり直します。", "failedToSave": "localStorage へのメッセージの保存に失敗しました", "failedToRestore": "localStorage からの復元に失敗しました", @@ -188,7 +185,25 @@ "failedToExport": "チャートデータの取得エラー", "failedToLoadExample": "例の画像の読み込みエラー", "failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。", - "storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした" + "storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした", + "sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。", + "sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。", + "continueWithoutSaving": "保存せずに続ける", + "llm": { + "invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。", + "forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。", + "model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。", + "insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。", + "rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。", + "context_too_long": "会話がこのモデルで扱える長さを超えています。新しいチャットを始めるか、より長いコンテキストに対応したモデルを選んでください。", + "images_unsupported": "このモデルは画像入力に対応していません。", + "tools_unsupported": "このモデルはツール呼び出しに対応していません。作図にはツール呼び出しが必要です。別のモデルを選んでください。", + "output_truncated": "ダイアグラムが完成する前に出力が途中で切れました。リクエストを簡単にするか、設定で出力上限を上げてください。", + "provider_unavailable": "プロバイダーで問題が発生しています。しばらくしてから再試行してください。", + "cannot_connect": "プロバイダーに接続できません。Base URL とネットワークを確認してください。", + "timeout": "プロバイダーから時間内に応答がありませんでした。", + "openModelSettings": "モデル設定を開く" + } }, "quota": { "dailyLimit": "1日の割当量に達しました", @@ -235,6 +250,7 @@ "reasoning": { "thinking": "考え中...", "thoughtFor": "{duration} 秒考えました", + "thoughtForOne": "1 秒考えました", "thoughtBrief": "数秒考えました" }, "dev": { @@ -325,6 +341,11 @@ "enterSecretKey": "シークレットアクセスキーを入力", "baseUrl": "ベース URL", "optional": "(オプション)", + "getApiKey": "API キーを取得", + "fetchModels": "プロバイダーからモデル一覧を取得", + "noTools": "ツール呼び出し非対応", + "mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。", + "requestUrl": "リクエスト先: {url}", "baseUrlWithExample": "ベース URL(オプション、例: {example})", "customEndpoint": "カスタムエンドポイント URL", "minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用", diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index 5de362c2..d3521c80 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -178,9 +178,6 @@ "networkError": "網路錯誤。請檢查您的連線。", "retryLimit": "已達自動重試限制({max})。請手動重試。", "continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。", - "validationFailed": "圖表驗證失敗。請嘗試重新產生。", - "malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。", - "failedToProcess": "無法處理圖表。請嘗試重新產生。", "sessionCorrupted": "工作階段資料已損壞。重新開始。", "failedToSave": "無法儲存訊息到 localStorage", "failedToRestore": "無法從 localStorage 還原", @@ -188,7 +185,25 @@ "failedToExport": "取得圖表資料時出錯", "failedToLoadExample": "載入範例圖片時出錯", "failedToRecordFeedback": "記錄您的回饋失敗。請重試。", - "storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間" + "storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間", + "sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。", + "sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。", + "continueWithoutSaving": "不儲存,繼續", + "llm": { + "invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。", + "forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。", + "model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。", + "insufficient_quota": "服務商帳戶的餘額或額度已經用完。", + "rate_limited": "服務商正在限制請求頻率,請稍候再試。", + "context_too_long": "對話內容超過了這個模型能處理的長度。請開啟新的對話,或換一個上下文更長的模型。", + "images_unsupported": "這個模型不支援圖片輸入。", + "tools_unsupported": "這個模型不支援工具呼叫,而繪圖需要工具呼叫。請換一個模型。", + "output_truncated": "輸出在圖表完成之前就被截斷了。請簡化請求,或在設定中調高輸出上限。", + "provider_unavailable": "服務商發生問題,請稍後再試。", + "cannot_connect": "無法連線到服務商,請檢查 Base URL 和網路。", + "timeout": "服務商沒有及時回應。", + "openModelSettings": "開啟模型設定" + } }, "quota": { "dailyLimit": "已達每日配額", @@ -235,6 +250,7 @@ "reasoning": { "thinking": "思考中...", "thoughtFor": "思考了 {duration} 秒", + "thoughtForOne": "思考了 1 秒", "thoughtBrief": "思考了幾秒鐘" }, "dev": { @@ -371,6 +387,11 @@ "enterSecretKey": "輸入您的 Secret Key", "baseUrl": "基礎 URL", "optional": "(可選)", + "getApiKey": "取得 API Key", + "fetchModels": "從服務商取得模型清單", + "noTools": "不支援工具呼叫", + "mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。", + "requestUrl": "請求將傳送至 {url}", "baseUrlWithExample": "基礎 URL(可選,例如 {example})", "customEndpoint": "自訂端點 URL", "minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API", diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index f46601e9..1d8f9adb 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -178,9 +178,6 @@ "networkError": "网络错误。请检查您的连接。", "retryLimit": "已达到自动重试限制({max})。请手动重试。", "continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。", - "validationFailed": "图表验证失败。请尝试重新生成。", - "malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。", - "failedToProcess": "无法处理图表。请尝试重新生成。", "sessionCorrupted": "会话数据已损坏。重新开始。", "failedToSave": "无法保存消息到 localStorage", "failedToRestore": "无法从 localStorage 恢复", @@ -188,7 +185,25 @@ "failedToExport": "获取图表数据时出错", "failedToLoadExample": "加载示例图片时出错", "failedToRecordFeedback": "记录您的反馈失败。请重试。", - "storageUpdateFailed": "聊天已清除,但无法更新浏览器存储" + "storageUpdateFailed": "聊天已清除,但无法更新浏览器存储", + "sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。", + "sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。", + "continueWithoutSaving": "不保存,继续", + "llm": { + "invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。", + "forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。", + "model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。", + "insufficient_quota": "服务商账户的余额或额度已经用完。", + "rate_limited": "服务商正在限制请求频率,请稍等片刻再试。", + "context_too_long": "对话内容超过了这个模型能处理的长度。请新开一个对话,或换一个上下文更长的模型。", + "images_unsupported": "这个模型不支持图片输入。", + "tools_unsupported": "这个模型不支持工具调用,而画图需要工具调用。请换一个模型。", + "output_truncated": "输出在图画完之前就被截断了。请简化请求,或在设置里调高输出上限。", + "provider_unavailable": "服务商出了问题,请稍后再试。", + "cannot_connect": "连接不上服务商,请检查 Base URL 和网络。", + "timeout": "服务商没有及时响应。", + "openModelSettings": "打开模型设置" + } }, "quota": { "dailyLimit": "已达每日配额", @@ -235,6 +250,7 @@ "reasoning": { "thinking": "思考中...", "thoughtFor": "思考了 {duration} 秒", + "thoughtForOne": "思考了 1 秒", "thoughtBrief": "思考了几秒钟" }, "dev": { @@ -371,6 +387,11 @@ "enterSecretKey": "输入您的 Secret Key", "baseUrl": "基础 URL", "optional": "(可选)", + "getApiKey": "获取 API Key", + "fetchModels": "从服务商获取模型列表", + "noTools": "不支持工具调用", + "mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。", + "requestUrl": "请求将发往 {url}", "baseUrlWithExample": "基础 URL(可选,例如 {example})", "customEndpoint": "自定义端点 URL", "minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API", diff --git a/lib/langfuse.ts b/lib/langfuse.ts index 040a296f..23398c47 100644 --- a/lib/langfuse.ts +++ b/lib/langfuse.ts @@ -51,8 +51,15 @@ export function setTraceOutput(output: string) { if (!isLangfuseEnabled()) return updateActiveTrace({ output }) + endTrace() +} + +// End the observe() wrapper span (AI SDK creates its own child spans with usage). +// It uses endOnExit: false, so every request path has to end it, or the trace +// is never exported: stream finish, stream error/abort, and early returns. +export function endTrace() { + if (!isLangfuseEnabled()) return - // End the observe() wrapper span (AI SDK creates its own child spans with usage) const activeSpan = api.trace.getActiveSpan() if (activeSpan) { activeSpan.end() diff --git a/lib/llm-errors.ts b/lib/llm-errors.ts new file mode 100644 index 00000000..c587276d --- /dev/null +++ b/lib/llm-errors.ts @@ -0,0 +1,187 @@ +import { + APICallError, + InvalidToolInputError, + LoadAPIKeyError, + NoSuchToolError, + RetryError, + ToolCallRepairError, +} from "ai" + +/** + * What went wrong with a model call, for a hint the user can act on. The + * provider's own message always goes along, because a guess can be wrong. + */ +export type LLMErrorCode = + | "invalid_api_key" + | "forbidden" + | "model_not_found" + | "insufficient_quota" + | "rate_limited" + | "context_too_long" + | "images_unsupported" + | "tools_unsupported" + | "output_truncated" + | "provider_unavailable" + | "cannot_connect" + | "timeout" + | "unknown" + +export interface LLMError { + type: "provider" + code: LLMErrorCode + message: string +} + +// Texts that name the cause more precisely than the status code: a quota +// error can come as 403 or 429, a context or image error as a plain 400 +const SPECIFIC_TEXTS: Array<[RegExp, LLMErrorCode]> = [ + [ + // Not "too many tokens": that is Bedrock's throttling message + /context length|context window|maximum context|prompt is too long|input is too long|too many input tokens/i, + "context_too_long", + ], + [ + /image content block|image_url|does not support image|image input is not supported/i, + "images_unsupported", + ], + [ + /does not support tools|tool use is not supported|tools? (?:are|is) not supported|function calling is not supported/i, + "tools_unsupported", + ], + // Bedrock, when the output limit cut the tool call's JSON short + [/toolUse\.input is invalid/i, "output_truncated"], + // Bedrock, for a model id without the inference profile prefix + [/on-demand throughput isn.t supported/i, "model_not_found"], + [ + /insufficient[_ ]quota|insufficient balance|exceeded your current quota|credit balance is too low|余额不足/i, + "insufficient_quota", + ], +] + +const STATUS_CODES: Record = { + 401: "invalid_api_key", + 402: "insufficient_quota", + // Not "invalid key": a valid key can lack access to a model or region + 403: "forbidden", + 404: "model_not_found", + 408: "timeout", + // A retired model + 410: "model_not_found", + 413: "context_too_long", + 429: "rate_limited", +} + +const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [ + [ + /model[_ ]not[_ ]found|model .*does not exist|unknown model|no such model/i, + "model_not_found", + ], + [ + /invalid[_ ]api[_ ]key|incorrect api key|unauthorized/i, + "invalid_api_key", + ], + // "too many tokens": Bedrock's throttling + [/rate limit|too many requests|too many tokens/i, "rate_limited"], + [ + /Cannot connect to API|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|fetch failed/i, + "cannot_connect", + ], +] + +/** Secrets a provider may echo back: API keys, Bearer tokens, key=value */ +function redact(text: string): string { + return text + .replace(/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{8,}/g, "$1-[redacted]") + .replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]") + .replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]") + .replace( + /\b(api[_-]?key|access[_-]?key|secret|token|password|signature)(["']?\s*[:=]\s*["']?)[^\s"',&}]+/gi, + "$1$2[redacted]", + ) +} + +function problemDetail(body: string): string | undefined { + try { + const detail = JSON.parse(body)?.detail + return typeof detail === "string" ? detail : undefined + } catch { + return undefined + } +} + +/** + * The error text for the chat stream: what went wrong with the provider as + * JSON for the hint, or the text the model must read to fix a tool call. + * On the server's keys the provider's own text stays in the server log: + * it can name the server's account, role or internal hosts. + */ +export function streamErrorText(error: unknown, hideDetails = false): string { + // The SDK passes an invalid tool call's error as a plain string. Other + // strings come from providers (DeepSeek's SDK sends stream errors so). + if ( + typeof error === "string" && + /^(Invalid input for tool|Model tried to call unavailable tool)/.test( + error, + ) + ) { + return error + } + if (isToolCallError(error)) return (error as Error).message + const classified = classifyLLMError(error) + if (hideDetails) { + console.error("[chat] Provider error:", error) + classified.message = "The provider returned an error." + } + return JSON.stringify(classified) +} + +/** + * Model and tool errors the SDK sends back to the model as the tool result, + * so it can fix its call. Their text has to stay as it is. + */ +export function isToolCallError(error: unknown): boolean { + return ( + InvalidToolInputError.isInstance(error) || + NoSuchToolError.isInstance(error) || + ToolCallRepairError.isInstance(error) + ) +} + +export function classifyLLMError(error: unknown): LLMError { + // After the SDK's retries, the last attempt says what happened + const e = RetryError.isInstance(error) ? error.lastError : error + // Errors sent inside the stream can be plain objects like OpenRouter's + // { code: 503, message } + const plain = e as { + message?: unknown + code?: unknown + statusCode?: number + } + const raw = + e instanceof Error + ? e.message + : typeof plain?.message === "string" + ? plain.message + : String(e) + const body = APICallError.isInstance(e) ? (e.responseBody ?? "") : "" + // A problem+json body names the reason the SDK left out (NVIDIA: "Gone") + const detail = problemDetail(body) + const message = redact(detail ? `${raw}: ${detail}` : raw).slice(0, 500) + const text = `${raw} ${body}` + const status = APICallError.isInstance(e) + ? e.statusCode + : (plain?.statusCode ?? + (typeof plain?.code === "number" ? plain.code : undefined)) + + const find = (rules: Array<[RegExp, LLMErrorCode]>) => + rules.find(([pattern]) => pattern.test(text))?.[1] + const code = + (e instanceof Error && e.name === "TimeoutError" && "timeout") || + (LoadAPIKeyError.isInstance(e) && "invalid_api_key") || + find(SPECIFIC_TEXTS) || + (status && STATUS_CODES[status]) || + (status && status >= 500 && "provider_unavailable") || + find(GENERAL_TEXTS) || + "unknown" + return { type: "provider", code, message } +} diff --git a/lib/model-catalog.json b/lib/model-catalog.json new file mode 100644 index 00000000..b505c31e --- /dev/null +++ b/lib/model-catalog.json @@ -0,0 +1,1682 @@ +{ + "openai": { + "chatgpt-image-latest": {"tools":false,"images":true,"reasoning":false}, + "gpt-4.1": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "gpt-4.1-mini": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "gpt-4o": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "gpt-4o-2024-08-06": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "gpt-4o-2024-11-20": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "gpt-4o-mini": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "gpt-5": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":272000}, + "gpt-5.1": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.2": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.2-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.3-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.3-codex-spark": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":32000}, + "gpt-5.4": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.4-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.5": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-daybreak-blue-latest": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-daybreak-red-latest": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-image-1-mini": {"tools":false,"images":true,"reasoning":false}, + "gpt-image-1.5": {"tools":false,"images":true,"reasoning":false}, + "gpt-realtime-2.1": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":32000}, + "o3": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "o3-pro": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "text-embedding-3-large": {"tools":false,"images":false,"reasoning":false,"context":8191,"output":3072}, + "text-embedding-3-small": {"tools":false,"images":false,"reasoning":false,"context":8191,"output":1536}, + "text-embedding-ada-002": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536} + }, + "anthropic": { + "claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-fable-5-1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-haiku-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-haiku-4-5-20251001": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-5-20251101": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-4-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "claude-sonnet-4-5-20250929": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000} + }, + "google": { + "deep-research-max-preview-04-2026": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":65536}, + "deep-research-preview-04-2026": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":65536}, + "gemini-2.5-computer-use-preview-10-2025": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":64000}, + "gemini-2.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-2.5-flash-image": {"tools":false,"images":true,"reasoning":true,"context":32768,"output":32768}, + "gemini-2.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3-flash-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3-pro-image": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":32768}, + "gemini-3-pro-image-preview": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "gemini-3.1-flash-image": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "gemini-3.1-flash-image-preview": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":65536}, + "gemini-3.1-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.1-flash-lite-image": {"tools":true,"images":true,"reasoning":true,"context":65536,"output":4096}, + "gemini-3.1-flash-live-preview": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":65536}, + "gemini-3.1-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.1-pro-preview-customtools": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.5-live-translate-preview": {"tools":false,"images":false,"reasoning":false,"context":16384,"output":32768}, + "gemini-3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-embedding-001": {"tools":false,"images":false,"reasoning":false,"context":2048,"output":1}, + "gemini-embedding-2": {"tools":false,"images":true,"reasoning":false,"context":8192,"output":1}, + "gemini-flash-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-flash-lite-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemma-4-26b-a4b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "gemma-4-31b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "lyria-3-clip-preview": {"tools":false,"images":true,"reasoning":false,"context":1048576,"output":65536}, + "lyria-3-pro-preview": {"tools":false,"images":true,"reasoning":false,"context":1048576,"output":65536} + }, + "vertexai": { + "claude-fable-5-1@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-fable-5@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-haiku-4-5@20251001": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-5@20251101": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-6@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-7@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-8@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5-5@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-4-5@20250929": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-sonnet-4-6@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-5-5@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-5@default": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "gemini-2.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-2.5-flash-image": {"tools":false,"images":true,"reasoning":false,"context":32768,"output":32768}, + "gemini-2.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65535}, + "gemini-2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3-flash-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3-pro-image": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":32768}, + "gemini-3.1-flash-image": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "gemini-3.1-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.1-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.1-pro-preview-customtools": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-embedding-001": {"tools":false,"images":false,"reasoning":false,"context":2048,"output":1}, + "gemini-flash-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-flash-lite-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "meta/llama-4-maverick-17b-128e-instruct-maas": {"tools":true,"images":true,"reasoning":false,"context":524288,"output":8192}, + "openai/gpt-oss-120b-maas": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":131072}, + "xai/grok-4.20-non-reasoning": {"tools":true,"images":true,"reasoning":false,"context":2000000,"output":30000}, + "xai/grok-4.20-reasoning": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":30000}, + "xai/grok-4.3": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":30000}, + "xai/grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":524288,"output":500000}, + "zai-org/glm-5.2-maas": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":64000} + }, + "azure": { + "claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-fable-5-1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-haiku-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-mythos-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-1": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "claude-opus-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "codestral-2501": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":256000}, + "cohere-command-a": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "cohere-embed-v-4-0": {"tools":false,"images":true,"reasoning":false,"context":128000,"output":1536}, + "cohere-embed-v3-english": {"tools":false,"images":false,"reasoning":false,"context":512,"output":1024}, + "cohere-embed-v3-multilingual": {"tools":false,"images":false,"reasoning":false,"context":512,"output":1024}, + "deepseek-v3.2": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":128000}, + "deepseek-v3.2-speciale": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":128000}, + "deepseek-v4-flash": {"tools":false,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-pro": {"tools":false,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "gpt-5": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1-codex-max": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1-codex-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.2": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.2-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.3-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.4-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.5": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-chat-latest": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-image-1.5": {"tools":false,"images":true,"reasoning":false}, + "grok-4-1-fast-non-reasoning": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "grok-4-1-fast-reasoning": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":8192}, + "grok-4-20-non-reasoning": {"tools":true,"images":false,"reasoning":false,"context":262000,"output":8192}, + "grok-4-20-reasoning": {"tools":true,"images":false,"reasoning":true,"context":262000,"output":8192}, + "grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":128000}, + "kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "llama-3.3-70b-instruct": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":32768}, + "llama-4-maverick-17b-128e-instruct-fp8": {"tools":true,"images":true,"reasoning":false,"context":1000000,"output":16384}, + "llama-4-scout-17b-16e-instruct": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "ministral-3b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":8192}, + "mistral-medium-2505": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":128000}, + "mistral-small-2503": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":32768}, + "model-router": {"tools":true,"images":true,"reasoning":false,"context":200000,"output":16384}, + "o3": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "phi-4": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":4096}, + "phi-4-mini": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "phi-4-mini-reasoning": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":4096}, + "phi-4-multimodal": {"tools":false,"images":true,"reasoning":false,"context":128000,"output":4096}, + "phi-4-reasoning": {"tools":false,"images":false,"reasoning":true,"context":32000,"output":4096}, + "phi-4-reasoning-plus": {"tools":false,"images":false,"reasoning":true,"context":32000,"output":4096}, + "text-embedding-3-large": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":3072}, + "text-embedding-3-small": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "text-embedding-ada-002": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536} + }, + "bedrock": { + "amazon.nova-2-lite-v1:0": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "amazon.nova-lite-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "amazon.nova-micro-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":10000}, + "amazon.nova-pro-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "anthropic.claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-fable-5-1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic.claude-opus-4-5-20251101-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic.claude-opus-4-6-v1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-sonnet-4-5-20250929-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic.claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic.claude-sonnet-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "apac.amazon.nova-lite-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "apac.amazon.nova-micro-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":10000}, + "apac.amazon.nova-pro-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "au.anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "au.anthropic.claude-opus-4-6-v1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "au.anthropic.claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "au.anthropic.claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "au.anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "au.anthropic.claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "au.anthropic.claude-sonnet-4-5-20250929-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "au.anthropic.claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "au.anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "ca.amazon.nova-lite-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "deepseek.r1-v1:0": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32768}, + "deepseek.v3-v1:0": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":81920}, + "deepseek.v3.2": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":81920}, + "eu.amazon.nova-2-lite-v1:0": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "eu.amazon.nova-lite-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "eu.amazon.nova-micro-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":10000}, + "eu.amazon.nova-pro-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "eu.anthropic.claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "eu.anthropic.claude-opus-4-5-20251101-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "eu.anthropic.claude-opus-4-6-v1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "eu.anthropic.claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.anthropic.claude-sonnet-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "eu.mistral.pixtral-large-2502-v1:0": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "global.amazon.nova-2-lite-v1:0": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "global.anthropic.claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-fable-5-1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "global.anthropic.claude-opus-4-5-20251101-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "global.anthropic.claude-opus-4-6-v1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "global.anthropic.claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.anthropic.claude-sonnet-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "global.moonshotai.kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":128000}, + "global.openai.gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.openai.gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.openai.gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.openai.gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.openai.gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.openai.gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.openai.gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "global.xai.grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "global.xai.grok-4.7": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "google.gemma-3-12b-it": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":8192}, + "google.gemma-3-27b-it": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":8192}, + "google.gemma-3-4b-it": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":4096}, + "google.gemma-4-26b-a4b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "google.gemma-4-31b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "google.gemma-4-e2b": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":8192}, + "in.anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "in.anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "in.anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "in.openai.gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "in.openai.gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "jp.amazon.nova-2-lite-v1:0": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "jp.anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "jp.anthropic.claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "jp.anthropic.claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "jp.anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "jp.anthropic.claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "jp.anthropic.claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "jp.anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "meta.llama3-1-70b-instruct-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "meta.llama3-1-8b-instruct-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "meta.llama3-3-70b-instruct-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "meta.llama4-maverick-17b-instruct-v1:0": {"tools":true,"images":true,"reasoning":false,"context":1000000,"output":8192}, + "meta.llama4-scout-17b-instruct-v1:0": {"tools":true,"images":true,"reasoning":false,"context":10000000,"output":8192}, + "minimax.minimax-m2": {"tools":true,"images":false,"reasoning":true,"context":204608,"output":128000}, + "minimax.minimax-m2.1": {"tools":true,"images":false,"reasoning":true,"context":196608,"output":131072}, + "minimax.minimax-m2.5": {"tools":true,"images":false,"reasoning":true,"context":196608,"output":98304}, + "mistral.devstral-2-123b": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":8192}, + "mistral.magistral-small-2509": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":40000}, + "mistral.ministral-3-14b-instruct": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "mistral.ministral-3-3b-instruct": {"tools":true,"images":true,"reasoning":false,"context":256000,"output":8192}, + "mistral.ministral-3-8b-instruct": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "mistral.mistral-large-3-675b-instruct": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":8192}, + "mistral.pixtral-large-2502-v1:0": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "mistral.voxtral-mini-3b-2507": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":4096}, + "mistral.voxtral-small-24b-2507": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":8192}, + "moonshot.kimi-k2-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":16000}, + "moonshotai.kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":16384}, + "nvidia.nemotron-nano-12b-v2": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":8192}, + "nvidia.nemotron-nano-3-30b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":8192}, + "nvidia.nemotron-nano-9b-v2": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "nvidia.nemotron-super-3-120b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":131072}, + "openai.gpt-5.4": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "openai.gpt-5.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "openai.gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai.gpt-oss-120b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":131072}, + "openai.gpt-oss-120b-1:0": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":128000}, + "openai.gpt-oss-20b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":131072}, + "openai.gpt-oss-20b-1:0": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":128000}, + "openai.gpt-oss-safeguard-120b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16384}, + "openai.gpt-oss-safeguard-20b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16384}, + "qwen.qwen3-235b-a22b-2507-v1:0": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":131072}, + "qwen.qwen3-32b-v1:0": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "qwen.qwen3-coder-30b-a3b-v1:0": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":131072}, + "qwen.qwen3-coder-480b-a35b-v1:0": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":65536}, + "qwen.qwen3-coder-next": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen.qwen3-next-80b-a3b": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":262000}, + "qwen.qwen3-vl-235b-a22b": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":262000}, + "us-gov.openai.gpt-oss-120b-1:0": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16384}, + "us-gov.openai.gpt-oss-20b-1:0": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16384}, + "us.amazon.nova-2-lite-v1:0": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "us.amazon.nova-lite-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "us.amazon.nova-micro-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":10000}, + "us.amazon.nova-pro-v1:0": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "us.anthropic.claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-fable-5-1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-haiku-4-5-20251001-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "us.anthropic.claude-opus-4-5-20251101-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "us.anthropic.claude-opus-4-6-v1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-sonnet-4-5-20250929-v1:0": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "us.anthropic.claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.anthropic.claude-sonnet-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "us.deepseek.r1-v1:0": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32768}, + "us.meta.llama3-1-70b-instruct-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "us.meta.llama3-1-8b-instruct-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "us.meta.llama3-3-70b-instruct-v1:0": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "us.meta.llama4-maverick-17b-instruct-v1:0": {"tools":true,"images":true,"reasoning":false,"context":1000000,"output":8192}, + "us.meta.llama4-scout-17b-instruct-v1:0": {"tools":true,"images":true,"reasoning":false,"context":10000000,"output":8192}, + "us.mistral.pixtral-large-2502-v1:0": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "us.moonshotai.kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":128000}, + "us.openai.gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.openai.gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.openai.gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.openai.gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.openai.gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.openai.gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.openai.gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "us.writer.palmyra-x4-v1:0": {"tools":true,"images":false,"reasoning":true,"context":122880,"output":8192}, + "us.writer.palmyra-x5-v1:0": {"tools":true,"images":false,"reasoning":true,"context":1040000,"output":8192}, + "us.xai.grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "us.xai.grok-4.7": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "writer.palmyra-x4-v1:0": {"tools":true,"images":false,"reasoning":true,"context":122880,"output":8192}, + "writer.palmyra-x5-v1:0": {"tools":true,"images":false,"reasoning":true,"context":1040000,"output":8192}, + "xai.grok-4.3": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "xai.grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "zai.glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":131072}, + "zai.glm-4.7-flash": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":131072}, + "zai.glm-5": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":131072} + }, + "ollama": { + "deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":1048576}, + "deepseek-v4-flash:0731": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":1048576}, + "deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":1048576}, + "deepseek-v4-pro:0813": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":1048576}, + "deepseek-v4.1-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":384000}, + "gemma4:31b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":131072}, + "glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":976000,"output":131072}, + "glm-5.3": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "glm-5.3-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "gpt-oss:120b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "gpt-oss:20b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "minimax-m2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax-m2.7": {"tools":true,"images":false,"reasoning":true,"context":196608,"output":196608}, + "minimax-m3": {"tools":true,"images":true,"reasoning":true,"context":512000,"output":131072}, + "mistral-large-3:675b": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":262144}, + "nemotron-3-nano:30b": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "nemotron-3-super": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "nemotron-3-ultra": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":128000}, + "qwen3.5:397b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536} + }, + "openrouter": { + "~anthropic/claude-fable-latest": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "~anthropic/claude-haiku-latest": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "~anthropic/claude-opus-latest": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "~anthropic/claude-sonnet-latest": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "~deepseek/deepseek-flash-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "~deepseek/deepseek-pro-latest": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":393216}, + "~deepseek/deepseek-v4-flash-latest": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":943718}, + "~google/gemini-flash-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "~google/gemini-pro-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "~moonshotai/kimi-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "~openai/gpt-astra-latest": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "~openai/gpt-luna-latest": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "~openai/gpt-mini-latest": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "~openai/gpt-sol-latest": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "~openai/gpt-terra-latest": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "~x-ai/grok-latest": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":450000}, + "~z-ai/glm-flash-latest": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "~z-ai/glm-latest": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":943718}, + "aion-labs/aion-2.0": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "aion-labs/aion-3.0": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "aion-labs/aion-3.0-mini": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "aion-labs/aion-3.5": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "aion-labs/aion-3.5-mini": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "aion-labs/aion-rp-llama-3.1-8b": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":29491}, + "amazon/nova-2-lite-v1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "amazon/nova-lite-v1": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":5120}, + "amazon/nova-micro-v1": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":5120}, + "amazon/nova-premier-v1": {"tools":true,"images":true,"reasoning":false,"context":1000000,"output":32000}, + "amazon/nova-pro-v1": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":5120}, + "anthracite-org/magnum-v4-72b": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":4096}, + "anthropic/claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-fable-5.1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-haiku-4.5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic/claude-opus-4.1": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "anthropic/claude-opus-4.5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic/claude-opus-4.6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-4.7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-4.8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-5.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-sonnet-4": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic/claude-sonnet-4.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "anthropic/claude-sonnet-4.6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-sonnet-5.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "apodex/apodex-1.1-mini:free": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":235929}, + "arcee-ai/trinity-large-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":80000}, + "baidu/ernie-4.5-vl-424b-a47b": {"tools":false,"images":true,"reasoning":true,"context":123000,"output":16000}, + "bytedance-seed/seed-1.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "bytedance-seed/seed-1.6-flash": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "bytedance-seed/seed-2-1-turbo": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "bytedance-seed/seed-2.0-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "bytedance-seed/seed-2.0-lite": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "bytedance-seed/seed-2.0-mini": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "bytedance/ui-tars-1.5-7b": {"tools":false,"images":true,"reasoning":false,"context":128000,"output":2048}, + "cognitivecomputations/dolphin-mistral-24b-venice-edition": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":8192}, + "cohere/command-a": {"tools":false,"images":false,"reasoning":false,"context":256000,"output":8192}, + "cohere/command-a-plus": {"tools":true,"images":true,"reasoning":true,"context":192000,"output":64000}, + "cohere/command-r-08-2024": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4000}, + "cohere/command-r-plus-08-2024": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4000}, + "cohere/command-r7b-12-2024": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":4000}, + "cohere/north-mini-code:free": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":64000}, + "deepseek/deepseek-chat": {"tools":true,"images":false,"reasoning":false,"context":163840,"output":16000}, + "deepseek/deepseek-chat-v3-0324": {"tools":true,"images":false,"reasoning":false,"context":163840,"output":147456}, + "deepseek/deepseek-chat-v3.1": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":32768}, + "deepseek/deepseek-r1": {"tools":true,"images":false,"reasoning":true,"context":64000,"output":16000}, + "deepseek/deepseek-r1-0528": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":32768}, + "deepseek/deepseek-v3.1-terminus": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":147456}, + "deepseek/deepseek-v3.2": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":65536}, + "deepseek/deepseek-v3.2-exp": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":147456}, + "deepseek/deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":943718}, + "deepseek/deepseek-v4-flash-0731": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":943718}, + "deepseek/deepseek-v4-flash-vision-exp": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":262144}, + "deepseek/deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":384000}, + "deepseek/deepseek-v4-pro-0813": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":943718}, + "deepseek/deepseek-v4.1-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "dots-studio/dots-3-note-preview:free": {"tools":true,"images":true,"reasoning":true,"context":512000,"output":460800}, + "fireworks/ember-1": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "google/gemini-2.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65535}, + "google/gemini-2.5-flash-image": {"tools":false,"images":true,"reasoning":false,"context":32768,"output":8192}, + "google/gemini-2.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65535}, + "google/gemini-2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-2.5-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3-flash-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3-pro-image": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "google/gemini-3-pro-image-preview": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":32768}, + "google/gemini-3.1-flash-image": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "google/gemini-3.1-flash-image-preview": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":58982}, + "google/gemini-3.1-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.1-flash-lite-image": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":58982}, + "google/gemini-3.1-flash-lite-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.1-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.1-pro-preview-customtools": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemma-2-27b-it": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":2048}, + "google/gemma-3-12b-it": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":16384}, + "google/gemma-3-27b-it": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":117964}, + "google/gemma-3-4b-it": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":16384}, + "google/gemma-4-26b-a4b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "google/gemma-4-26b-a4b-it:free": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "google/gemma-4-31b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":16384}, + "google/gemma-4-31b-it:free": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "google/lyria-3-clip-preview": {"tools":false,"images":true,"reasoning":false,"context":1048576,"output":65536}, + "google/lyria-3-pro-preview": {"tools":false,"images":true,"reasoning":false,"context":1048576,"output":65536}, + "gryphe/mythomax-l2-13b": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":3686}, + "ibm-granite/granite-4.0-h-micro": {"tools":false,"images":false,"reasoning":false,"context":131000,"output":117900}, + "ibm-granite/granite-4.2-8b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":117964}, + "inception/mercury-2": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":50000}, + "inception/mercury-2.5": {"tools":true,"images":false,"reasoning":true,"context":260000,"output":65536}, + "inclusionai/ling-3.0-flash": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "inclusionai/ling-3.0-flash-fin": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "inclusionai/ling-3.0-flash-sante:free": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "inclusionai/ling-3.0-flash-vl": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "inclusionai/ling-3.1-flash": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "inference-net/schematron-v2-small": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":4096}, + "inference-net/schematron-v2-turbo": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":8192}, + "kwaipilot/kat-coder-pro-v2.5": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":235929}, + "liquid/lfm-2.5-2.6b:free": {"tools":true,"images":false,"reasoning":true,"context":65536,"output":8192}, + "mancer/weaver": {"tools":false,"images":false,"reasoning":false,"context":8000,"output":6000}, + "meituan/longcat-2.0": {"tools":true,"images":false,"reasoning":true,"context":1048756,"output":262144}, + "meta-llama/llama-3.1-70b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":16384}, + "meta-llama/llama-3.1-8b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":117964}, + "meta-llama/llama-3.2-1b-instruct": {"tools":false,"images":false,"reasoning":false,"context":60000,"output":54000}, + "meta-llama/llama-3.2-3b-instruct": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":117964}, + "meta-llama/llama-3.3-70b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":16384}, + "meta-llama/llama-4-maverick": {"tools":true,"images":true,"reasoning":false,"context":1048576,"output":16384}, + "meta-llama/llama-4-scout": {"tools":true,"images":true,"reasoning":false,"context":1310720,"output":16384}, + "meta-llama/llama-guard-4-12b": {"tools":false,"images":true,"reasoning":false,"context":163840,"output":16384}, + "meta/muse-glimmer-30b": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":117964}, + "meta/muse-spark-1.1": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "meta/muse-spark-1.2": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "meta/muse-spark-1.2-contributor": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "meta/muse-spark-1.3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "meta/muse-spark-1.3-contributor": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "microsoft/phi-4": {"tools":false,"images":false,"reasoning":false,"context":16384,"output":14745}, + "microsoft/wizardlm-2-8x22b": {"tools":false,"images":false,"reasoning":false,"context":65535,"output":8000}, + "minimax/minimax-01": {"tools":false,"images":true,"reasoning":false,"context":1000192,"output":40000}, + "minimax/minimax-m1": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":40000}, + "minimax/minimax-m2": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":176947}, + "minimax/minimax-m2-her": {"tools":false,"images":false,"reasoning":false,"context":65536,"output":2048}, + "minimax/minimax-m2.1": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128000}, + "minimax/minimax-m2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":176947}, + "minimax/minimax-m3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":512000}, + "mistralai/codestral-2508": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":204800}, + "mistralai/devstral-2512": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":209715}, + "mistralai/ministral-14b-2512": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":209715}, + "mistralai/ministral-3b-2512": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":104857}, + "mistralai/ministral-8b-2512": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":209715}, + "mistralai/mistral-large": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":102400}, + "mistralai/mistral-large-2407": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":104857}, + "mistralai/mistral-large-2512": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":209715}, + "mistralai/mistral-medium-3": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":104857}, + "mistralai/mistral-medium-3-5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":209715}, + "mistralai/mistral-medium-3.1": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":104857}, + "mistralai/mistral-nemo": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":16384}, + "mistralai/mistral-saba": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":26214}, + "mistralai/mistral-small-24b-instruct-2501": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":16384}, + "mistralai/mistral-small-2603": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":209715}, + "mistralai/mistral-small-3.1-24b-instruct": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":102400}, + "mistralai/mistral-small-3.2-24b-instruct": {"tools":true,"images":true,"reasoning":false,"context":256000,"output":16384}, + "mistralai/mixtral-8x22b-instruct": {"tools":true,"images":false,"reasoning":false,"context":65536,"output":52428}, + "mistralai/voxtral-small-24b-2507": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":26214}, + "moonshotai/kimi-k2": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":98304}, + "moonshotai/kimi-k2-0905": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":98304}, + "moonshotai/kimi-k2-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":235929}, + "moonshotai/kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "moonshotai/kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "moonshotai/kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "moonshotai/kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943718}, + "morph/morph-v3-fast": {"tools":false,"images":false,"reasoning":false,"context":81920,"output":38000}, + "morph/morph-v3-large": {"tools":false,"images":false,"reasoning":false,"context":262144,"output":131072}, + "nex-agi/nex-n2.5-mini": {"tools":false,"images":true,"reasoning":true,"context":262144,"output":235929}, + "nex-agi/nex-n2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "nousresearch/hermes-3-llama-3.1-405b": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":16384}, + "nousresearch/hermes-3-llama-3.1-70b": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":16384}, + "nousresearch/hermes-4-405b": {"tools":false,"images":false,"reasoning":true,"context":131072,"output":117964}, + "nvidia/nemotron-3-nano-30b-a3b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":235929}, + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":65536}, + "nvidia/nemotron-3-super-120b-a12b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":235929}, + "nvidia/nemotron-3-super-120b-a12b:free": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":235929}, + "nvidia/nemotron-3-ultra-550b-a55b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":16384}, + "nvidia/nemotron-3-ultra-550b-a55b:free": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":65536}, + "nvidia/nemotron-3.5-content-safety": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":117964}, + "nvidia/nemotron-3.5-content-safety:free": {"tools":false,"images":true,"reasoning":true,"context":128000,"output":8192}, + "nvidia/nemotron-3.5-lightning": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":131072}, + "nvidia/nemotron-3.5-lightning:free": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":65536}, + "openai/gpt-3.5-turbo": {"tools":true,"images":false,"reasoning":false,"context":16385,"output":4096}, + "openai/gpt-3.5-turbo-0613": {"tools":true,"images":false,"reasoning":false,"context":4095,"output":3685}, + "openai/gpt-3.5-turbo-16k": {"tools":true,"images":false,"reasoning":false,"context":16385,"output":4096}, + "openai/gpt-3.5-turbo-instruct": {"tools":false,"images":false,"reasoning":false,"context":4095,"output":3685}, + "openai/gpt-4": {"tools":true,"images":false,"reasoning":false,"context":8191,"output":4096}, + "openai/gpt-4-turbo": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "openai/gpt-4.1": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4.1-mini": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4.1-nano": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4o": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-2024-05-13": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "openai/gpt-4o-2024-08-06": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-2024-11-20": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-mini": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-mini-2024-07-18": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-5": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-image": {"tools":false,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-image-mini": {"tools":false,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-codex-max": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-codex-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2-chat": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":32000}, + "openai/gpt-5.2-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.3-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.4-image-2": {"tools":false,"images":true,"reasoning":true,"context":272000,"output":128000}, + "openai/gpt-5.4-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.5": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-luna-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-sol-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-terra-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-astra-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-luna-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-sol-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6.1-sol-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-audio": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-audio-mini": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-chat-latest": {"tools":true,"images":true,"reasoning":false,"context":400000,"output":128000}, + "openai/gpt-oss-120b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":117964}, + "openai/gpt-oss-20b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "openai/gpt-oss-safeguard-20b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":65536}, + "openai/o1": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o1-pro": {"tools":false,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o3": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o3-mini": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":100000}, + "openai/o3-mini-high": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":100000}, + "openai/o3-pro": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o4-mini": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o4-mini-high": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openrouter/auto": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "openrouter/bodybuilder": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":128000}, + "openrouter/free": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":8000}, + "openrouter/fusion": {"tools":false,"images":false,"reasoning":false,"context":1000000,"output":128000}, + "openrouter/pareto-code": {"tools":false,"images":false,"reasoning":false,"context":2000000,"output":200000}, + "perceptron/perceptron-mk1": {"tools":false,"images":true,"reasoning":true,"context":32768,"output":8192}, + "perceptron/perceptron-mk1.5": {"tools":true,"images":true,"reasoning":true,"context":36864,"output":8192}, + "perplexity/sonar": {"tools":false,"images":true,"reasoning":false,"context":127072,"output":114364}, + "perplexity/sonar-deep-research": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":115200}, + "perplexity/sonar-pro": {"tools":false,"images":true,"reasoning":false,"context":200000,"output":8000}, + "perplexity/sonar-pro-search": {"tools":false,"images":true,"reasoning":true,"context":200000,"output":8000}, + "perplexity/sonar-reasoning-pro": {"tools":false,"images":true,"reasoning":true,"context":128000,"output":115200}, + "poolside/laguna-s-2.1": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "poolside/laguna-s-2.1:free": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "poolside/laguna-xs-2.1": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "poolside/laguna-xs-2.1:free": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "prism-ml/ternary-bonsai-2-27b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "qwen/qwen-2.5-72b-instruct": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":16384}, + "qwen/qwen-2.5-7b-instruct": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":29491}, + "qwen/qwen-2.5-coder-32b-instruct": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":29491}, + "qwen/qwen-plus": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":32768}, + "qwen/qwen-plus-2025-07-28": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":32768}, + "qwen/qwen2.5-vl-72b-instruct": {"tools":false,"images":true,"reasoning":false,"context":128000,"output":115200}, + "qwen/qwen3-14b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "qwen/qwen3-235b-a22b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "qwen/qwen3-235b-a22b-2507": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":235929}, + "qwen/qwen3-235b-a22b-thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":117964}, + "qwen/qwen3-30b-a3b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "qwen/qwen3-30b-a3b-instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":32000}, + "qwen/qwen3-30b-a3b-thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":81920,"output":32768}, + "qwen/qwen3-32b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "qwen/qwen3-8b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "qwen/qwen3-coder": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen/qwen3-coder-30b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":235929}, + "qwen/qwen3-coder-flash": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":65536}, + "qwen/qwen3-coder-next": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":235929}, + "qwen/qwen3-coder-plus": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":65536}, + "qwen/qwen3-max": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen/qwen3-max-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3-next-80b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":235929}, + "qwen/qwen3-next-80b-a3b-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "qwen/qwen3-vl-235b-a22b-instruct": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":32768}, + "qwen/qwen3-vl-235b-a22b-thinking": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "qwen/qwen3-vl-30b-a3b-instruct": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":16384}, + "qwen/qwen3-vl-30b-a3b-thinking": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "qwen/qwen3-vl-32b-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":32768}, + "qwen/qwen3-vl-8b-instruct": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":32768}, + "qwen/qwen3-vl-8b-thinking": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "qwen/qwen3.5-122b-a10b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3.5-27b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3.5-35b-a3b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "qwen/qwen3.5-397b-a17b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "qwen/qwen3.5-9b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "qwen/qwen3.5-flash-02-23": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen/qwen3.5-plus-02-15": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen/qwen3.5-plus-20260420": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen/qwen3.6-27b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":81920}, + "qwen/qwen3.6-35b-a3b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "qwen/qwen3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen/qwen3.6-max-preview": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3.6-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen/qwen3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen/qwen3.7-max": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "qwen/qwen3.7-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen/qwen3.8-2.4t-a95b": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "qwen/qwen3.8-27b": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen/qwen3.8-27b:free": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":235929}, + "qwen/qwen3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen/qwen3.8-max-0902": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen/qwen3.8-max-prime": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen/qwen3.8-omni-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "rekaai/reka-edge": {"tools":true,"images":true,"reasoning":false,"context":16384,"output":14745}, + "rekaai/reka-flash-3": {"tools":false,"images":false,"reasoning":true,"context":65536,"output":58982}, + "relace/relace-apply-3": {"tools":false,"images":false,"reasoning":false,"context":256000,"output":128000}, + "relace/relace-search": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":128000}, + "sakana/fugu-max": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "sakana/fugu-ultra": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "sakana/fugu-ultra-v2": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "sakana/sakana-namazu": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "sao10k/l3-lunaris-8b": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":7372}, + "sao10k/l3.1-euryale-70b": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":16384}, + "sao10k/l3.3-euryale-70b": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":16384}, + "stealth/space-bunny-alpha": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":524288}, + "stepfun/step-3.5-flash": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "stepfun/step-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":230400}, + "tencent/hunyuan-a13b-instruct": {"tools":false,"images":false,"reasoning":true,"context":131072,"output":117964}, + "tencent/hy-mt2-1.8b": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":4096}, + "tencent/hy-mt2-30b-a3b": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":4096}, + "tencent/hy-mt2-7b": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":4096}, + "tencent/hy3": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":128000}, + "tencent/hy3-preview": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":235929}, + "tencent/hy4-preview": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":64000}, + "thedrummer/cydonia-24b-v4.1": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":117964}, + "thedrummer/skyfall-36b-v2": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":29491}, + "thedrummer/unslopnemo-12b": {"tools":false,"images":false,"reasoning":false,"context":1024000,"output":819200}, + "thinkingmachines/inkling": {"tools":true,"images":true,"reasoning":true,"context":524288,"output":262144}, + "thinkingmachines/inkling-small": {"tools":true,"images":true,"reasoning":true,"context":524288,"output":262144}, + "thinkingmachines/inkling-small:free": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":262144}, + "thinkingmachines/inkling:free": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":262144}, + "unbiased/pareto": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":131072}, + "unbiased/pareto-26.10-preview": {"tools":true,"images":true,"reasoning":false,"context":1048576,"output":131072}, + "undi95/remm-slerp-l2-13b": {"tools":false,"images":false,"reasoning":false,"context":6144,"output":5529}, + "upstage/solar-mini4": {"tools":true,"images":false,"reasoning":true,"context":524288,"output":131072}, + "upstage/solar-pro-3": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":117964}, + "upstage/solar-pro4": {"tools":true,"images":false,"reasoning":true,"context":524288,"output":131072}, + "writer/palmyra-x5": {"tools":false,"images":false,"reasoning":false,"context":1040000,"output":8192}, + "x-ai/grok-4.20": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":1800000}, + "x-ai/grok-4.20-multi-agent": {"tools":false,"images":true,"reasoning":true,"context":2000000,"output":1800000}, + "x-ai/grok-4.3": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":900000}, + "x-ai/grok-4.5": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":450000}, + "x-ai/grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":450000}, + "x-ai/grok-4.7": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":450000}, + "x-ai/grok-build-0.1": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":230400}, + "xiaomi/mimo-v2.5": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":131072}, + "xiaomi/mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1050000,"output":131072}, + "xiaomi/mimo-v2.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":131072}, + "xiaomi/mimo-v2.6-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":131072}, + "xiaomi/mimo-v2.6-pro-ultraspeed": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "z-ai/glm-4.5": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "z-ai/glm-4.5-air": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "z-ai/glm-4.5v": {"tools":true,"images":true,"reasoning":true,"context":65536,"output":16384}, + "z-ai/glm-4.6": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":16384}, + "z-ai/glm-4.6v": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "z-ai/glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "z-ai/glm-4.7-flash": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":117964}, + "z-ai/glm-5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128000}, + "z-ai/glm-5-turbo": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":131072}, + "z-ai/glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "z-ai/glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":943718}, + "z-ai/glm-5.3": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "z-ai/glm-5.3-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":943717}, + "z-ai/glm-5.3-flashx": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "z-ai/glm-5.3-prime": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "z-ai/glm-5v-turbo": {"tools":true,"images":true,"reasoning":true,"context":202752,"output":131072} + }, + "aihubmix": { + "alicloud-deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "alicloud-deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "alicloud-glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000}, + "claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-fable-5-1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-haiku-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-opus-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-6-think": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-7-think": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-4-8": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "claude-opus-4-8-think": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-opus-5-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "claude-sonnet-4-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "claude-sonnet-4-6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "claude-sonnet-4-6-think": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "coding-glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000}, + "coding-glm-5.1-free": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000}, + "coding-minimax-m2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128100}, + "coding-minimax-m2.7-free": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128100}, + "coding-minimax-m2.7-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128100}, + "coding-xiaomi-mimo-v2.5": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "coding-xiaomi-mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "deep-deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deep-deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-flash-0731": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-flash-0731-fast": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-flash-vision-exp": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-pro-0813": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4.1-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":384000}, + "doubao-seed-2-0-code-preview": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "doubao-seed-2-0-lite-260428": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "doubao-seed-2-0-mini-260428": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "doubao-seed-2-0-pro": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "gemini-2.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-2.5-flash-image": {"tools":false,"images":true,"reasoning":true,"context":32768,"output":32768}, + "gemini-2.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3-flash-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3-pro-image": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":32768}, + "gemini-3.1-flash-image": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "gemini-3.1-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.1-flash-lite-image": {"tools":true,"images":true,"reasoning":true,"context":65536,"output":4096}, + "gemini-3.1-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.1-pro-preview-customtools": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "gemini-3.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "glm-4.5v": {"tools":true,"images":true,"reasoning":true,"context":64000,"output":16384}, + "glm-4.6": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "glm-4.6v": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":32768}, + "glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":128000}, + "glm-5.3": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":128000}, + "glm-5.3-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "glm-5v-turbo": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":128000}, + "gpt-5": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.1-codex-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.2": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.2-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.3-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.4-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.4-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "gpt-5.5": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "gpt-image-1.5": {"tools":false,"images":true,"reasoning":false}, + "grok-4.3": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "grok-4.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "grok-4.7": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "grok-build-0.1": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "hy3": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":128000}, + "hy3-preview": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":128000}, + "hy4-preview": {"tools":true,"images":false,"reasoning":true,"context":1024000,"output":64000}, + "kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "kimi-k2.7-code-highspeed": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "longcat-2.0": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "mimo-v2.5": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.6-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.6-pro-ultraspeed": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "minimax-m2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128000}, + "minimax-m3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":512000}, + "muse-spark-1.1": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "muse-spark-1.2": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "muse-spark-1.3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "o3": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "o4-mini": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "ox-alpha": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen3-max": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen3-vl-plus": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "qwen3.5-122b-a10b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen3.5-27b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen3.5-35b-a3b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen3.5-397b-a17b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.5-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.6-35b-a3b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":991000,"output":64000}, + "qwen3.6-max-preview": {"tools":true,"images":false,"reasoning":true,"context":240000,"output":64000}, + "qwen3.6-plus": {"tools":true,"images":true,"reasoning":true,"context":991000,"output":64000}, + "qwen3.7-flash": {"tools":true,"images":false,"reasoning":true,"context":991000,"output":64000}, + "qwen3.7-max": {"tools":true,"images":false,"reasoning":true,"context":991000,"output":64000}, + "qwen3.7-plus": {"tools":true,"images":false,"reasoning":true,"context":991000,"output":64000}, + "qwen3.8-2.4t-a95b": {"tools":true,"images":true,"reasoning":true,"context":262000,"output":262000}, + "qwen3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen3.8-max": {"tools":true,"images":false,"reasoning":true,"context":991000,"output":128000}, + "qwen3.8-max-preview": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen3.8-omni-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "step-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "step-5-preview": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "xiaomi-mimo-v2.5": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "xiaomi-mimo-v2.5-free": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "xiaomi-mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "xiaomi-mimo-v2.5-pro-free": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "zai-glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000} + }, + "deepseek": { + "deepseek-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":393216}, + "deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":393216} + }, + "siliconflow": { + "ByteDance-Seed/Seed-OSS-36B-Instruct": {"tools":true,"images":false,"reasoning":false,"context":262000,"output":262000}, + "deepseek-ai/DeepSeek-OCR": {"tools":false,"images":true,"reasoning":false,"context":8192,"output":8192}, + "deepseek-ai/DeepSeek-R1": {"tools":true,"images":false,"reasoning":true,"context":164000,"output":164000}, + "deepseek-ai/DeepSeek-V3": {"tools":true,"images":false,"reasoning":false,"context":164000,"output":164000}, + "deepseek-ai/DeepSeek-V3.1-Terminus": {"tools":true,"images":false,"reasoning":true,"context":164000,"output":164000}, + "deepseek-ai/DeepSeek-V3.2": {"tools":true,"images":false,"reasoning":true,"context":164000,"output":164000}, + "deepseek-ai/DeepSeek-V4-Flash": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-ai/DeepSeek-V4-Pro": {"tools":true,"images":false,"reasoning":true,"context":1049000,"output":393000}, + "inclusionAI/Ling-flash-2.0": {"tools":true,"images":false,"reasoning":false,"context":131000,"output":131000}, + "PaddlePaddle/PaddleOCR-VL-1.5": {"tools":false,"images":true,"reasoning":false,"context":16384,"output":16384}, + "Pro/deepseek-ai/DeepSeek-R1": {"tools":true,"images":false,"reasoning":true,"context":164000,"output":164000}, + "Pro/deepseek-ai/DeepSeek-V3": {"tools":true,"images":false,"reasoning":false,"context":164000,"output":164000}, + "Pro/deepseek-ai/DeepSeek-V3.1-Terminus": {"tools":true,"images":false,"reasoning":true,"context":164000,"output":164000}, + "Pro/deepseek-ai/DeepSeek-V3.2": {"tools":true,"images":false,"reasoning":true,"context":164000,"output":164000}, + "Pro/MiniMaxAI/MiniMax-M2.5": {"tools":true,"images":false,"reasoning":false,"context":192000,"output":131000}, + "Pro/moonshotai/Kimi-K2.5": {"tools":true,"images":true,"reasoning":true,"context":262000,"output":262000}, + "Pro/moonshotai/Kimi-K2.6": {"tools":true,"images":true,"reasoning":true,"context":262000,"output":262000}, + "Pro/zai-org/GLM-5": {"tools":true,"images":false,"reasoning":true,"context":205000,"output":205000}, + "Pro/zai-org/GLM-5.1": {"tools":true,"images":false,"reasoning":true,"context":205000,"output":205000}, + "Qwen/Qwen2.5-72B-Instruct": {"tools":true,"images":false,"reasoning":false,"context":33000,"output":4000}, + "Qwen/Qwen2.5-7B-Instruct": {"tools":true,"images":false,"reasoning":false,"context":33000,"output":4000}, + "Qwen/Qwen3-14B": {"tools":true,"images":false,"reasoning":true,"context":131000,"output":131000}, + "Qwen/Qwen3-235B-A22B-Thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":262000,"output":262000}, + "Qwen/Qwen3-30B-A3B-Instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":262000,"output":262000}, + "Qwen/Qwen3-32B": {"tools":true,"images":false,"reasoning":true,"context":131000,"output":131000}, + "Qwen/Qwen3-8B": {"tools":true,"images":false,"reasoning":true,"context":131000,"output":131000}, + "Qwen/Qwen3-Coder-30B-A3B-Instruct": {"tools":true,"images":false,"reasoning":false,"context":262000,"output":262000}, + "Qwen/Qwen3-Coder-480B-A35B-Instruct": {"tools":true,"images":false,"reasoning":false,"context":262000,"output":262000}, + "Qwen/Qwen3-VL-30B-A3B-Instruct": {"tools":true,"images":true,"reasoning":false,"context":262000,"output":262000}, + "Qwen/Qwen3-VL-30B-A3B-Thinking": {"tools":true,"images":true,"reasoning":true,"context":262000,"output":262000}, + "Qwen/Qwen3-VL-32B-Instruct": {"tools":true,"images":true,"reasoning":false,"context":262000,"output":262000}, + "Qwen/Qwen3-VL-32B-Thinking": {"tools":true,"images":true,"reasoning":true,"context":262000,"output":262000}, + "Qwen/Qwen3-VL-8B-Instruct": {"tools":true,"images":true,"reasoning":false,"context":262000,"output":262000}, + "Qwen/Qwen3.5-122B-A10B": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "Qwen/Qwen3.5-27B": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "Qwen/Qwen3.5-35B-A3B": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "Qwen/Qwen3.5-397B-A17B": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "Qwen/Qwen3.5-4B": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "Qwen/Qwen3.5-9B": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "Qwen/Qwen3.6-35B-A3B": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":65536}, + "stepfun-ai/Step-3.5-Flash": {"tools":true,"images":false,"reasoning":true,"context":262000,"output":262000}, + "tencent/Hunyuan-A13B-Instruct": {"tools":true,"images":false,"reasoning":true,"context":131000,"output":131000}, + "zai-org/GLM-4.5-Air": {"tools":true,"images":false,"reasoning":false,"context":131000,"output":131000}, + "zai-org/GLM-5.2": {"tools":true,"images":false,"reasoning":true,"context":1049000,"output":262000} + }, + "gateway": { + "alibaba/qwen-3-14b": {"tools":true,"images":false,"reasoning":true,"context":40960,"output":16384}, + "alibaba/qwen-3-235b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":16384}, + "alibaba/qwen-3-30b": {"tools":true,"images":false,"reasoning":true,"context":40960,"output":16384}, + "alibaba/qwen-3-32b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":8192}, + "alibaba/qwen-3.6-max-preview": {"tools":true,"images":false,"reasoning":true,"context":240000,"output":64000}, + "alibaba/qwen3-235b-a22b-thinking": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "alibaba/qwen3-coder": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "alibaba/qwen3-coder-30b-a3b": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":8192}, + "alibaba/qwen3-coder-next": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":256000}, + "alibaba/qwen3-coder-plus": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":65536}, + "alibaba/qwen3-embedding-0.6b": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":32768}, + "alibaba/qwen3-embedding-4b": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":32768}, + "alibaba/qwen3-embedding-8b": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":32768}, + "alibaba/qwen3-max": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":32768}, + "alibaba/qwen3-max-preview": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":32768}, + "alibaba/qwen3-max-thinking": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":65536}, + "alibaba/qwen3-next-80b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262114,"output":262114}, + "alibaba/qwen3-next-80b-a3b-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":262144}, + "alibaba/qwen3-vl-235b-a22b-instruct": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":129024}, + "alibaba/qwen3-vl-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":129024}, + "alibaba/qwen3-vl-thinking": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "alibaba/qwen3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "alibaba/qwen3.5-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "alibaba/qwen3.6-27b": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":65536}, + "alibaba/qwen3.6-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "alibaba/qwen3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":991000,"output":64000}, + "alibaba/qwen3.7-max": {"tools":true,"images":false,"reasoning":true,"context":991000,"output":64000}, + "alibaba/qwen3.7-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "alibaba/qwen3.8-2.4t-a95b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":128000}, + "alibaba/qwen3.8-27b": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "alibaba/qwen3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":991000,"output":128000}, + "alibaba/qwen3.8-max": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":128000}, + "alibaba/qwen3.8-max-0902": {"tools":true,"images":true,"reasoning":true,"context":991000,"output":128000}, + "alibaba/qwen3.8-max-prime": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "alibaba/qwen3.8-omni-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "amazon/nova-2-lite": {"tools":false,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "amazon/nova-lite": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "amazon/nova-micro": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":10000}, + "amazon/nova-pro": {"tools":true,"images":true,"reasoning":false,"context":300000,"output":10000}, + "amazon/titan-embed-text-v2": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "anthropic/claude-3-haiku": {"tools":true,"images":true,"reasoning":false,"context":200000,"output":4096}, + "anthropic/claude-fable-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-fable-5.1": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-haiku-4.5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic/claude-opus-4": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "anthropic/claude-opus-4.5": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "anthropic/claude-opus-4.6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-4.7": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-4.8": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-4.8-fast": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-5-fast": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-5.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-opus-5.5-fast": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-sonnet-4": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "anthropic/claude-sonnet-4.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "anthropic/claude-sonnet-4.6": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-sonnet-5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "anthropic/claude-sonnet-5.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "arcee-ai/trinity-large-thinking": {"tools":true,"images":false,"reasoning":true,"context":262100,"output":80000}, + "bytedance/seed-1.6": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "bytedance/seed-1.8": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32768}, + "bytedance/seed-2.1-turbo": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "cohere/command-a": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":8000}, + "cohere/embed-v4.0": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":1536}, + "cohere/rerank-v3.5": {"tools":false,"images":false,"reasoning":false,"context":4096,"output":4096}, + "cohere/rerank-v4-fast": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "cohere/rerank-v4-pro": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "deepseek/deepseek-r1": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32768}, + "deepseek/deepseek-v3.1": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":128000}, + "deepseek/deepseek-v3.1-terminus": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "deepseek/deepseek-v3.2": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":8000}, + "deepseek/deepseek-v3.2-thinking": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":8000}, + "deepseek/deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek/deepseek-v4-flash-0731": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek/deepseek-v4-flash-vision-exp": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "deepseek/deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek/deepseek-v4-pro-0813": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek/deepseek-v4.1-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":32768}, + "deepseek/deepseek-v4.1-flash-fast": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1000000}, + "fireworks/ember-1": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "fish-audio/transcribe-1": {"tools":false,"images":false,"reasoning":false}, + "google/gemini-2.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-2.5-flash-image": {"tools":false,"images":true,"reasoning":false,"context":32768,"output":65535}, + "google/gemini-2.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65535}, + "google/gemini-2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "google/gemini-3-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65000}, + "google/gemini-3-pro-image": {"tools":false,"images":true,"reasoning":false,"context":65536,"output":32768}, + "google/gemini-3.1-flash-image": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "google/gemini-3.1-flash-image-preview": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "google/gemini-3.1-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65000}, + "google/gemini-3.1-flash-lite-image": {"tools":false,"images":true,"reasoning":true,"context":65536,"output":4096}, + "google/gemini-3.1-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "google/gemini-3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "google/gemini-3.5-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65000}, + "google/gemini-3.5-transcribe": {"tools":false,"images":false,"reasoning":false}, + "google/gemini-3.5-transcribe-live": {"tools":false,"images":false,"reasoning":false}, + "google/gemini-3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "google/gemini-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "google/gemini-3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65535}, + "google/gemini-3.8-live": {"tools":false,"images":false,"reasoning":false}, + "google/gemini-3.8-live-extended-thinking": {"tools":false,"images":false,"reasoning":false}, + "google/gemini-embedding-001": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "google/gemini-embedding-2": {"tools":false,"images":false,"reasoning":false}, + "google/gemini-omni-flash-preview": {"tools":false,"images":true,"reasoning":true,"context":1000000,"output":57920}, + "google/gemma-4-26b-a4b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "google/gemma-4-31b-it": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":131072}, + "google/text-embedding-005": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "google/text-multilingual-embedding-002": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "inception/mercury-2": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":50000}, + "inception/mercury-2.5": {"tools":true,"images":false,"reasoning":true,"context":260000,"output":65536}, + "inception/mercury-coder-small": {"tools":true,"images":false,"reasoning":false,"context":32000,"output":16384}, + "inclusionai/ling-3.0-flash": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":32000}, + "inclusionai/ling-3.0-flash-fin": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":32000}, + "inclusionai/ling-3.0-flash-sante": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":32000}, + "inclusionai/ling-3.0-flash-sante-free": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":32000}, + "inclusionai/ling-3.0-flash-vl": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "inclusionai/ling-3.1-flash": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "inclusionai/ling-3.1-flash-free": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "inference-net/schematron-v2-small": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":4096}, + "inference-net/schematron-v2-turbo": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":8192}, + "interfaze/interfaze-beta": {"tools":false,"images":true,"reasoning":true,"context":1000000,"output":32000}, + "meituan/longcat-2.5-preview": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "meta/llama-3.1-70b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":8192}, + "meta/llama-3.1-8b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":8192}, + "meta/llama-3.3-70b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "meta/llama-4-maverick": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "meta/llama-4-scout": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "meta/muse-glimmer-30b": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":131072}, + "meta/muse-spark-1.1": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "meta/muse-spark-1.2": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "meta/muse-spark-1.2-contributor": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "meta/muse-spark-1.3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "meta/muse-spark-1.3-contributor": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "microsoft/mai-transcribe-1.5": {"tools":false,"images":false,"reasoning":false}, + "microsoft/mai-transcribe-2": {"tools":false,"images":false,"reasoning":false}, + "microsoft/mai-transcribe-2-streaming": {"tools":false,"images":false,"reasoning":false}, + "minimax/minimax-m2": {"tools":true,"images":false,"reasoning":true,"context":205000,"output":196608}, + "minimax/minimax-m2.1": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.1-lightning": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131000}, + "minimax/minimax-m2.5-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131000}, + "minimax/minimax-m2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131000}, + "minimax/minimax-m2.7-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131100}, + "minimax/minimax-m3": {"tools":true,"images":true,"reasoning":true,"context":512000,"output":512000}, + "mistral/codestral": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":4096}, + "mistral/codestral-embed": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "mistral/ministral-14b": {"tools":false,"images":true,"reasoning":false,"context":262144,"output":256000}, + "mistral/ministral-3b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":128000}, + "mistral/ministral-8b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":128000}, + "mistral/mistral-embed": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "mistral/mistral-large-3": {"tools":false,"images":true,"reasoning":false,"context":262144,"output":256000}, + "mistral/mistral-medium-3.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":256000}, + "mistral/mistral-nemo": {"tools":true,"images":false,"reasoning":false,"context":60288,"output":16000}, + "mistral/mistral-small": {"tools":true,"images":true,"reasoning":false,"context":262144,"output":4000}, + "mixedbread/toast-1": {"tools":true,"images":false,"reasoning":false,"context":131000,"output":4000}, + "moonshotai/kimi-k2": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":131072}, + "moonshotai/kimi-k2-thinking": {"tools":true,"images":false,"reasoning":true,"context":216144,"output":216144}, + "moonshotai/kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "moonshotai/kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262000,"output":262000}, + "moonshotai/kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32768}, + "moonshotai/kimi-k2.7-code-highspeed": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "moonshotai/kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "moonshotai/kimi-k3-fast": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "morph/morph-v3-fast": {"tools":false,"images":false,"reasoning":false,"context":16000,"output":16000}, + "morph/morph-v3-large": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "nvidia/nemotron-3-nano-30b-a3b": {"tools":false,"images":false,"reasoning":true,"context":262144,"output":262144}, + "nvidia/nemotron-3-super-120b-a12b": {"tools":false,"images":false,"reasoning":true,"context":256000,"output":32000}, + "nvidia/nemotron-3-ultra-550b-a55b": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":65000}, + "nvidia/nemotron-3.5-lightning": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":131072}, + "nvidia/nemotron-nano-12b-v2-vl": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":131072}, + "nvidia/nemotron-nano-9b-v2": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":131072}, + "openai/gpt-3.5-turbo": {"tools":false,"images":false,"reasoning":false,"context":16385,"output":4096}, + "openai/gpt-4.1": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4.1-fast": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4.1-mini": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4.1-mini-fast": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4.1-nano-fast": {"tools":true,"images":true,"reasoning":false,"context":1047576,"output":32768}, + "openai/gpt-4o": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-fast": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-mini": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-mini-fast": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":16384}, + "openai/gpt-4o-mini-transcribe": {"tools":false,"images":false,"reasoning":false}, + "openai/gpt-4o-transcribe": {"tools":false,"images":false,"reasoning":false}, + "openai/gpt-5": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-fast": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-mini-fast": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":272000}, + "openai/gpt-5.1-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-codex-max": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-codex-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-thinking": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.1-thinking-fast": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2-fast": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.2-pro": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.3-codex": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.3-codex-fast": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.4-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.4-mini": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4-mini-fast": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4-nano": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "openai/gpt-5.4-pro": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.5": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "openai/gpt-5.5-fast": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "openai/gpt-5.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":128000}, + "openai/gpt-5.6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-luna-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-sol-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-terra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-5.6-terra-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-astra": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-astra-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-luna": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-luna-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6-sol-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6.1-sol": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-6.1-sol-fast": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":128000}, + "openai/gpt-live-1": {"tools":false,"images":false,"reasoning":false}, + "openai/gpt-oss-120b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":131072}, + "openai/gpt-oss-20b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "openai/gpt-oss-safeguard-120b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16000}, + "openai/gpt-oss-safeguard-20b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16000}, + "openai/gpt-realtime-1.5": {"tools":false,"images":false,"reasoning":false}, + "openai/gpt-realtime-2": {"tools":false,"images":false,"reasoning":false}, + "openai/gpt-realtime-2.1": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32000}, + "openai/gpt-realtime-mini": {"tools":false,"images":false,"reasoning":false}, + "openai/gpt-realtime-whisper": {"tools":false,"images":false,"reasoning":false}, + "openai/o3": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o3-fast": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o3-pro": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/o4-mini-fast": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":100000}, + "openai/text-embedding-3-large": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "openai/text-embedding-3-small": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "openai/text-embedding-ada-002": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "openai/whisper-1": {"tools":false,"images":false,"reasoning":false}, + "perplexity/pplx-embed-v1-0.6b": {"tools":false,"images":false,"reasoning":false,"context":32000}, + "perplexity/pplx-embed-v1-4b": {"tools":false,"images":false,"reasoning":false,"context":32000}, + "perplexity/sonar": {"tools":true,"images":true,"reasoning":false,"context":127000,"output":8000}, + "poolside/laguna-s-2.1": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "poolside/laguna-s-2.1-free": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":32768}, + "quiverai/arrow-2": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":65536}, + "quiverai/arrow-2-telos": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":65536}, + "sakana/fugu-max": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "sakana/fugu-ultra": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "sakana/fugu-ultra-v2": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "sakana/namazu": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "spacexai/grok-4.1-fast-non-reasoning": {"tools":true,"images":true,"reasoning":false,"context":1000000,"output":1000000}, + "spacexai/grok-4.1-fast-reasoning": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "spacexai/grok-4.20-multi-agent": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "spacexai/grok-4.20-multi-agent-beta": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "spacexai/grok-4.20-non-reasoning": {"tools":true,"images":true,"reasoning":false,"context":2000000,"output":2000000}, + "spacexai/grok-4.20-non-reasoning-beta": {"tools":true,"images":true,"reasoning":false,"context":2000000,"output":2000000}, + "spacexai/grok-4.20-reasoning": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "spacexai/grok-4.20-reasoning-beta": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "spacexai/grok-4.3": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "spacexai/grok-4.5": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "spacexai/grok-4.6": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "spacexai/grok-4.7": {"tools":true,"images":true,"reasoning":true,"context":500000,"output":500000}, + "spacexai/grok-build-0.1": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "spacexai/grok-stt": {"tools":false,"images":false,"reasoning":false}, + "spacexai/grok-voice-think-fast-1.0": {"tools":false,"images":false,"reasoning":false}, + "spacexai/grok-voice-think-fast-2.0": {"tools":false,"images":false,"reasoning":false}, + "stepfun/step-3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":262114,"output":262114}, + "stepfun/step-3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "stepfun/step-5-preview": {"tools":false,"images":true,"reasoning":false,"context":1000000,"output":1000000}, + "tencent/hy-mt2-lite": {"tools":false,"images":false,"reasoning":false,"context":8000,"output":4000}, + "tencent/hy-mt2-plus": {"tools":false,"images":false,"reasoning":false,"context":8000,"output":4000}, + "tencent/hy-mt2-pro": {"tools":false,"images":false,"reasoning":false,"context":8000,"output":4000}, + "tencent/hy3": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":262144}, + "tencent/hy4-preview": {"tools":true,"images":false,"reasoning":true,"context":1024000,"output":64000}, + "thinkingmachines/inkling": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "thinkingmachines/inkling-small": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":1000000}, + "voyage/rerank-2.5": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "voyage/rerank-2.5-lite": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "voyage/rerank-3": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "voyage/rerank-3-lite": {"tools":false,"images":false,"reasoning":false,"context":32000,"output":32000}, + "voyage/voyage-3-large": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "voyage/voyage-3.5": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "voyage/voyage-3.5-lite": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "voyage/voyage-4": {"tools":false,"images":false,"reasoning":false,"context":32000}, + "voyage/voyage-4-large": {"tools":false,"images":false,"reasoning":false,"context":32000}, + "voyage/voyage-4-lite": {"tools":false,"images":false,"reasoning":false,"context":32000}, + "voyage/voyage-code-2": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "voyage/voyage-code-3": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "voyage/voyage-finance-2": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "voyage/voyage-law-2": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1536}, + "xiaomi/mimo-v2.5": {"tools":true,"images":true,"reasoning":true,"context":1050000,"output":131100}, + "xiaomi/mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1050000,"output":131000}, + "xiaomi/mimo-v2.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "xiaomi/mimo-v2.6-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "xiaomi/mimo-v2.6-pro-ultraspeed": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "zai/glm-4.5": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":96000}, + "zai/glm-4.5-air": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":96000}, + "zai/glm-4.5v": {"tools":true,"images":true,"reasoning":true,"context":66000,"output":16000}, + "zai/glm-4.6": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":96000}, + "zai/glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":120000}, + "zai/glm-4.7-flash": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":131000}, + "zai/glm-4.7-flashx": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000}, + "zai/glm-5": {"tools":true,"images":false,"reasoning":true,"context":202800,"output":131100}, + "zai/glm-5-turbo": {"tools":true,"images":false,"reasoning":true,"context":202800,"output":131072}, + "zai/glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":202800,"output":64000}, + "zai/glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":128000}, + "zai/glm-5.2-fast": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":128000}, + "zai/glm-5.3": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":1000000}, + "zai/glm-5.3-fast": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":262144}, + "zai/glm-5.3-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131000}, + "zai/glm-5.3-flashx": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "zai/glm-5v-turbo": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":128000} + }, + "doubao": { + "deepseek-v4-flash-ga-260731": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-pro-ga-260813": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "doubao-seed-1-6-251015": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":64000}, + "doubao-seed-1-6-flash-250828": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-1-6-vision-250815": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-1-8-251228": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":64000}, + "doubao-seed-2-0-code-preview-260215": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "doubao-seed-2-0-lite-260428": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":131072}, + "doubao-seed-2-0-mini-260428": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":131072}, + "doubao-seed-2-0-pro-260215": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "doubao-seed-2-1-pro-260628": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "doubao-seed-2-1-turbo-260628": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "doubao-seed-character-260628": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "doubao-seed-evolving": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":256000}, + "glm-5-2-260617": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "glm-5-3-flash-260828": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072} + }, + "modelscope": { + "Qwen/Qwen3-235B-A22B-Instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":131072}, + "Qwen/Qwen3-235B-A22B-Thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":131072}, + "Qwen/Qwen3-30B-A3B-Instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":16384}, + "Qwen/Qwen3-30B-A3B-Thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32768}, + "Qwen/Qwen3-Coder-30B-A3B-Instruct": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "ZhipuAI/GLM-4.5": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "ZhipuAI/GLM-4.6": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":98304} + }, + "glm": { + "glm-4.5": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "glm-4.5-air": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "glm-4.5-flash": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "glm-4.5v": {"tools":true,"images":true,"reasoning":true,"context":64000,"output":16384}, + "glm-4.6": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "glm-4.6v": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":32768}, + "glm-4.6v-flash": {"tools":true,"images":true,"reasoning":true,"context":128000,"output":32768}, + "glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "glm-4.7-flash": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":131072}, + "glm-4.7-flashx": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":131072}, + "glm-5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":131072}, + "glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "glm-5.3": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "glm-5.3-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "glm-5.3-flashx": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "glm-5v-turbo": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":131072} + }, + "qwen": { + "deepseek-r1": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "deepseek-r1-0528": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "deepseek-r1-distill-llama-70b": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "deepseek-r1-distill-llama-8b": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "deepseek-r1-distill-qwen-1-5b": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "deepseek-r1-distill-qwen-14b": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "deepseek-r1-distill-qwen-32b": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "deepseek-r1-distill-qwen-7b": {"tools":true,"images":false,"reasoning":true,"context":32768,"output":16384}, + "deepseek-v3": {"tools":true,"images":false,"reasoning":false,"context":65536,"output":8192}, + "deepseek-v3-1": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":65536}, + "deepseek-v3-2-exp": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":65536}, + "deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":384000}, + "deepseek-v4.1-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":384000}, + "glm-5": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":16384}, + "glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":202752,"output":128000}, + "glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":128000}, + "glm-5.3": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":131072}, + "kimi-k2-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":16384}, + "kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":16384}, + "kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "kimi/kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "MiniMax-M2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax/MiniMax-M2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "moonshot-kimi-k2-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qvq-max": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":8192}, + "qwen-deep-research": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":32768}, + "qwen-doc-turbo": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen-flash": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":32768}, + "qwen-long": {"tools":true,"images":false,"reasoning":false,"context":10000000,"output":8192}, + "qwen-math-plus": {"tools":true,"images":false,"reasoning":false,"context":4096,"output":3072}, + "qwen-math-turbo": {"tools":true,"images":false,"reasoning":false,"context":4096,"output":3072}, + "qwen-max": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen-mt-plus": {"tools":false,"images":false,"reasoning":false,"context":16384,"output":8192}, + "qwen-mt-turbo": {"tools":false,"images":false,"reasoning":false,"context":16384,"output":8192}, + "qwen-omni-turbo": {"tools":true,"images":true,"reasoning":false,"context":32768,"output":2048}, + "qwen-omni-turbo-realtime": {"tools":true,"images":true,"reasoning":false,"context":32768,"output":2048}, + "qwen-plus": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":32768}, + "qwen-plus-character": {"tools":true,"images":false,"reasoning":false,"context":32768,"output":4096}, + "qwen-turbo": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":16384}, + "qwen-vl-max": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":8192}, + "qwen-vl-ocr": {"tools":false,"images":true,"reasoning":false,"context":34096,"output":4096}, + "qwen-vl-plus": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-14b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-32b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-72b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-7b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-coder-32b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-coder-7b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-math-72b-instruct": {"tools":true,"images":false,"reasoning":false,"context":4096,"output":3072}, + "qwen2-5-math-7b-instruct": {"tools":true,"images":false,"reasoning":false,"context":4096,"output":3072}, + "qwen2-5-omni-7b": {"tools":true,"images":true,"reasoning":false,"context":32768,"output":2048}, + "qwen2-5-vl-72b-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":8192}, + "qwen2-5-vl-7b-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":8192}, + "qwen3-14b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "qwen3-235b-a22b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "qwen3-32b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":16384}, + "qwen3-8b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "qwen3-asr-flash": {"tools":false,"images":false,"reasoning":false,"context":53248,"output":4096}, + "qwen3-coder-30b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen3-coder-480b-a35b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen3-coder-flash": {"tools":true,"images":false,"reasoning":false,"context":1000000,"output":65536}, + "qwen3-coder-plus": {"tools":true,"images":false,"reasoning":false,"context":1048576,"output":65536}, + "qwen3-max": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "qwen3-next-80b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":32768}, + "qwen3-next-80b-a3b-thinking": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "qwen3-omni-flash": {"tools":true,"images":true,"reasoning":true,"context":65536,"output":16384}, + "qwen3-omni-flash-realtime": {"tools":true,"images":true,"reasoning":false,"context":65536,"output":16384}, + "qwen3-vl-235b-a22b": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "qwen3-vl-30b-a3b": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "qwen3-vl-plus": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":32768}, + "qwen3.5-397b-a17b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen3.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.5-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.6-max-preview": {"tools":true,"images":false,"reasoning":true,"context":245800,"output":65536}, + "qwen3.6-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.7-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen3.7-max": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":65536}, + "qwen3.7-plus": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "qwen3.8-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen3.8-max": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwen3.8-omni-flash": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":131072}, + "qwq-32b": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "qwq-plus": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":8192}, + "siliconflow/deepseek-r1-0528": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":32768}, + "siliconflow/deepseek-v3-0324": {"tools":true,"images":false,"reasoning":false,"context":163840,"output":163840}, + "siliconflow/deepseek-v3.1-terminus": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":65536}, + "siliconflow/deepseek-v3.2": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":65536}, + "tongyi-intent-detect-v3": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":1024} + }, + "qiniu": { + "claude-3.5-haiku": {"tools":true,"images":true,"reasoning":false,"context":200000,"output":8192}, + "claude-3.5-sonnet": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":8200}, + "claude-3.7-sonnet": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":128000}, + "claude-4.0-opus": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "claude-4.0-sonnet": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-4.1-opus": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":32000}, + "claude-4.5-haiku": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "claude-4.5-opus": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":200000}, + "claude-4.5-sonnet": {"tools":true,"images":true,"reasoning":true,"context":200000,"output":64000}, + "deepseek-r1": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":32000}, + "deepseek-r1-0528": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":32000}, + "deepseek-v3": {"tools":false,"images":false,"reasoning":false,"context":128000,"output":16000}, + "deepseek-v3-0324": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":16000}, + "deepseek-v3.1": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":32000}, + "deepseek/deepseek-math-v2": {"tools":false,"images":false,"reasoning":true,"context":160000,"output":160000}, + "deepseek/deepseek-v3.1-terminus": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":32000}, + "deepseek/deepseek-v3.1-terminus-thinking": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32000}, + "deepseek/deepseek-v3.2-251201": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":32000}, + "deepseek/deepseek-v3.2-exp": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":32000}, + "deepseek/deepseek-v3.2-exp-thinking": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32000}, + "doubao-1.5-pro-32k": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":12000}, + "doubao-1.5-thinking-pro": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":16000}, + "doubao-1.5-vision-pro": {"tools":false,"images":true,"reasoning":false,"context":128000,"output":16000}, + "doubao-seed-1.6": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-1.6-flash": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-1.6-thinking": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-2.0-code": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "doubao-seed-2.0-lite": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-2.0-mini": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":32000}, + "doubao-seed-2.0-pro": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":128000}, + "gemini-2.0-flash": {"tools":true,"images":true,"reasoning":false,"context":1048576,"output":8192}, + "gemini-2.0-flash-lite": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":8192}, + "gemini-2.5-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":64000}, + "gemini-2.5-flash-lite": {"tools":true,"images":true,"reasoning":false,"context":1048576,"output":64000}, + "gemini-2.5-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":65536}, + "gemini-3.0-flash-preview": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "gemini-3.0-pro-image-preview": {"tools":false,"images":true,"reasoning":false,"context":32768,"output":8192}, + "gemini-3.0-pro-preview": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":64000}, + "glm-4.5": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "glm-4.5-air": {"tools":true,"images":false,"reasoning":true,"context":131000,"output":4096}, + "gpt-oss-120b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":4096}, + "gpt-oss-20b": {"tools":true,"images":false,"reasoning":true,"context":128000,"output":4096}, + "kimi-k2": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":128000}, + "meituan/longcat-flash-chat": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":131072}, + "meituan/longcat-flash-lite": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":320000}, + "mimo-v2-flash": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":256000}, + "MiniMax-M1": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":80000}, + "minimax/minimax-m2": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000}, + "minimax/minimax-m2.1": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128000}, + "minimax/minimax-m2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128000}, + "minimax/minimax-m2.5-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":128000}, + "moonshotai/kimi-k2-0905": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":100000}, + "moonshotai/kimi-k2-thinking": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":100000}, + "moonshotai/kimi-k2.5": {"tools":true,"images":true,"reasoning":false,"context":256000,"output":256000}, + "openai/gpt-5": {"tools":true,"images":false,"reasoning":false,"context":400000,"output":128000}, + "openai/gpt-5.2": {"tools":true,"images":true,"reasoning":true,"context":400000,"output":128000}, + "qwen-max-2025-01-25": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":4096}, + "qwen-turbo": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":4096}, + "qwen-vl-max-2025-01-25": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":4096}, + "qwen2.5-vl-72b-instruct": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "qwen2.5-vl-7b-instruct": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":8192}, + "qwen3-235b-a22b": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":32000}, + "qwen3-235b-a22b-instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":64000}, + "qwen3-235b-a22b-thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":4096}, + "qwen3-30b-a3b": {"tools":true,"images":false,"reasoning":true,"context":40000,"output":4096}, + "qwen3-30b-a3b-instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":128000,"output":32000}, + "qwen3-30b-a3b-thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":126000,"output":32000}, + "qwen3-32b": {"tools":true,"images":false,"reasoning":true,"context":40000,"output":4096}, + "qwen3-coder-480b-a35b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262000,"output":4096}, + "qwen3-max": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen3-max-preview": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":64000}, + "qwen3-next-80b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":32768}, + "qwen3-next-80b-a3b-thinking": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "qwen3-vl-30b-a3b-thinking": {"tools":true,"images":true,"reasoning":false,"context":128000,"output":32000}, + "qwen3.5-397b-a17b": {"tools":true,"images":true,"reasoning":true,"context":256000,"output":64000}, + "stepfun-ai/gelab-zero-4b-preview": {"tools":true,"images":true,"reasoning":false,"context":8192,"output":4096}, + "stepfun/step-3.5-flash": {"tools":false,"images":true,"reasoning":false,"context":64000,"output":4096}, + "x-ai/grok-4-fast": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "x-ai/grok-4-fast-non-reasoning": {"tools":true,"images":true,"reasoning":false,"context":2000000,"output":2000000}, + "x-ai/grok-4-fast-reasoning": {"tools":true,"images":true,"reasoning":true,"context":2000000,"output":2000000}, + "x-ai/grok-4.1-fast": {"tools":true,"images":false,"reasoning":true,"context":2000000,"output":2000000}, + "x-ai/grok-4.1-fast-non-reasoning": {"tools":true,"images":true,"reasoning":false,"context":2000000,"output":2000000}, + "x-ai/grok-4.1-fast-reasoning": {"tools":true,"images":true,"reasoning":true,"context":20000000,"output":2000000}, + "x-ai/grok-code-fast-1": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":10000}, + "xiaomi/mimo-v2-flash": {"tools":true,"images":false,"reasoning":true,"context":256000,"output":256000}, + "z-ai/autoglm-phone-9b": {"tools":true,"images":true,"reasoning":false,"context":12800,"output":4096}, + "z-ai/glm-4.6": {"tools":true,"images":false,"reasoning":false,"context":200000,"output":200000}, + "z-ai/glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":200000}, + "z-ai/glm-5": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000} + }, + "kimi": { + "kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k2.7-code-highspeed": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576} + }, + "minimax": { + "MiniMax-M2": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax-M2.1": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax-M2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax-M2.5-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax-M2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax-M2.7-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "MiniMax-M3": {"tools":true,"images":true,"reasoning":true,"context":1000000,"output":512000} + }, + "novita": { + "baichuan/baichuan-m2-32b": {"tools":false,"images":false,"reasoning":false,"context":131072,"output":131072}, + "baidu/ernie-4.5-21B-a3b": {"tools":true,"images":false,"reasoning":false,"context":120000,"output":8000}, + "baidu/ernie-4.5-21B-a3b-thinking": {"tools":false,"images":false,"reasoning":true,"context":131072,"output":65536}, + "baidu/ernie-4.5-300b-a47b-paddle": {"tools":false,"images":false,"reasoning":false,"context":123000,"output":12000}, + "baidu/ernie-4.5-vl-28b-a3b": {"tools":true,"images":true,"reasoning":true,"context":30000,"output":8000}, + "baidu/ernie-4.5-vl-28b-a3b-thinking": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":65536}, + "baidu/ernie-4.5-vl-424b-a47b": {"tools":false,"images":true,"reasoning":true,"context":123000,"output":16000}, + "deepseek/deepseek-ocr": {"tools":false,"images":true,"reasoning":false,"context":8192,"output":8192}, + "deepseek/deepseek-ocr-2": {"tools":false,"images":true,"reasoning":false,"context":8192,"output":8192}, + "deepseek/deepseek-prover-v2-671b": {"tools":false,"images":false,"reasoning":false,"context":160000,"output":160000}, + "deepseek/deepseek-r1-0528": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":32768}, + "deepseek/deepseek-r1-0528-qwen3-8b": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":32000}, + "deepseek/deepseek-r1-distill-llama-70b": {"tools":false,"images":false,"reasoning":true,"context":8192,"output":8192}, + "deepseek/deepseek-r1-distill-qwen-14b": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":16384}, + "deepseek/deepseek-r1-distill-qwen-32b": {"tools":false,"images":false,"reasoning":false,"context":64000,"output":32000}, + "deepseek/deepseek-r1-turbo": {"tools":true,"images":false,"reasoning":true,"context":64000,"output":16000}, + "deepseek/deepseek-v3-0324": {"tools":true,"images":false,"reasoning":false,"context":163840,"output":163840}, + "deepseek/deepseek-v3-turbo": {"tools":true,"images":false,"reasoning":false,"context":64000,"output":16000}, + "deepseek/deepseek-v3.1": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "deepseek/deepseek-v3.1-terminus": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "deepseek/deepseek-v3.2": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":65536}, + "deepseek/deepseek-v3.2-exp": {"tools":true,"images":false,"reasoning":true,"context":163840,"output":65536}, + "deepseek/deepseek-v4-flash": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":393216}, + "deepseek/deepseek-v4-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":393216}, + "google/gemma-3-12b-it": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":8192}, + "google/gemma-3-27b-it": {"tools":false,"images":true,"reasoning":false,"context":98304,"output":16384}, + "google/gemma-4-26b-a4b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "google/gemma-4-31b-it": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":131072}, + "gryphe/mythomax-l2-13b": {"tools":false,"images":false,"reasoning":false,"context":4096,"output":3200}, + "inclusionai/ling-2.6-1t": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":32768}, + "inclusionai/ling-2.6-flash": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":32768}, + "inclusionai/ring-2.6-1t": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":65536}, + "kwaipilot/kat-coder-pro": {"tools":true,"images":false,"reasoning":false,"context":256000,"output":128000}, + "meta-llama/llama-3-70b-instruct": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":8000}, + "meta-llama/llama-3-8b-instruct": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":8192}, + "meta-llama/llama-3.1-8b-instruct": {"tools":false,"images":false,"reasoning":false,"context":16384,"output":16384}, + "meta-llama/llama-3.2-3b-instruct": {"tools":false,"images":false,"reasoning":false,"context":32768,"output":32000}, + "meta-llama/llama-3.3-70b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":120000}, + "meta-llama/llama-4-maverick-17b-128e-instruct-fp8": {"tools":false,"images":true,"reasoning":false,"context":1048576,"output":8192}, + "meta-llama/llama-4-scout-17b-16e-instruct": {"tools":false,"images":true,"reasoning":false,"context":131072,"output":131072}, + "microsoft/wizardlm-2-8x22b": {"tools":false,"images":false,"reasoning":false,"context":65535,"output":8000}, + "minimax/minimax-m2": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.1": {"tools":true,"images":false,"reasoning":false,"context":204800,"output":131072}, + "minimax/minimax-m2.5": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.5-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimax/minimax-m2.7-highspeed": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "minimaxai/minimax-m1-80k": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":40000}, + "mistralai/mistral-nemo": {"tools":false,"images":false,"reasoning":false,"context":60288,"output":16000}, + "moonshotai/kimi-k2-0905": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":98304}, + "moonshotai/kimi-k2-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":32768}, + "moonshotai/kimi-k2-thinking": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":98304}, + "moonshotai/kimi-k2.5": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "moonshotai/kimi-k2.6": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "moonshotai/kimi-k2.7-code": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":262144}, + "moonshotai/kimi-k3": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":1048576}, + "nousresearch/hermes-2-pro-llama-3-8b": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":8192}, + "openai/gpt-oss-120b": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "openai/gpt-oss-20b": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "paddlepaddle/paddleocr-vl": {"tools":false,"images":true,"reasoning":false,"context":16384,"output":16384}, + "qwen/qwen-2.5-72b-instruct": {"tools":true,"images":false,"reasoning":false,"context":32000,"output":8192}, + "qwen/qwen-mt-plus": {"tools":false,"images":false,"reasoning":false,"context":16384,"output":8192}, + "qwen/qwen2.5-7b-instruct": {"tools":true,"images":false,"reasoning":false,"context":32000,"output":32000}, + "qwen/qwen2.5-vl-72b-instruct": {"tools":false,"images":true,"reasoning":false,"context":32768,"output":32768}, + "qwen/qwen3-235b-a22b-fp8": {"tools":false,"images":false,"reasoning":true,"context":40960,"output":20000}, + "qwen/qwen3-235b-a22b-instruct-2507": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":16384}, + "qwen/qwen3-235b-a22b-thinking-2507": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "qwen/qwen3-30b-a3b-fp8": {"tools":false,"images":false,"reasoning":true,"context":40960,"output":20000}, + "qwen/qwen3-32b-fp8": {"tools":false,"images":false,"reasoning":true,"context":40960,"output":20000}, + "qwen/qwen3-4b-fp8": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":20000}, + "qwen/qwen3-8b-fp8": {"tools":false,"images":false,"reasoning":true,"context":128000,"output":20000}, + "qwen/qwen3-coder-30b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":160000,"output":32768}, + "qwen/qwen3-coder-480b-a35b-instruct": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen/qwen3-coder-next": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen/qwen3-max": {"tools":true,"images":false,"reasoning":false,"context":262144,"output":65536}, + "qwen/qwen3-next-80b-a3b-instruct": {"tools":true,"images":false,"reasoning":false,"context":131072,"output":32768}, + "qwen/qwen3-next-80b-a3b-thinking": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":32768}, + "qwen/qwen3-omni-30b-a3b-instruct": {"tools":true,"images":true,"reasoning":false,"context":65536,"output":16384}, + "qwen/qwen3-omni-30b-a3b-thinking": {"tools":true,"images":true,"reasoning":true,"context":65536,"output":16384}, + "qwen/qwen3-vl-235b-a22b-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":32768}, + "qwen/qwen3-vl-235b-a22b-thinking": {"tools":false,"images":true,"reasoning":true,"context":131072,"output":32768}, + "qwen/qwen3-vl-30b-a3b-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":32768}, + "qwen/qwen3-vl-30b-a3b-thinking": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":32768}, + "qwen/qwen3-vl-8b-instruct": {"tools":true,"images":true,"reasoning":false,"context":131072,"output":32768}, + "qwen/qwen3.5-122b-a10b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3.5-27b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3.5-35b-a3b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":65536}, + "qwen/qwen3.5-397b-a17b": {"tools":true,"images":true,"reasoning":true,"context":262144,"output":64000}, + "qwen/qwen3.7-max": {"tools":true,"images":false,"reasoning":true,"context":1000000,"output":65536}, + "sao10K/l3-70b-euryale-v2.1": {"tools":true,"images":false,"reasoning":false,"context":8192,"output":8192}, + "sao10K/l3-8b-lunaris": {"tools":false,"images":false,"reasoning":false,"context":8192,"output":8192}, + "sao10K/L3-8B-stheno-v3.2": {"tools":true,"images":false,"reasoning":false,"context":8192,"output":32000}, + "sao10K/l31-70b-euryale-v2.2": {"tools":true,"images":false,"reasoning":false,"context":8192,"output":8192}, + "xiaomimimo/mimo-v2-flash": {"tools":true,"images":false,"reasoning":true,"context":262144,"output":32000}, + "xiaomimimo/mimo-v2-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "xiaomimimo/mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "zai-org/autoglm-phone-9b-multilingual": {"tools":false,"images":true,"reasoning":false,"context":65536,"output":65536}, + "zai-org/glm-4.5": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "zai-org/glm-4.5-air": {"tools":true,"images":false,"reasoning":true,"context":131072,"output":98304}, + "zai-org/glm-4.5v": {"tools":true,"images":true,"reasoning":true,"context":65536,"output":16384}, + "zai-org/glm-4.6": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "zai-org/glm-4.6v": {"tools":true,"images":true,"reasoning":true,"context":131072,"output":32768}, + "zai-org/glm-4.7": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "zai-org/glm-4.7-flash": {"tools":true,"images":false,"reasoning":true,"context":200000,"output":128000}, + "zai-org/glm-5": {"tools":true,"images":false,"reasoning":true,"context":202800,"output":131072}, + "zai-org/glm-5.1": {"tools":true,"images":false,"reasoning":true,"context":204800,"output":131072}, + "zai-org/glm-5.2": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072} + }, + "mimo": { + "mimo-v2.5": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.5-pro": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.5-pro-ultraspeed": {"tools":true,"images":false,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.6-flash": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.6-pro": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072}, + "mimo-v2.6-pro-ultraspeed": {"tools":true,"images":true,"reasoning":true,"context":1048576,"output":131072} + } +} diff --git a/lib/model-catalog.ts b/lib/model-catalog.ts new file mode 100644 index 00000000..60f26cf5 --- /dev/null +++ b/lib/model-catalog.ts @@ -0,0 +1,45 @@ +import catalog from "@/lib/model-catalog.json" +import type { ProviderName } from "@/lib/types/model-config" + +/** + * What models.dev knows about a model (scripts/update-model-catalog.mjs). + * Only used for hints: requests are sent the same way either way, since + * the data can be wrong or out of date. + */ +export interface ModelInfo { + tools: boolean + images: boolean + reasoning: boolean + context?: number + output?: number +} + +const CATALOG = catalog as Record> + +/** + * The entry for a model: an exact match ignoring case, else the longest id + * the model id starts with, followed by "-", ":" or ".". So + * claude-sonnet-4-5-20250929 finds claude-sonnet-4-5, but gpt-4 does not + * find gpt-4o. + */ +export function getModelInfo( + provider: ProviderName, + modelId: string, +): ModelInfo | undefined { + const models = CATALOG[provider] + if (!models) return undefined + const wanted = modelId.trim().toLowerCase() + let best: string | undefined + for (const id of Object.keys(models)) { + const lower = id.toLowerCase() + if (lower === wanted) return models[id] + if ( + wanted.startsWith(lower) && + "-:.".includes(wanted[lower.length]) && + lower.length > (best?.length ?? 0) + ) { + best = id + } + } + return best ? models[best] : undefined +} diff --git a/lib/output-token-limit.ts b/lib/output-token-limit.ts index bd4a9876..28119eea 100644 --- a/lib/output-token-limit.ts +++ b/lib/output-token-limit.ts @@ -22,6 +22,12 @@ export const MAX_OUTPUT_TOKENS_LIMIT = 200000 */ const MIN_USABLE_OUTPUT_TOKENS = 1024 +/** + * Retry budget when a rejection names the budget parameter but no number we can + * read. It is the default from before 64000, which these providers ran with. + */ +const FALLBACK_OUTPUT_TOKENS = 16000 + /** Status codes that can carry a complaint about the requested budget. */ const BUDGET_REJECTION_STATUSES = new Set([400, 422]) @@ -29,24 +35,8 @@ function usableLimit(value: number): number | null { return value >= MIN_USABLE_OUTPUT_TOKENS ? value : null } -/** - * A budget this large exceeds what some models accept. Providers reject it with a - * 400 that names the real limit, so we parse the number out and retry once - * instead of failing the turn. - * - * Formats seen in the wild: - * - Bedrock: "The maximum tokens you requested exceeds the model limit of 4096." - * - OpenRouter: "This endpoint's maximum context length is 64000 tokens. However, - * you requested about 64025 tokens (25 of text input, 64000 in the output)." - * Note this one is an input+output ceiling, so the input has to be subtracted. - * - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..." - * - OpenAI: "This model supports at most 16384 completion tokens" - * - * Every pattern names tokens explicitly. A generic one (an earlier draft matched - * "lower than N") would reinterpret unrelated failures, and retrying on a bogus - * number turns a readable error into an empty diagram. - */ -export function parseOutputTokenLimit(error: unknown): number | null { +/** Message and body of an error that may be about the budget, or null. */ +export function rejectionText(error: unknown): string | null { const err = error as { message?: unknown responseBody?: unknown @@ -66,24 +56,109 @@ export function parseOutputTokenLimit(error: unknown): number | null { typeof err?.responseBody === "string" ? err.responseBody : "", ].join(" ") - if (!text) return null + return text.trim() ? text : null +} +/** + * A budget this large exceeds what some models accept. Providers reject it with a + * 400 that names the real limit, so we parse the number out and retry once + * instead of failing the turn. + * + * Formats seen in the wild: + * - Bedrock: "The maximum tokens you requested exceeds the model limit of 4096." + * - OpenRouter: "This endpoint's maximum context length is 64000 tokens. However, + * you requested about 64025 tokens (25 of text input, 64000 in the output)." + * Note this one is an input+output ceiling, so the input has to be subtracted. + * vLLM and SGLang send the same kind of ceiling, with the input written as + * "6000 in the messages", "has 6000 input tokens" or "6000 tokens from the input". + * - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..." + * - OpenAI: "This model supports at most 16384 completion tokens" + * - Volcengine Ark: "The parameter `max_tokens` specified in the request are not + * valid: integer above maximum value, expected a value <= 32768, but got 64000" + * - DashScope: "Range of max_tokens should be [1, 8192]" + * + * Every pattern names tokens explicitly. A generic one (an earlier draft matched + * "lower than N") would reinterpret unrelated failures, and retrying on a bogus + * number turns a readable error into an empty diagram. + */ +function readCeiling(text: string): number | null { // Combined input+output ceiling: subtract the input the provider counted, // plus a small margin because its estimate is approximate. - const context = text.match(/maximum context length is (\d+)/i) + const context = text.match(/maximum context length (?:is|of) (\d+)/i) if (context) { - const input = text.match(/(\d+) of text input/i) - return usableLimit( - Number(context[1]) - (input ? Number(input[1]) : 0) - 1024, - ) + const input = + text.match(/(\d+) of text input/i) || + text.match(/(\d+) in the messages/i) || + text.match(/(\d+) tokens from the input/i) || + text.match(/(\d+) input tokens/i) + return Number(context[1]) - (input ? Number(input[1]) : 0) - 1024 } const output = text.match(/model limit of (\d+)/i) || text.match(/> (\d+), which is the maximum/i) || - text.match(/at most (\d+) completion tokens/i) + text.match(/at most (\d+) completion tokens/i) || + text.match(/max_\w*tokens.*?expected a value (?:<=|\\u003c=) (\d+)/i) || + text.match(/Range of max_tokens should be \[1,\s*(\d+)\]/i) - return output ? usableLimit(Number(output[1])) : null + return output ? Number(output[1]) : null +} + +/** The usable output ceiling named in a rejection, or null. */ +export function parseOutputTokenLimit(error: unknown): number | null { + const text = rejectionText(error) + const ceiling = text ? readCeiling(text) : null + return ceiling === null ? null : usableLimit(ceiling) +} + +/** + * Thinking budget the provider adds on top of maxOutputTokens. Bedrock and + * Anthropic send maxOutputTokens + budgetTokens as max_tokens, so a ceiling in + * their rejection covers both. + */ +function thinkingBudget(providerOptions: unknown): number { + const options = providerOptions as + | { + bedrock?: { + reasoningConfig?: { type?: string; budgetTokens?: unknown } + } + anthropic?: { + thinking?: { type?: string; budgetTokens?: unknown } + } + } + | undefined + const config = + options?.bedrock?.reasoningConfig ?? options?.anthropic?.thinking + return config?.type === "enabled" && typeof config.budgetTokens === "number" + ? config.budgetTokens + : 0 +} + +/** + * The budget to retry with after a rejection, or null to surface the error. + */ +export function retryOutputTokens( + error: unknown, + params: { maxOutputTokens?: number; providerOptions?: unknown }, +): number | null { + const requested = params.maxOutputTokens + const text = rejectionText(error) + if (!requested || !text) return null + + const ceiling = readCeiling(text) + if (ceiling !== null) { + // The ceiling applies to what was actually sent, thinking included, + // so the retry has to leave room for the thinking too. + const thinking = thinkingBudget(params.providerOptions) + if (ceiling >= requested + thinking) return null + return usableLimit(ceiling - thinking) + } + + // Names the budget parameter, but in a format we cannot read a number from + if (/max_\w*tokens/i.test(text) && requested > FALLBACK_OUTPUT_TOKENS) { + return FALLBACK_OUTPUT_TOKENS + } + return null } /** @@ -103,17 +178,15 @@ export function withOutputTokenLimitFallback( try { return await doStream() } catch (error) { - const limit = parseOutputTokenLimit(error) - const requested = params.maxOutputTokens - - if (!limit || !requested || limit >= requested) throw error + const retry = retryOutputTokens(error, params) + if (!retry) throw error console.warn( - `[maxOutputTokens] ${requested} rejected, retrying with ${limit}`, + `[maxOutputTokens] ${params.maxOutputTokens} rejected, retrying with ${retry}`, ) return await inner.doStream({ ...params, - maxOutputTokens: limit, + maxOutputTokens: retry, }) } }, @@ -135,11 +208,17 @@ function validBudget(value: string | null | undefined): number | null { * desktop app too), then server env, then the default. Both sources go through * the same validation, so a typo in either falls back instead of reaching the * provider. + * + * On the server's credentials the user setting can only lower the server value, + * so MAX_OUTPUT_TOKENS keeps capping what the server pays for. */ -export function resolveMaxOutputTokens(headerValue: string | null): number { - return ( - validBudget(headerValue) ?? - validBudget(process.env.MAX_OUTPUT_TOKENS) ?? - DEFAULT_MAX_OUTPUT_TOKENS - ) +export function resolveMaxOutputTokens( + headerValue: string | null, + usesServerCredentials: boolean, +): number { + const header = validBudget(headerValue) + const server = + validBudget(process.env.MAX_OUTPUT_TOKENS) ?? DEFAULT_MAX_OUTPUT_TOKENS + if (header === null) return server + return usesServerCredentials ? Math.min(header, server) : header } diff --git a/lib/pdf-utils.ts b/lib/pdf-utils.ts index 2e5c4adb..49db1041 100644 --- a/lib/pdf-utils.ts +++ b/lib/pdf-utils.ts @@ -1,4 +1,4 @@ -import { extractText, getDocumentProxy } from "unpdf" +import { extractText } from "unpdf" // Maximum characters allowed for extracted text (configurable via env) const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars @@ -14,6 +14,7 @@ const TEXT_EXTENSIONS = [ ".json", ".csv", ".xml", + ".svg", ".html", ".css", ".js", @@ -43,8 +44,10 @@ const TEXT_EXTENSIONS = [ */ export async function extractPdfText(file: File): Promise { const buffer = await file.arrayBuffer() - const pdf = await getDocumentProxy(new Uint8Array(buffer)) - const { text } = await extractText(pdf, { mergePages: true }) + // Pass raw bytes so unpdf destroys the PDF document when it is done + const { text } = await extractText(new Uint8Array(buffer), { + mergePages: true, + }) return text as string } diff --git a/lib/provider-models.ts b/lib/provider-models.ts new file mode 100644 index 00000000..a809da75 --- /dev/null +++ b/lib/provider-models.ts @@ -0,0 +1,255 @@ +import { createGateway } from "ai" +import { getModelInfo } from "@/lib/model-catalog" +import { readLimitedBody } from "@/lib/read-limited-body" +import { + normalizeBaseUrl, + PROVIDER_INFO, + type ProviderName, +} from "@/lib/types/model-config" + +/** A model a provider offers. tools is false when it cannot call tools. */ +export interface ListedModel { + id: string + tools?: boolean +} + +export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models" + +export function canListModels(provider: ProviderName): boolean { + return ( + Object.hasOwn(PROVIDER_INFO, provider) && + !!PROVIDER_INFO[provider].modelList + ) +} + +// Models in OpenAI-style lists that are not for chat +const NON_CHAT = + /(?:^|[-/_])(?:embed(?:ding)?s?|whisper|tts|transcribe|dall-e|moderation|rerank|realtime|sora)(?:$|[-/_])|gpt-image/i + +const NON_CHAT_AIHUBMIX_TYPES = new Set([ + "embedding", + "image_generation", + "rerank", + "transcription", + "tts", + "video", +]) + +/** Chat model ids from AIHubMix's public model list */ +export function extractAihubmixModelIds(payload: unknown): string[] { + const data = (payload as { data?: unknown })?.data + if (!Array.isArray(data)) return [] + const ids = new Set() + for (const item of data) { + const record = item as { model_id?: unknown; types?: unknown } + if (typeof record?.model_id !== "string" || !record.model_id.trim()) { + continue + } + const types = new Set( + typeof record.types === "string" + ? record.types.split(",").map((t) => t.trim()) + : [], + ) + if (!types.has("llm")) continue + if ([...NON_CHAT_AIHUBMIX_TYPES].some((t) => types.has(t))) continue + ids.add(record.model_id.trim()) + } + return [...ids] +} + +/** + * An error this module wrote itself. Only these texts reach the caller: + * the base URL is the caller's and may be an internal address, so anything + * else (a parse error quoting the body, a network error naming a host) + * stays in the server log. + */ +export class ModelListError extends Error { + constructor( + message: string, + readonly statusCode?: number, + ) { + super(message) + this.name = "ModelListError" + } +} + +const MAX_LIST_BYTES = 2 * 1024 * 1024 + +/** A fetch that reads at most MAX_LIST_BYTES of each response */ +function sizeLimitedFetch(fetchFn: typeof fetch): typeof fetch { + return async (input, init) => { + // Ends a download that is too large (the Gateway SDK passes no + // signal of its own) + const download = new AbortController() + const signal = init?.signal + ? AbortSignal.any([init.signal, download.signal]) + : download.signal + const response = await fetchFn(input, { ...init, signal }) + const body = await readLimitedBody(response, MAX_LIST_BYTES) + if (body === null) { + download.abort() + throw new ModelListError("The model list is too large.") + } + // The body is already decoded and has its own length now + const headers = new Headers(response.headers) + headers.delete("content-encoding") + headers.delete("content-length") + // Some statuses must have no body at all + const noBody = [101, 204, 205, 304].includes(response.status) + return new Response(noBody ? null : body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } +} + +/** GET a JSON list; a failed request carries its status for the error hint */ +async function getJson( + url: string, + headers: Record, + fetchFn: typeof fetch, +): Promise { + const response = await fetchFn(url, { + headers, + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) { + throw new ModelListError( + `The model list request failed (${response.status})`, + response.status, + ) + } + const text = await response.text() + try { + return JSON.parse(text) + } catch { + throw new ModelListError("The model list was not valid JSON.") + } +} + +/** + * Where to list from without the user's base URL: where chat goes then. For + * Ollama without a key that is the server's Ollama, else the SDK's local + * default; a local default in PROVIDER_INFO (SGLang's) only fills the + * settings form. + */ +function listFallbackUrl(provider: ProviderName, apiKey?: string): string { + if (provider === "ollama" && !apiKey) { + return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api" + } + const url = PROVIDER_INFO[provider].defaultBaseUrl + return url?.startsWith("https://") ? url : "" +} + +/** + * The provider's chat models, with tool support from the provider's own + * data or else models.dev. Only the client's key is used, so the server's + * keys never go to a URL the client chose. + */ +export async function listProviderModels( + provider: ProviderName, + { apiKey, baseUrl }: { apiKey?: string; baseUrl?: string }, + unlimitedFetch: typeof fetch = fetch, +): Promise { + const fetchFn = sizeLimitedFetch(unlimitedFetch) + const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider, apiKey)) + const bearer: Record = apiKey + ? { Authorization: `Bearer ${apiKey}` } + : {} + let models: ListedModel[] + + // AIHubMix has a public list, unless the user points to another + // endpoint, which is OpenAI-compatible + const style = + provider === "aihubmix" && + baseUrl && + !/^https:\/\/aihubmix\.com(\/v1)?$/.test(base) + ? "openai" + : PROVIDER_INFO[provider].modelList + + switch (style) { + case "anthropic": { + const data = await getJson( + `${base}/models?limit=1000`, + { + "x-api-key": apiKey ?? "", + "anthropic-version": "2023-06-01", + }, + fetchFn, + ) + models = (data.data ?? []).map((m: { id: string }) => ({ + id: m.id, + })) + break + } + case "google": { + // The key goes in a header: in the URL it would end up in logs + const data = await getJson( + `${base}/models?pageSize=1000`, + { "x-goog-api-key": apiKey ?? "" }, + fetchFn, + ) + models = (data.models ?? []) + .filter((m: { supportedGenerationMethods?: string[] }) => + m.supportedGenerationMethods?.includes("generateContent"), + ) + .map((m: { name: string }) => ({ + id: m.name.replace(/^models\//, ""), + })) + break + } + case "ollama": { + const api = base.endsWith("/api") ? base : `${base}/api` + const data = await getJson(`${api}/tags`, bearer, fetchFn) + models = (data.models ?? []).map((m: { name: string }) => ({ + id: m.name, + })) + break + } + case "openrouter": { + const data = await getJson(`${base}/models`, bearer, fetchFn) + models = (data.data ?? []).map( + (m: { id: string; supported_parameters?: string[] }) => ({ + id: m.id, + ...(m.supported_parameters && { + tools: m.supported_parameters.includes("tools"), + }), + }), + ) + break + } + case "gateway": { + const { models: entries } = await createGateway({ + ...(apiKey && { apiKey }), + ...(baseUrl && { baseURL: base }), + fetch: fetchFn, + }).getAvailableModels() + models = entries + .filter((m) => !m.modelType || m.modelType === "language") + .map((m) => ({ id: m.id })) + break + } + case "aihubmix": { + const data = await getJson(AIHUBMIX_MODELS_ENDPOINT, {}, fetchFn) + models = extractAihubmixModelIds(data).map((id) => ({ id })) + break + } + default: { + if (!base) { + throw new ModelListError( + `${PROVIDER_INFO[provider].label} needs a base URL to list its models.`, + ) + } + const data = await getJson(`${base}/models`, bearer, fetchFn) + models = (data.data ?? []) + .map((m: { id: string }) => ({ id: m.id })) + .filter((m: ListedModel) => !NON_CHAT.test(m.id)) + } + } + + return models.map((m) => ({ + ...m, + tools: m.tools ?? getModelInfo(provider, m.id)?.tools, + })) +} diff --git a/lib/read-limited-body.ts b/lib/read-limited-body.ts new file mode 100644 index 00000000..884462f9 --- /dev/null +++ b/lib/read-limited-body.ts @@ -0,0 +1,32 @@ +/** + * Read a response body, giving up once it passes maxBytes, so a huge + * download from a URL the client chose can't exhaust server memory. + * Returns null when it is too large; the caller then aborts the request, + * which ends the download. + */ +export async function readLimitedBody( + response: Response, + maxBytes: number, +): Promise { + if (Number(response.headers.get("content-length")) > maxBytes) { + return null + } + if (!response.body) return new ArrayBuffer(0) + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + // Not awaited: a copy of the body that Next.js keeps (its fetch + // dedupe) can hold the cancel back until it is read + reader.cancel().catch(() => {}) + return null + } + chunks.push(value) + } + return new Blob(chunks as BlobPart[]).arrayBuffer() +} diff --git a/lib/server-model-config.ts b/lib/server-model-config.ts index f00d1485..6b5220f0 100644 --- a/lib/server-model-config.ts +++ b/lib/server-model-config.ts @@ -47,11 +47,14 @@ export interface FlattenedServerModel { /** * Convert provider name to URL-safe slug for use in model ID - * e.g., "OpenAI Production" → "openai-production" + * e.g., "OpenAI Production" → "openai-production", "主力" → "4e3b-529b" + * Non-ASCII characters become their hex code point so CJK names stay + * distinct; the id is sent in HTTP headers, which must be ASCII. */ -function slugify(name: string): string { +export function slugify(name: string): string { return name .toLowerCase() + .replace(/[^\p{ASCII}]/gu, (c) => `-${c.codePointAt(0)?.toString(16)}-`) .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") } @@ -189,6 +192,7 @@ export async function loadFlattenedServerModels(): Promise< const defaultModelId = process.env.AI_MODEL const flattened: FlattenedServerModel[] = [] + const seenIds = new Set() for (const p of cfg.providers) { const providerLabel = @@ -199,6 +203,16 @@ export async function loadFlattenedServerModels(): Promise< for (const modelId of p.models) { const id = `server:${nameSlug}:${modelId}` + // Names that differ only in case or punctuation share a slug. + // A repeated id would always resolve to the first provider's + // credentials, so drop it instead. + if (seenIds.has(id)) { + console.warn( + `[server-model-config] Skipping duplicate model id "${id}". Provider names must differ in letters or digits.`, + ) + continue + } + seenIds.add(id) // Default model priority: // 1. From ai-models.json: first model of provider with default: true diff --git a/lib/session-storage.ts b/lib/session-storage.ts index 8450375c..33098367 100644 --- a/lib/session-storage.ts +++ b/lib/session-storage.ts @@ -1,5 +1,6 @@ import { type DBSchema, type IDBPDatabase, openDB } from "idb" import { nanoid } from "nanoid" +import { toast } from "sonner" import type { Template } from "./template-storage" // Constants @@ -61,6 +62,7 @@ let dbPromise: Promise> | null = null async function getDB(): Promise> { if (!dbPromise) { + // A failed or lost connection is not cached: the next call reopens it dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db, oldVersion) { if (oldVersion < 1) { @@ -88,6 +90,28 @@ async function getDB(): Promise> { } } }, + blocked() { + // An older tab keeps the DB open, so the upgrade has to wait + toast.warning( + "Please close other tabs of this app to finish updating chat storage.", + { id: "idb-upgrade-blocked", duration: 10000 }, + ) + }, + blocking(_currentVersion, _blockedVersion, event) { + // Another tab needs to upgrade the DB: close our connection so + // it is not stuck, and reopen on the next call + const db = event.target as IDBDatabase + db.close() + dbPromise = null + }, + terminated() { + // The browser closed the connection (e.g. Safari after a long + // time in the background) + dbPromise = null + }, + }).catch((error) => { + dbPromise = null + throw error }) } return dbPromise @@ -145,36 +169,22 @@ export async function getSession(id: string): Promise { } } +// Returns false on failure (e.g. storage quota exceeded). Other sessions are +// never deleted automatically; the caller tells the user instead. export async function saveSession(session: ChatSession): Promise { if (!isIndexedDBAvailable()) return false try { const db = await getDB() await db.put(STORE_NAME, session) + // The desktop app opens this port (this origin's chats) next launch + window.electronAPI?.chatSaved?.().catch(() => {}) return true } catch (error) { - // Handle quota exceeded - if ( - error instanceof DOMException && - error.name === "QuotaExceededError" - ) { - console.warn("Storage quota exceeded, deleting oldest session...") - await deleteOldestSession() - // Retry once - try { - const db = await getDB() - await db.put(STORE_NAME, session) - return true - } catch (retryError) { - console.error( - "Failed to save session after cleanup:", - retryError, - ) - return false - } - } else { - console.error("Failed to save session:", error) - return false - } + console.error("Failed to save session:", error) + // Reopen the connection next time in case it was lost (Safari reports + // "Connection to Indexed Database server lost" without closing it) + dbPromise = null + return false } } @@ -189,13 +199,18 @@ export async function deleteSession(id: string): Promise { } export async function getSessionCount(): Promise { - if (!isIndexedDBAvailable()) return 0 + return (await readSessionCount()) ?? 0 +} + +/** The number of saved chats, or null when it could not be read */ +export async function readSessionCount(): Promise { + if (!isIndexedDBAvailable()) return null try { const db = await getDB() return await db.count(STORE_NAME) } catch (error) { console.error("Failed to get session count:", error) - return 0 + return null } } diff --git a/lib/ssrf-protection.ts b/lib/ssrf-protection.ts index 6b43ddf2..20f2e191 100644 --- a/lib/ssrf-protection.ts +++ b/lib/ssrf-protection.ts @@ -115,3 +115,87 @@ export async function isPrivateUrl(urlString: string): Promise { export function allowPrivateUrls(): boolean { return process.env.ALLOW_PRIVATE_URLS !== "false" } + +/** A redirect the guard below refused; its text is safe to show */ +export class RedirectRefusedError extends Error { + constructor(message = "Redirects are not allowed for custom base URLs") { + super(message) + this.name = "RedirectRefusedError" + } +} + +const MAX_REDIRECTS = 5 + +// Dropped when a redirect goes to another origin: those fetch drops, and +// the key headers of providers that do not use Authorization (Anthropic, +// Google, Azure) +const CREDENTIAL_HEADERS = [ + "authorization", + "proxy-authorization", + "cookie", + "x-api-key", + "x-goog-api-key", + "api-key", +] + +/** + * A fetch for requests to a base URL the client chose. With private URLs + * blocked, a public URL could still redirect the request to an internal + * host, so redirects are refused. With private URLs allowed but the quota + * on (DYNAMODB_QUOTA_TABLE), a request to a private address counts as the + * server's: a public URL's redirects are followed only to public addresses, + * or it could reach the server's own network uncounted. Undefined otherwise. + */ +export function redirectGuardedFetch(): typeof fetch | undefined { + const blockAll = !allowPrivateUrls() + if (!blockAll && !process.env.DYNAMODB_QUOTA_TABLE) return undefined + return async (input, init) => { + let url = input instanceof Request ? input.url : String(input) + let next = init + // A request to a private address already counts as the server's + let startsPrivate: boolean | undefined + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const response = await fetch(url, { ...next, redirect: "manual" }) + const location = response.headers.get("location") + if (response.status < 300 || response.status >= 400 || !location) { + return response + } + if (blockAll) throw new RedirectRefusedError() + startsPrivate ??= await isPrivateUrl(url) + const from = new URL(url) + url = new URL(location, url).toString() + if (!startsPrivate && (await isPrivateUrl(url))) { + throw new RedirectRefusedError( + "Redirects to private addresses are not allowed", + ) + } + // The rest as fetch itself does it. Another origin gets no + // credentials (the user's key, EdgeOne's cookies) + const headers = new Headers(next?.headers) + if (new URL(url).origin !== from.origin) { + for (const name of CREDENTIAL_HEADERS) headers.delete(name) + } + next = { ...next, headers } + // 303, and 301 or 302 after a POST, go on as a GET without the + // body + const method = (next.method ?? "GET").toUpperCase() + if ( + response.status === 303 || + ((response.status === 301 || response.status === 302) && + method === "POST") + ) { + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "content-language", + "content-location", + ]) { + headers.delete(name) + } + next = { ...next, method: "GET", body: undefined } + } + } + throw new RedirectRefusedError("Too many redirects") + } +} diff --git a/lib/system-prompts.ts b/lib/system-prompts.ts index aafa52b4..f033bd61 100644 --- a/lib/system-prompts.ts +++ b/lib/system-prompts.ts @@ -1,11 +1,14 @@ /** * System prompts for different AI models * Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5) - * - * Token counting utilities are in a separate file (token-counter.ts) to avoid - * WebAssembly issues with Next.js server-side rendering. */ +import { + SWIMLANE_EXAMPLE, + TWO_EDGES_EXAMPLE, + WAYPOINT_EXAMPLE, +} from "@/packages/mcp-server/src/xml-examples.ts" + // Default system prompt (~1900 tokens) - works with all models export const DEFAULT_SYSTEM_PROMPT = ` You are an expert diagram creation assistant specializing in draw.io XML generation. @@ -41,7 +44,7 @@ parameters: { tool name: edit_diagram description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram. parameters: { - edits: Array<{search: string, replace: string}> + operations: Array<{operation: "update" | "add" | "delete", cell_id: string, new_xml?: string}> } ---Tool3--- tool name: append_diagram @@ -241,21 +244,7 @@ const EXTENDED_ADDITIONS = ` **Example with swimlanes and edges** (generate ONLY this - no wrapper tags): \`\`\`xml - - - - - - - - - - - - - - - +${SWIMLANE_EXAMPLE} \`\`\` ### append_diagram Details @@ -318,12 +307,7 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d ### Two edges between same nodes (CORRECT - no overlap): \`\`\`xml - - - - - - +${TWO_EDGES_EXAMPLE} \`\`\` ### Edge with single waypoint (simple detour): @@ -342,14 +326,7 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d **WRONG:** Direct diagonal line crosses over Develop **CORRECT:** Route around the OUTSIDE (go right first, then up) \`\`\`xml - - - - - - - - +${WAYPOINT_EXAMPLE} \`\`\` This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side. diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index a1d9603a..d8acad4f 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -32,6 +32,8 @@ export interface ModelConfig { modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5" validated?: boolean // Has this model been validated validationError?: string // Error message if validation failed + validationWarning?: string // Passed, but e.g. did not call a tool + responseTime?: number // Milliseconds the last test took } // Provider configuration @@ -120,22 +122,45 @@ export const PROVIDER_LOGO_MAP: Record = { atlascloud: "openai", } -// Provider metadata +/** How a provider lists its models (see lib/provider-models.ts) */ +export type ModelListStyle = + | "openai" + | "anthropic" + | "google" + | "ollama" + | "openrouter" + | "aihubmix" + | "gateway" + +// Provider metadata. apiKeyUrl is the page where users create a key. +// modelList is missing where a key alone cannot list the models (Bedrock, +// Vertex, Azure) or the list is not reliable (Doubao, MiniMax). export const PROVIDER_INFO: Record< ProviderName, - { label: string; defaultBaseUrl?: string } + { + label: string + defaultBaseUrl?: string + apiKeyUrl?: string + modelList?: ModelListStyle + } > = { openai: { label: "OpenAI", defaultBaseUrl: "https://api.openai.com/v1", + apiKeyUrl: "https://platform.openai.com/api-keys", + modelList: "openai", }, anthropic: { label: "Anthropic", defaultBaseUrl: "https://api.anthropic.com/v1", + apiKeyUrl: "https://platform.claude.com/settings/keys", + modelList: "anthropic", }, google: { label: "Google", defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", + apiKeyUrl: "https://aistudio.google.com/apikey", + modelList: "google", }, vertexai: { label: "Google Vertex AI" }, azure: { @@ -146,77 +171,114 @@ export const PROVIDER_INFO: Record< ollama: { label: "Ollama", defaultBaseUrl: "https://ollama.com/api", + apiKeyUrl: "https://ollama.com/settings/keys", + modelList: "ollama", }, openrouter: { label: "OpenRouter", defaultBaseUrl: "https://openrouter.ai/api/v1", + apiKeyUrl: "https://openrouter.ai/keys", + modelList: "openrouter", }, aihubmix: { label: "AIHubMix", defaultBaseUrl: "https://aihubmix.com/v1", + apiKeyUrl: "https://aihubmix.com/token", + modelList: "aihubmix", }, deepseek: { label: "DeepSeek", defaultBaseUrl: "https://api.deepseek.com/v1", + apiKeyUrl: "https://platform.deepseek.com/api_keys", + modelList: "openai", }, siliconflow: { label: "SiliconFlow", defaultBaseUrl: "https://api.siliconflow.cn/v1", + apiKeyUrl: "https://cloud.siliconflow.cn/account/ak", + modelList: "openai", }, sglang: { label: "SGLang", defaultBaseUrl: "http://127.0.0.1:8000/v1", + modelList: "openai", }, gateway: { label: "AI Gateway", defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", + apiKeyUrl: "https://vercel.com/ai-gateway", + modelList: "gateway", }, edgeone: { label: "EdgeOne Pages" }, doubao: { label: "Doubao (ByteDance)", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", + apiKeyUrl: + "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", }, modelscope: { label: "ModelScope", defaultBaseUrl: "https://api-inference.modelscope.cn/v1", + apiKeyUrl: "https://modelscope.cn/my/myaccesstoken", + modelList: "openai", }, glm: { label: "GLM (Zhipu)", defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", + apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", + modelList: "openai", }, qwen: { label: "Qwen (Alibaba)", defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key", + modelList: "openai", }, qiniu: { label: "Qiniu", defaultBaseUrl: "https://api.qnaigc.com/v1", + apiKeyUrl: "https://www.qiniu.com/ai/models", + modelList: "openai", }, kimi: { label: "Kimi (Moonshot)", defaultBaseUrl: "https://api.moonshot.cn/v1", + apiKeyUrl: "https://platform.moonshot.cn/console/api-keys", + modelList: "openai", }, minimax: { label: "MiniMax", defaultBaseUrl: "https://api.minimaxi.com/anthropic", + apiKeyUrl: + "https://platform.minimaxi.com/user-center/basic-information/interface-key", }, novita: { label: "Novita AI", defaultBaseUrl: "https://api.novita.ai/openai", + apiKeyUrl: "https://novita.ai/dashboard/key", + modelList: "openai", }, mimo: { label: "MiMo (Xiaomi)", defaultBaseUrl: "https://api.xiaomimimo.com/v1", + apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys", + modelList: "openai", }, atlascloud: { label: "Atlas Cloud", defaultBaseUrl: "https://api.atlascloud.ai/v1", + apiKeyUrl: "https://www.atlascloud.ai/console/api-keys", + modelList: "openai", }, } // Suggested models per provider for quick add export const SUGGESTED_MODELS: Partial> = { openai: [ + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-6-astra", "gpt-5.5-pro", "gpt-5.5", "gpt-5.4-pro", @@ -230,7 +292,13 @@ export const SUGGESTED_MODELS: Partial> = { "gpt-4o-mini", ], anthropic: [ - // Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs) + // Claude 5 series (latest) + "claude-opus-5-5", + "claude-sonnet-5-5", + "claude-fable-5-1", + "claude-opus-5", + "claude-sonnet-5", + // Claude 4.8 / 4.7 / 4.6 series (dateless pinned IDs) "claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5", @@ -279,39 +347,47 @@ export const SUGGESTED_MODELS: Partial> = { "o3", "o4-mini", ], + // Newer models only answer through an inference profile id (the region + // prefix). Each id here was called once on 2026-10-04. bedrock: [ - // Anthropic Claude - "anthropic.claude-opus-4-8", - "anthropic.claude-opus-4-7", - "anthropic.claude-sonnet-4-6", - "anthropic.claude-opus-4-6-v1", - "anthropic.claude-opus-4-5-20251101-v1:0", - "anthropic.claude-sonnet-4-5-20250929-v1:0", - "anthropic.claude-haiku-4-5-20251001-v1:0", - "anthropic.claude-opus-4-1-20250805-v1:0", - "anthropic.claude-opus-4-20250514-v1:0", - "anthropic.claude-sonnet-4-20250514-v1:0", - "anthropic.claude-3-5-haiku-20241022-v1:0", + // Anthropic Claude ("global." works from any region) + "global.anthropic.claude-opus-5-5", + "global.anthropic.claude-sonnet-5-5", + "global.anthropic.claude-fable-5-1", + "global.anthropic.claude-opus-5", + "global.anthropic.claude-sonnet-5", + "global.anthropic.claude-opus-4-8", + "global.anthropic.claude-opus-4-7", + "global.anthropic.claude-sonnet-4-6", + "global.anthropic.claude-opus-4-6-v1", + "global.anthropic.claude-opus-4-5-20251101-v1:0", + "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "global.anthropic.claude-sonnet-4-20250514-v1:0", // Amazon Nova - "amazon.nova-2-lite-v1:0", - "amazon.nova-premier-v1:0", + "us.amazon.nova-2-lite-v1:0", "amazon.nova-pro-v1:0", "amazon.nova-lite-v1:0", "amazon.nova-micro-v1:0", // Meta Llama - "meta.llama4-maverick-17b-instruct-v1:0", - "meta.llama4-scout-17b-instruct-v1:0", - "meta.llama3-3-70b-instruct-v1:0", + "us.meta.llama4-maverick-17b-instruct-v1:0", + "us.meta.llama4-scout-17b-instruct-v1:0", + "us.meta.llama3-3-70b-instruct-v1:0", // Mistral "mistral.mistral-large-3-675b-instruct", - "mistral.pixtral-large-2502-v1:0", + "us.mistral.pixtral-large-2502-v1:0", ], openrouter: [ // Anthropic + "anthropic/claude-opus-5.5", + "anthropic/claude-sonnet-5.5", + "anthropic/claude-fable-5.1", "anthropic/claude-opus-4.8", "anthropic/claude-sonnet-4.6", "anthropic/claude-haiku-4.5", // OpenAI + "openai/gpt-6.1-sol", + "openai/gpt-6-luna", "openai/gpt-5.5", "openai/gpt-5.4", "openai/gpt-5.4-mini", @@ -400,6 +476,10 @@ export const SUGGESTED_MODELS: Partial> = { "default", ], gateway: [ + "anthropic/claude-opus-5.5", + "anthropic/claude-sonnet-5.5", + "openai/gpt-6.1-sol", + "openai/gpt-6-luna", "openai/gpt-5.5", "anthropic/claude-opus-4.7", "google/gemini-3.1-pro-preview", @@ -529,3 +609,38 @@ export function findModelById( ): FlattenedModel | undefined { return flattenModels(config).find((m) => m.id === modelId) } + +/** + * A base URL the way the SDKs expect it: no spaces, no trailing slash, and + * no endpoint path users often paste along (".../v1/chat/completions"), + * which the SDK would append a second time. + */ +export function normalizeBaseUrl(url: string): string { + return url + .trim() + .replace(/\/+$/, "") + .replace(/\/(?:chat\/completions|completions|messages|responses)$/, "") +} + +/** Where a chat request goes for a base URL, or null when the SDK decides */ +export function chatRequestUrl( + provider: ProviderName, + baseUrl: string, +): string | null { + const url = normalizeBaseUrl(baseUrl) + if (!url) return null + if (provider === "anthropic") return `${url}/messages` + // These SDKs build their own paths (or, for MiniMax, pick the protocol + // from the URL) + const ownPaths: ProviderName[] = [ + "google", + "vertexai", + "azure", + "bedrock", + "ollama", + "gateway", + "minimax", + "edgeone", + ] + return ownPaths.includes(provider) ? null : `${url}/chat/completions` +} diff --git a/lib/url-utils.ts b/lib/url-utils.ts index e0f0010b..d0b774f4 100644 --- a/lib/url-utils.ts +++ b/lib/url-utils.ts @@ -1,5 +1,6 @@ import { z } from "zod" import { getApiEndpoint } from "@/lib/base-path" +import { STORAGE_KEYS } from "@/lib/storage" export interface UrlData { url: string @@ -18,7 +19,11 @@ const UrlResponseSchema = z.object({ export async function extractUrlContent(url: string): Promise { const response = await fetch(getApiEndpoint("/api/parse-url"), { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "x-access-code": + localStorage.getItem(STORAGE_KEYS.accessCode) || "", + }, body: JSON.stringify({ url }), }) diff --git a/lib/use-file-processor.tsx b/lib/use-file-processor.tsx index 5b2622ce..3f86d076 100644 --- a/lib/use-file-processor.tsx +++ b/lib/use-file-processor.tsx @@ -27,78 +27,72 @@ export function useFileProcessor() { const handleFileChange = async (newFiles: File[]) => { setFiles(newFiles) - // Extract text immediately for new PDF/text files - for (const file of newFiles) { - const needsExtraction = - (isPdfFile(file) || isTextFile(file)) && !pdfData.has(file) - if (needsExtraction) { - // Mark as extracting - setPdfData((prev) => { - const next = new Map(prev) - next.set(file, { - text: "", - charCount: 0, - isExtracting: true, - }) - return next - }) + const pending = newFiles.filter( + (file) => + (isPdfFile(file) || isTextFile(file)) && !pdfData.has(file), + ) - // Extract text asynchronously - try { - let text: string - if (isPdfFile(file)) { - text = await extractPdfText(file) - } else { - text = await extractTextFileContent(file) - } + // Before any await: drop data for removed files and mark every new + // file as extracting, so queued files also block sending + setPdfData((prev) => { + const next = new Map() + for (const file of newFiles) { + const existing = prev.get(file) + if (existing) next.set(file, existing) + } + for (const file of pending) { + next.set(file, { text: "", charCount: 0, isExtracting: true }) + } + return next + }) - // Check character limit - if (text.length > MAX_EXTRACTED_CHARS) { - const limitK = MAX_EXTRACTED_CHARS / 1000 - toast.error( - `${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`, - ) - setPdfData((prev) => { - const next = new Map(prev) - next.delete(file) - return next - }) - // Remove the file from the list - setFiles((prev) => prev.filter((f) => f !== file)) - continue - } + // Extract one file at a time + for (const file of pending) { + try { + let text: string + if (isPdfFile(file)) { + text = await extractPdfText(file) + } else { + text = await extractTextFileContent(file) + } - setPdfData((prev) => { - const next = new Map(prev) - next.set(file, { - text, - charCount: text.length, - isExtracting: false, - }) - return next - }) - } catch (error) { - console.error("Failed to extract text:", error) - toast.error(`Failed to read file: ${file.name}`) + // Check character limit + if (text.length > MAX_EXTRACTED_CHARS) { + const limitK = MAX_EXTRACTED_CHARS / 1000 + toast.error( + `${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`, + ) setPdfData((prev) => { const next = new Map(prev) next.delete(file) return next }) + // Remove the file from the list + setFiles((prev) => prev.filter((f) => f !== file)) + continue } + + setPdfData((prev) => { + // The file was removed while extracting + if (!prev.has(file)) return prev + const next = new Map(prev) + next.set(file, { + text, + charCount: text.length, + isExtracting: false, + }) + return next + }) + } catch (error) { + console.error("Failed to extract text:", error) + toast.error(`Failed to read file: ${file.name}`) + setPdfData((prev) => { + const next = new Map(prev) + next.delete(file) + return next + }) } } - - // Clean up pdfData for removed files - setPdfData((prev) => { - const next = new Map(prev) - for (const key of prev.keys()) { - if (!newFiles.includes(key)) { - next.delete(key) - } - } - return next - }) } return { diff --git a/lib/utils.ts b/lib/utils.ts index 2c1f8b43..a4749b92 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,9 +1,7 @@ import { type ClassValue, clsx } from "clsx" import * as pako from "pako" import { twMerge } from "tailwind-merge" -import type { DiagramOperation } from "@/components/chat/types" - -export type { DiagramOperation } +import { hasCells } from "@/packages/mcp-server/src/pages.ts" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -20,37 +18,16 @@ export function cn(...inputs: ClassValue[]) { export const MIN_REAL_DIAGRAM_LENGTH = 300 /** - * Check if diagram XML represents a real diagram (not just empty template). + * Check if diagram XML represents a real diagram (not just empty template): + * it has a shape (however short), or is long enough to hold pages worth + * keeping. * @param xml - The diagram XML string to check * @returns true if the XML is a real diagram with content */ export function isRealDiagram(xml: string | undefined | null): boolean { - return !!xml && xml.length > MIN_REAL_DIAGRAM_LENGTH + return !!xml && (hasCells(xml) || xml.length > MIN_REAL_DIAGRAM_LENGTH) } -// ============================================================================ -// XML Validation/Fix Constants -// ============================================================================ - -/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */ -const MAX_XML_SIZE = 1_000_000 - -/** Maximum iterations for aggressive cell dropping to prevent infinite loops */ -const MAX_DROP_ITERATIONS = 10 - -/** Structural attributes that should not be duplicated in draw.io */ -const STRUCTURAL_ATTRS = [ - "edge", - "parent", - "source", - "target", - "vertex", - "connectable", -] - -/** Valid XML entity names */ -const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"]) - // ============================================================================ // mxCell XML Helpers // ============================================================================ @@ -76,6 +53,20 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean { // No valid ending found at all if (lastValidEnd === -1) return false + // If the last mxCell has no after it, it must be self-closing. + // Otherwise the trailing "/>" belongs to a child such as + // and the output was cut off before the cell was closed. + const lastCellStart = trimmed.lastIndexOf(" lastMxCellClose && + // (quoted values may hold a raw "<", which the auto-fix escapes) + !/^"']|"[^"]*"|'[^']*')*\/>/.test( + trimmed.slice(lastCellStart), + ) + ) { + return false + } + // Check what comes after the last valid ending // For />: add 2 chars, for : add 9 chars const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 @@ -95,102 +86,12 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean { export function extractCompleteMxCells(xml: string | undefined | null): string { if (!xml) return "" - const completeCells: Array<{ index: number; text: string }> = [] + // Match self-closing or ..., in document order. + // The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows + // the following cells up to the next . + const cellPattern = /]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g - // Match self-closing mxCell tags: - // Also match mxCell with nested mxGeometry: ... - const selfClosingPattern = /]*\/>/g - const nestedPattern = /]*>[\s\S]*?<\/mxCell>/g - - // Find all self-closing mxCell elements - let match: RegExpExecArray | null - while ((match = selfClosingPattern.exec(xml)) !== null) { - completeCells.push({ index: match.index, text: match[0] }) - } - - // Find all mxCell elements with nested content (like mxGeometry) - while ((match = nestedPattern.exec(xml)) !== null) { - completeCells.push({ index: match.index, text: match[0] }) - } - - // Sort by position to maintain order - completeCells.sort((a, b) => a.index - b.index) - - // Remove duplicates (a self-closing match might overlap with nested match) - const seen = new Set() - const uniqueCells = completeCells.filter((cell) => { - if (seen.has(cell.index)) return false - seen.add(cell.index) - return true - }) - - return uniqueCells.map((c) => c.text).join("\n") -} - -// ============================================================================ -// XML Parsing Helpers -// ============================================================================ - -interface ParsedTag { - tag: string - tagName: string - isClosing: boolean - isSelfClosing: boolean - startIndex: number - endIndex: number -} - -/** - * Parse XML tags while properly handling quoted strings - * This is a shared utility used by both validation and fixing logic - */ -function parseXmlTags(xml: string): ParsedTag[] { - const tags: ParsedTag[] = [] - let i = 0 - - while (i < xml.length) { - const tagStart = xml.indexOf("<", i) - if (tagStart === -1) break - - // Find matching > by tracking quotes - let tagEnd = tagStart + 1 - let inQuote = false - let quoteChar = "" - - while (tagEnd < xml.length) { - const c = xml[tagEnd] - if (inQuote) { - if (c === quoteChar) inQuote = false - } else { - if (c === '"' || c === "'") { - inQuote = true - quoteChar = c - } else if (c === ">") { - break - } - } - tagEnd++ - } - - if (tagEnd >= xml.length) break - - const tag = xml.substring(tagStart, tagEnd + 1) - i = tagEnd + 1 - - const tagMatch = /^<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)/.exec(tag) - if (!tagMatch) continue - - tags.push({ - tag, - tagName: tagMatch[2], - isClosing: tagMatch[1] === "/", - isSelfClosing: tag.endsWith("/>"), - startIndex: tagStart, - endIndex: tagEnd, - }) - } - - return tags + return (xml.match(cellPattern) || []).join("\n") } /** @@ -315,61 +216,6 @@ export function convertToLegalXml(xmlString: string): string { return result } -/** - * Wrap XML content with the full mxfile structure required by draw.io. - * Always adds root cells (id="0" and id="1") automatically. - * If input already contains root cells, they are removed to avoid duplication. - * LLM should only generate mxCell elements starting from id="2". - * @param xml - The XML string (bare mxCells, , , or full ) - * @returns Full mxfile-wrapped XML string with root cells included - */ -export function wrapWithMxFile(xml: string): string { - const ROOT_CELLS = '' - - if (!xml || !xml.trim()) { - return `${ROOT_CELLS}` - } - - // Already has full structure - if (xml.includes("${xml}` - } - - // Has wrapper - extract inner content - let content = xml - if (xml.includes("")) { - content = xml.replace(/<\/?root>/g, "").trim() - } - - // Strip trailing LLM wrapper tags (from any provider: Anthropic, DeepSeek, etc.) - // Find the last valid mxCell ending and remove everything after it - const lastSelfClose = content.lastIndexOf("/>") - const lastMxCellClose = content.lastIndexOf("") - const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) - if (lastValidEnd !== -1) { - const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 - const suffix = content.slice(lastValidEnd + endOffset) - // If suffix is only closing tags (wrapper tags), strip it - if (/^(\s*<\/[^>]+>)*\s*$/.test(suffix)) { - content = content.slice(0, lastValidEnd + endOffset) - } - } - - // Remove any existing root cells from content (LLM shouldn't include them, but handle it gracefully) - // Use flexible patterns that match both self-closing (/>) and non-self-closing (>) formats - content = content - .replace(/]*\bid=["']0["'][^>]*(?:\/>|><\/mxCell>)/g, "") - .replace(/]*\bid=["']1["'][^>]*(?:\/>|><\/mxCell>)/g, "") - .trim() - - return `${ROOT_CELLS}${content}` -} - /** * Replace nodes in a Draw.io XML diagram * @param currentXML - The original Draw.io XML string @@ -472,1207 +318,11 @@ export function replaceNodes(currentXML: string, nodes: string): string { } } -// ============================================================================ -// ID-based Diagram Operations -// ============================================================================ - -export interface OperationError { - type: "update" | "add" | "delete" - cellId: string - message: string -} - -export interface ApplyOperationsResult { - result: string - errors: OperationError[] -} - /** - * Apply diagram operations (update/add/delete) using ID-based lookup. - * This replaces the text-matching approach with direct DOM manipulation. - * - * @param xmlContent - The full mxfile XML content - * @param operations - Array of operations to apply - * @returns Object with result XML and any errors + * Decode an xmlsvg export (SVG data URL) into uncompressed diagram XML. + * Only the first page is returned; for the full multi-page document use the + * autosaved chartXML instead. */ -export function applyDiagramOperations( - xmlContent: string, - operations: DiagramOperation[], -): ApplyOperationsResult { - const errors: OperationError[] = [] - - // Parse the XML - const parser = new DOMParser() - const doc = parser.parseFromString(xmlContent, "text/xml") - - // Check for parse errors - const parseError = doc.querySelector("parsererror") - if (parseError) { - return { - result: xmlContent, - errors: [ - { - type: "update", - cellId: "", - message: `XML parse error: ${parseError.textContent}`, - }, - ], - } - } - - // Find the root element (inside mxGraphModel) - const root = doc.querySelector("root") - if (!root) { - return { - result: xmlContent, - errors: [ - { - type: "update", - cellId: "", - message: "Could not find element in XML", - }, - ], - } - } - - // Build a map of cell IDs to elements - const cellMap = new Map() - root.querySelectorAll("mxCell").forEach((cell) => { - const id = cell.getAttribute("id") - if (id) cellMap.set(id, cell) - }) - - // Process each operation - for (const op of operations) { - if (op.operation === "update") { - const existingCell = cellMap.get(op.cell_id) - if (!existingCell) { - errors.push({ - type: "update", - cellId: op.cell_id, - message: `Cell with id="${op.cell_id}" not found`, - }) - continue - } - - if (!op.new_xml) { - errors.push({ - type: "update", - cellId: op.cell_id, - message: "new_xml is required for update operation", - }) - continue - } - - // Parse the new XML - const newDoc = parser.parseFromString( - `${op.new_xml}`, - "text/xml", - ) - const newCell = newDoc.querySelector("mxCell") - if (!newCell) { - errors.push({ - type: "update", - cellId: op.cell_id, - message: "new_xml must contain an mxCell element", - }) - continue - } - - // Validate ID matches - const newCellId = newCell.getAttribute("id") - if (newCellId !== op.cell_id) { - errors.push({ - type: "update", - cellId: op.cell_id, - message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, - }) - continue - } - - // Import and replace the node - const importedNode = doc.importNode(newCell, true) - existingCell.parentNode?.replaceChild(importedNode, existingCell) - - // Update the map with the new element - cellMap.set(op.cell_id, importedNode) - } else if (op.operation === "add") { - // Check if ID already exists - if (cellMap.has(op.cell_id)) { - errors.push({ - type: "add", - cellId: op.cell_id, - message: `Cell with id="${op.cell_id}" already exists`, - }) - continue - } - - if (!op.new_xml) { - errors.push({ - type: "add", - cellId: op.cell_id, - message: "new_xml is required for add operation", - }) - continue - } - - // Parse the new XML - const newDoc = parser.parseFromString( - `${op.new_xml}`, - "text/xml", - ) - const newCell = newDoc.querySelector("mxCell") - if (!newCell) { - errors.push({ - type: "add", - cellId: op.cell_id, - message: "new_xml must contain an mxCell element", - }) - continue - } - - // Validate ID matches - const newCellId = newCell.getAttribute("id") - if (newCellId !== op.cell_id) { - errors.push({ - type: "add", - cellId: op.cell_id, - message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, - }) - continue - } - - // Import and append the node - const importedNode = doc.importNode(newCell, true) - root.appendChild(importedNode) - - // Add to map - cellMap.set(op.cell_id, importedNode) - } else if (op.operation === "delete") { - // Protect root cells from deletion - if (op.cell_id === "0" || op.cell_id === "1") { - errors.push({ - type: "delete", - cellId: op.cell_id, - message: `Cannot delete root cell "${op.cell_id}"`, - }) - continue - } - - const existingCell = cellMap.get(op.cell_id) - if (!existingCell) { - // Cell not found - might have been cascade-deleted by a previous operation - // Skip silently instead of erroring (AI may redundantly list children/edges) - continue - } - - // Cascade delete: collect all cells to delete (children + edges + self) - const cellsToDelete = new Set() - - // Recursive function to find all descendants - const collectDescendants = (cellId: string) => { - if (cellsToDelete.has(cellId)) return - cellsToDelete.add(cellId) - - // Find children (cells where parent === cellId) - const children = root.querySelectorAll( - `mxCell[parent="${cellId}"]`, - ) - children.forEach((child) => { - const childId = child.getAttribute("id") - if (childId && childId !== "0" && childId !== "1") { - collectDescendants(childId) - } - }) - } - - // Collect the target cell and all its descendants - collectDescendants(op.cell_id) - - // Find edges referencing any of the cells to be deleted - // Also recursively collect children of those edges (e.g., edge labels) - for (const cellId of cellsToDelete) { - const referencingEdges = root.querySelectorAll( - `mxCell[source="${cellId}"], mxCell[target="${cellId}"]`, - ) - referencingEdges.forEach((edge) => { - const edgeId = edge.getAttribute("id") - // Protect root cells from being added via edge references - if (edgeId && edgeId !== "0" && edgeId !== "1") { - // Recurse to collect edge's children (like labels) - collectDescendants(edgeId) - } - }) - } - - // Log what will be deleted - if (cellsToDelete.size > 1) { - console.log( - `[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`, - ) - } - - // Delete all collected cells - for (const cellId of cellsToDelete) { - const cell = cellMap.get(cellId) - if (cell) { - cell.parentNode?.removeChild(cell) - cellMap.delete(cellId) - } - } - } - } - - // Serialize back to string - const serializer = new XMLSerializer() - const result = serializer.serializeToString(doc) - - return { result, errors } -} - -// ============================================================================ -// Validation Helper Functions -// ============================================================================ - -/** Check for duplicate structural attributes in a tag */ -function checkDuplicateAttributes(xml: string): string | null { - const structuralSet = new Set(STRUCTURAL_ATTRS) - const tagPattern = /<[^>]+>/g - let tagMatch - while ((tagMatch = tagPattern.exec(xml)) !== null) { - const tag = tagMatch[0] - const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g - const attributes = new Map() - let attrMatch - while ((attrMatch = attrPattern.exec(tag)) !== null) { - const attrName = attrMatch[1] - attributes.set(attrName, (attributes.get(attrName) || 0) + 1) - } - const duplicates = Array.from(attributes.entries()) - .filter(([name, count]) => count > 1 && structuralSet.has(name)) - .map(([name]) => name) - if (duplicates.length > 0) { - return `Invalid XML: Duplicate structural attribute(s): ${duplicates.join(", ")}. Remove duplicate attributes.` - } - } - return null -} - -/** Check for duplicate IDs in XML */ -function checkDuplicateIds(xml: string): string | null { - const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi - const ids = new Map() - let idMatch - while ((idMatch = idPattern.exec(xml)) !== null) { - const id = idMatch[1] - ids.set(id, (ids.get(id) || 0) + 1) - } - const duplicateIds = Array.from(ids.entries()) - .filter(([, count]) => count > 1) - .map(([id, count]) => `'${id}' (${count}x)`) - if (duplicateIds.length > 0) { - return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.` - } - return null -} - -/** Check for tag mismatches using parsed tags */ -function checkTagMismatches(xml: string): string | null { - const xmlWithoutComments = xml.replace(//g, "") - const tags = parseXmlTags(xmlWithoutComments) - const tagStack: string[] = [] - - for (const { tagName, isClosing, isSelfClosing } of tags) { - if (isClosing) { - if (tagStack.length === 0) { - return `Invalid XML: Closing tag without matching opening tag` - } - const expected = tagStack.pop() - if (expected?.toLowerCase() !== tagName.toLowerCase()) { - return `Invalid XML: Expected closing tag but found ` - } - } else if (!isSelfClosing) { - tagStack.push(tagName) - } - } - if (tagStack.length > 0) { - return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}` - } - return null -} - -/** Check for invalid character references */ -function checkCharacterReferences(xml: string): string | null { - const charRefPattern = /&#x?[^;]+;?/g - let charMatch - while ((charMatch = charRefPattern.exec(xml)) !== null) { - const ref = charMatch[0] - if (ref.startsWith("&#x")) { - if (!ref.endsWith(";")) { - return `Invalid XML: Missing semicolon after hex reference: ${ref}` - } - const hexDigits = ref.substring(3, ref.length - 1) - if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) { - return `Invalid XML: Invalid hex character reference: ${ref}` - } - } else if (ref.startsWith("&#")) { - if (!ref.endsWith(";")) { - return `Invalid XML: Missing semicolon after decimal reference: ${ref}` - } - const decDigits = ref.substring(2, ref.length - 1) - if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) { - return `Invalid XML: Invalid decimal character reference: ${ref}` - } - } - } - return null -} - -/** Check for invalid entity references */ -function checkEntityReferences(xml: string): string | null { - const xmlWithoutComments = xml.replace(//g, "") - const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g - if (bareAmpPattern.test(xmlWithoutComments)) { - return "Invalid XML: Found unescaped & character(s). Replace & with &" - } - const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g - let entityMatch - while ( - (entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null - ) { - if (!VALID_ENTITIES.has(entityMatch[1])) { - return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)` - } - } - return null -} - -/** Check for nested mxCell tags using regex */ -function checkNestedMxCells(xml: string): string | null { - const cellTagPattern = /<\/?mxCell[^>]*>/g - const cellStack: number[] = [] - let cellMatch - while ((cellMatch = cellTagPattern.exec(xml)) !== null) { - const tag = cellMatch[0] - if (tag.startsWith("")) { - if (cellStack.length > 0) cellStack.pop() - } else if (!tag.endsWith("/>")) { - const isLabelOrGeometry = - /\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag) - if (!isLabelOrGeometry) { - cellStack.push(cellMatch.index) - if (cellStack.length > 1) { - return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements." - } - } - } - } - return null -} - -/** - * Validates draw.io XML structure for common issues - * Uses DOM parsing + additional regex checks for high accuracy - * @param xml - The XML string to validate - * @returns null if valid, error message string if invalid - */ -export function validateMxCellStructure(xml: string): string | null { - // Size check for performance - if (xml.length > MAX_XML_SIZE) { - console.warn( - `[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`, - ) - } - - // 0. First use DOM parser to catch syntax errors (most accurate) - try { - const parser = new DOMParser() - const doc = parser.parseFromString(xml, "text/xml") - const parseError = doc.querySelector("parsererror") - if (parseError) { - return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.` - } - - // DOM-based checks for nested mxCell - const allCells = doc.querySelectorAll("mxCell") - for (const cell of allCells) { - if (cell.parentElement?.tagName === "mxCell") { - const id = cell.getAttribute("id") || "unknown" - return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` - } - } - } catch (error) { - // Log unexpected DOMParser errors before falling back to regex checks - console.warn( - "[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:", - error, - ) - } - - // 1. Check for CDATA wrapper (invalid at document root) - if (/^\s* from end" - } - - // 2. Check for duplicate structural attributes - const dupAttrError = checkDuplicateAttributes(xml) - if (dupAttrError) { - return dupAttrError - } - - // 3. Check for unescaped < in attribute values - const attrValuePattern = /=\s*"([^"]*)"/g - let attrValMatch - while ((attrValMatch = attrValuePattern.exec(xml)) !== null) { - const value = attrValMatch[1] - if (//g - let commentMatch - while ((commentMatch = commentPattern.exec(xml)) !== null) { - if (/--/.test(commentMatch[1])) { - return "Invalid XML: Comment contains -- (double hyphen) which is not allowed" - } - } - - // 8. Check for unescaped entity references and invalid entity names - const entityError = checkEntityReferences(xml) - if (entityError) { - return entityError - } - - // 9. Check for empty id attributes on mxCell - if (/]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) { - return "Invalid XML: Found mxCell element(s) with empty id attribute" - } - - // 10. Check for nested mxCell tags - const nestedCellError = checkNestedMxCells(xml) - if (nestedCellError) { - return nestedCellError - } - - return null -} - -/** - * Attempts to auto-fix common XML issues in draw.io diagrams - * @param xml - The XML string to fix - * @returns Object with fixed XML and list of fixes applied - */ -export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { - let fixed = xml - const fixes: string[] = [] - - // 0. Fix JSON-escaped XML (common when XML is stored in JSON without unescaping) - // Only apply when we see JSON-escaped attribute patterns like =\"value\" - // Don't apply to legitimate \n in value attributes (draw.io uses these for line breaks) - if (/=\\"/.test(fixed)) { - // Replace literal \" with actual quotes - fixed = fixed.replace(/\\"/g, '"') - // Replace literal \n with actual newlines (only after confirming JSON-escaped) - fixed = fixed.replace(/\\n/g, "\n") - fixes.push("Fixed JSON-escaped XML") - } - - // 1. Remove CDATA wrapper (MUST be before text-before-root check) - if (/^\s*\s*$/, "") - fixes.push("Removed CDATA wrapper") - } - - // 1b. Strip trailing LLM wrapper tags (DeepSeek, Anthropic, etc.) - // These are closing tags after the last valid mxCell that break XML parsing - const lastSelfClose = fixed.lastIndexOf("/>") - const lastMxCellClose = fixed.lastIndexOf("") - const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) - if (lastValidEnd !== -1) { - const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 - const suffix = fixed.slice(lastValidEnd + endOffset) - // If suffix contains only closing tags (wrapper tags) or whitespace, strip it - if (/^(\s*<\/[^>]+>)+\s*$/.test(suffix)) { - fixed = fixed.slice(0, lastValidEnd + endOffset) - fixes.push("Stripped trailing LLM wrapper tags") - } - } - - // 2. Remove text before XML declaration or root element (only if it's garbage text, not valid XML) - const xmlStart = fixed.search(/<(\?xml|mxGraphModel|mxfile)/i) - if (xmlStart > 0 && !/^<[a-zA-Z]/.test(fixed.trim())) { - fixed = fixed.substring(xmlStart) - fixes.push("Removed text before XML root") - } - - // 2. Fix duplicate attributes (keep first occurrence, remove duplicates) - let dupAttrFixed = false - fixed = fixed.replace(/<[^>]+>/g, (tag) => { - let newTag = tag - - for (const attr of STRUCTURAL_ATTRS) { - // Find all occurrences of this attribute - const attrRegex = new RegExp( - `\\s${attr}\\s*=\\s*["'][^"']*["']`, - "gi", - ) - const matches = tag.match(attrRegex) - - if (matches && matches.length > 1) { - // Keep first, remove others - let firstKept = false - newTag = newTag.replace(attrRegex, (m) => { - if (!firstKept) { - firstKept = true - return m - } - dupAttrFixed = true - return "" - }) - } - } - return newTag - }) - if (dupAttrFixed) { - fixes.push("Removed duplicate structural attributes") - } - - // 3. Fix unescaped & characters (but not valid entities) - // Match & not followed by valid entity pattern - const ampersandPattern = - /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g - if (ampersandPattern.test(fixed)) { - fixed = fixed.replace( - /&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g, - "&", - ) - fixes.push("Escaped unescaped & characters") - } - - // 3. Fix invalid entity names like &quot; -> " - // Common mistake: double-escaping - const invalidEntities = [ - { pattern: /&quot;/g, replacement: """, name: "&quot;" }, - { pattern: /&lt;/g, replacement: "<", name: "&lt;" }, - { pattern: /&gt;/g, replacement: ">", name: "&gt;" }, - { pattern: /&apos;/g, replacement: "'", name: "&apos;" }, - { pattern: /&amp;/g, replacement: "&", name: "&amp;" }, - ] - for (const { pattern, replacement, name } of invalidEntities) { - if (pattern.test(fixed)) { - fixed = fixed.replace(pattern, replacement) - fixes.push(`Fixed double-escaped entity ${name}`) - } - } - - // 3b. Fix malformed attribute values where " is used as delimiter instead of actual quotes - // Pattern: attr="value" should become attr="value" (the " was meant to be the quote delimiter) - // This commonly happens with dashPattern="1 1;" - const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/ - if (malformedQuotePattern.test(fixed)) { - // Replace =" with =" and trailing " before next attribute or tag end with " - fixed = fixed.replace( - /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g, - '$1="$2"', - ) - fixes.push( - 'Fixed malformed attribute quotes (="..." to ="...")', - ) - } - - // 3c. Fix malformed closing tags like -> - const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g - if (malformedClosingTag.test(fixed)) { - fixed = fixed.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "") - fixes.push("Fixed malformed closing tags ( to )") - } - - // 3d. Fix missing space between attributes like vertex="1"parent="1" - const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g - if (missingSpacePattern.test(fixed)) { - fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2") - fixes.push("Added missing space between attributes") - } - - // 3e. Fix unescaped quotes in style color values like fillColor="#fff2e6" - // The " after Color= prematurely ends the style attribute. Remove it. - // Pattern: ;fillColor="#fff → ;fillColor=#fff (remove first ", keep second as style closer) - const quotedColorPattern = /;([a-zA-Z]*[Cc]olor)="#/ - if (quotedColorPattern.test(fixed)) { - fixed = fixed.replace(/;([a-zA-Z]*[Cc]olor)="#/g, ";$1=#") - fixes.push("Removed quotes around color values in style") - } - - // 4. Fix unescaped < and > in attribute values - // < is required to be escaped, > is not strictly required but we escape for consistency - const attrPattern = /(=\s*")([^"]*?)(<)([^"]*?)(")/g - let attrMatch - let hasUnescapedLt = false - while ((attrMatch = attrPattern.exec(fixed)) !== null) { - if (!attrMatch[3].startsWith("<")) { - hasUnescapedLt = true - break - } - } - if (hasUnescapedLt) { - // Replace < and > with < and > inside attribute values - fixed = fixed.replace(/=\s*"([^"]*)"/g, (_match, value) => { - const escaped = value.replace(//g, ">") - return `="${escaped}"` - }) - fixes.push("Escaped <> characters in attribute values") - } - - // 5. Fix invalid character references (remove malformed ones) - // Pattern: &#x followed by non-hex chars before ; - const invalidHexRefs: string[] = [] - fixed = fixed.replace(/&#x([^;]*);/g, (match, hex) => { - if (/^[0-9a-fA-F]+$/.test(hex) && hex.length > 0) { - return match // Valid hex ref, keep it - } - invalidHexRefs.push(match) - return "" // Remove invalid ref - }) - if (invalidHexRefs.length > 0) { - fixes.push( - `Removed ${invalidHexRefs.length} invalid hex character reference(s)`, - ) - } - - // 6. Fix invalid decimal character references - const invalidDecRefs: string[] = [] - fixed = fixed.replace(/&#([^x][^;]*);/g, (match, dec) => { - if (/^[0-9]+$/.test(dec) && dec.length > 0) { - return match // Valid decimal ref, keep it - } - invalidDecRefs.push(match) - return "" // Remove invalid ref - }) - if (invalidDecRefs.length > 0) { - fixes.push( - `Removed ${invalidDecRefs.length} invalid decimal character reference(s)`, - ) - } - - // 7. Fix invalid comment syntax (replace -- with - repeatedly until none left) - fixed = fixed.replace(//g, (match, content) => { - if (/--/.test(content)) { - // Keep replacing until no double hyphens remain - let fixedContent = content - while (/--/.test(fixedContent)) { - fixedContent = fixedContent.replace(/--/g, "-") - } - fixes.push("Fixed invalid comment syntax (removed double hyphens)") - return `` - } - return match - }) - - // 8. Fix tags that should be (common LLM mistake) - // This handles both opening and closing tags - const hasCellTags = /<\/?Cell[\s>]/i.test(fixed) - if (hasCellTags) { - console.log("[autoFixXml] Step 8: Found tags to fix") - const beforeFix = fixed - fixed = fixed.replace(//gi, "") - fixed = fixed.replace(/<\/Cell>/gi, "") - if (beforeFix !== fixed) { - console.log("[autoFixXml] Step 8: Fixed tags") - } - fixes.push("Fixed tags to ") - } - - // 8b. Fix common closing tag typos (MUST run before foreign tag removal) - const tagTypos = [ - { wrong: /<\/mxElement>/gi, right: "", name: "" }, - { wrong: /<\/mxcell>/g, right: "", name: "" }, // case sensitivity - { - wrong: /<\/mxgeometry>/g, - right: "", - name: "", - }, - { wrong: /<\/mxpoint>/g, right: "", name: "" }, - { - wrong: /<\/mxgraphmodel>/gi, - right: "", - name: "", - }, - ] - for (const { wrong, right, name } of tagTypos) { - const before = fixed - fixed = fixed.replace(wrong, right) - if (fixed !== before) { - fixes.push(`Fixed typo ${name} to ${right}`) - } - } - - // 8c. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first) - // IMPORTANT: Only remove tags at the element level, NOT inside quoted attribute values - // Tags like ,
inside value="text" should be preserved (they're HTML content) - const validDrawioTags = new Set([ - "mxfile", - "diagram", - "mxGraphModel", - "root", - "mxCell", - "mxGeometry", - "mxPoint", - "Array", - "Object", - "mxRectangle", - ]) - - // Helper: Check if a position is inside a quoted attribute value - // by counting unescaped quotes before that position - const isInsideQuotes = (str: string, pos: number): boolean => { - let inQuote = false - let quoteChar = "" - for (let i = 0; i < pos && i < str.length; i++) { - const c = str[i] - if (inQuote) { - if (c === quoteChar) inQuote = false - } else if (c === '"' || c === "'") { - // Check if this quote is part of an attribute (preceded by =) - // Look back for = sign - let j = i - 1 - while (j >= 0 && /\s/.test(str[j])) j-- - if (j >= 0 && str[j] === "=") { - inQuote = true - quoteChar = c - } - } - } - return inQuote - } - - const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g - let foreignMatch - const foreignTags = new Set() - const foreignTagPositions: Array<{ - tag: string - start: number - end: number - }> = [] - - while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) { - const tagName = foreignMatch[1] - // Skip if this is a valid draw.io tag - if (validDrawioTags.has(tagName)) continue - // Skip if this tag is inside a quoted attribute value - if (isInsideQuotes(fixed, foreignMatch.index)) continue - - foreignTags.add(tagName) - foreignTagPositions.push({ - tag: tagName, - start: foreignMatch.index, - end: foreignMatch.index + foreignMatch[0].length, - }) - } - - if (foreignTagPositions.length > 0) { - // Remove tags from end to start to preserve indices - foreignTagPositions.sort((a, b) => b.start - a.start) - for (const { start, end } of foreignTagPositions) { - fixed = fixed.slice(0, start) + fixed.slice(end) - } - fixes.push( - `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, - ) - } - - // 10. Fix unclosed tags by appending missing closing tags - // Use parseXmlTags helper to track open tags - const tagStack: string[] = [] - const parsedTags = parseXmlTags(fixed) - - for (const { tagName, isClosing, isSelfClosing } of parsedTags) { - if (isClosing) { - // Find matching opening tag (may not be the last one if there's mismatch) - const lastIdx = tagStack.lastIndexOf(tagName) - if (lastIdx !== -1) { - tagStack.splice(lastIdx, 1) - } - } else if (!isSelfClosing) { - tagStack.push(tagName) - } - } - - // If there are unclosed tags, append closing tags in reverse order - // But first verify with simple count that they're actually unclosed - if (tagStack.length > 0) { - const tagsToClose: string[] = [] - for (const tagName of tagStack.reverse()) { - // Simple count check: only close if opens > closes - const openCount = ( - fixed.match(new RegExp(`<${tagName}[\\s>]`, "gi")) || [] - ).length - const closeCount = ( - fixed.match(new RegExp(``, "gi")) || [] - ).length - if (openCount > closeCount) { - tagsToClose.push(tagName) - } - } - if (tagsToClose.length > 0) { - const closingTags = tagsToClose.map((t) => ``).join("\n") - fixed = fixed.trimEnd() + "\n" + closingTags - fixes.push( - `Closed ${tagsToClose.length} unclosed tag(s): ${tagsToClose.join(", ")}`, - ) - } - } - - // 10b. Remove extra closing tags (more closes than opens) - // Need to properly count self-closing tags (they don't need closing tags) - // IMPORTANT: Only count tags at element level, NOT inside quoted attribute values - const tagCounts = new Map< - string, - { opens: number; closes: number; selfClosing: number } - >() - // Match full tags to detect self-closing by checking if ends with /> - const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g - let tagCountMatch - while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) { - // Skip tags inside quoted attribute values (e.g., value="Title") - if (isInsideQuotes(fixed, tagCountMatch.index)) continue - - const fullMatch = tagCountMatch[0] // e.g., "" or "" - const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell" - const isClosing = tagPart.startsWith("/") - const isSelfClosing = fullMatch.endsWith("/>") - const tagName = isClosing ? tagPart.slice(1) : tagPart - - // Only count valid draw.io tags - skip partial/invalid tags like "mx" from streaming - if (!validDrawioTags.has(tagName)) continue - - let counts = tagCounts.get(tagName) - if (!counts) { - counts = { opens: 0, closes: 0, selfClosing: 0 } - tagCounts.set(tagName, counts) - } - if (isClosing) { - counts.closes++ - } else if (isSelfClosing) { - counts.selfClosing++ - } else { - counts.opens++ - } - } - - // Log tag counts for debugging - for (const [tagName, counts] of tagCounts) { - if ( - tagName === "mxCell" || - tagName === "mxGeometry" || - counts.opens !== counts.closes - ) { - console.log( - `[autoFixXml] Step 10b: ${tagName} - opens: ${counts.opens}, closes: ${counts.closes}, selfClosing: ${counts.selfClosing}`, - ) - } - } - - // Find tags with extra closing tags (self-closing tags are balanced, don't need closing) - for (const [tagName, counts] of tagCounts) { - const extraCloses = counts.closes - counts.opens // Only compare opens vs closes (self-closing are balanced) - if (extraCloses > 0) { - console.log( - `[autoFixXml] Step 10b: ${tagName} has ${counts.opens} opens, ${counts.closes} closes, removing ${extraCloses} extra`, - ) - // Remove extra closing tags from the end - let removed = 0 - const closeTagPattern = new RegExp(``, "g") - const matches = [...fixed.matchAll(closeTagPattern)] - // Remove from the end (last occurrences are likely the extras) - for ( - let i = matches.length - 1; - i >= 0 && removed < extraCloses; - i-- - ) { - const match = matches[i] - const idx = match.index ?? 0 - fixed = fixed.slice(0, idx) + fixed.slice(idx + match[0].length) - removed++ - } - if (removed > 0) { - console.log( - `[autoFixXml] Step 10b: Removed ${removed} extra `, - ) - fixes.push( - `Removed ${removed} extra closing tag(s)`, - ) - } - } - } - - // 10c. Remove trailing garbage after last XML tag (e.g., stray backslashes, text) - // Find the last valid closing tag or self-closing tag - const closingTagPattern = /<\/[a-zA-Z][a-zA-Z0-9]*>|\/>/g - let lastValidTagEnd = -1 - let closingMatch - while ((closingMatch = closingTagPattern.exec(fixed)) !== null) { - lastValidTagEnd = closingMatch.index + closingMatch[0].length - } - if (lastValidTagEnd > 0 && lastValidTagEnd < fixed.length) { - const trailing = fixed.slice(lastValidTagEnd).trim() - if (trailing) { - fixed = fixed.slice(0, lastValidTagEnd) - fixes.push("Removed trailing garbage after last XML tag") - } - } - - // 11. Fix nested mxCell by flattening - // Pattern A: ...... (duplicate ID) - // Pattern B: ...... (different ID - true nesting) - const lines = fixed.split("\n") - let newLines: string[] = [] - let nestedFixed = 0 - let extraClosingToRemove = 0 - - // First pass: fix duplicate ID nesting (same as before) - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - const nextLine = lines[i + 1] - - // Check if current line and next line are both mxCell opening tags with same ID - if ( - nextLine && - /") && - !nextLine.includes("/>") - ) { - const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] - const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1] - - if (id1 && id1 === id2) { - nestedFixed++ - extraClosingToRemove++ // Need to remove one later - continue // Skip this duplicate opening line - } - } - - // Remove extra if we have pending removals - if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) { - extraClosingToRemove-- - continue // Skip this closing tag - } - - newLines.push(line) - } - - if (nestedFixed > 0) { - fixed = newLines.join("\n") - fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`) - } - - // Second pass: fix true nesting (different IDs) - // Insert before nested child to close parent - const lines2 = fixed.split("\n") - newLines = [] - let trueNestedFixed = 0 - let cellDepth = 0 - let pendingCloseRemoval = 0 - - for (let i = 0; i < lines2.length; i++) { - const line = lines2[i] - const trimmed = line.trim() - - // Track mxCell depth - const isOpenCell = /") - const isCloseCell = trimmed === "" - - if (isOpenCell) { - if (cellDepth > 0) { - // Found nested cell - insert closing tag for parent before this line - const indent = line.match(/^(\s*)/)?.[1] || "" - newLines.push(indent + "") - trueNestedFixed++ - pendingCloseRemoval++ // Need to remove one later - } - cellDepth = 1 // Reset to 1 since we just opened a new cell - newLines.push(line) - } else if (isCloseCell) { - if (pendingCloseRemoval > 0) { - pendingCloseRemoval-- - // Skip this extra closing tag - } else { - cellDepth = Math.max(0, cellDepth - 1) - newLines.push(line) - } - } else { - newLines.push(line) - } - } - - if (trueNestedFixed > 0) { - fixed = newLines.join("\n") - fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`) - } - - // 12. Fix duplicate IDs by appending suffix - const seenIds = new Map() - const duplicateIds: string[] = [] - - // First pass: find duplicates - const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi - let idMatch - while ((idMatch = idPattern.exec(fixed)) !== null) { - const id = idMatch[1] - seenIds.set(id, (seenIds.get(id) || 0) + 1) - } - - // Find which IDs are duplicated - for (const [id, count] of seenIds) { - if (count > 1) duplicateIds.push(id) - } - - // Second pass: rename duplicates (keep first occurrence, rename others) - if (duplicateIds.length > 0) { - const idCounters = new Map() - fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => { - if (!duplicateIds.includes(id)) return match - - const count = idCounters.get(id) || 0 - idCounters.set(id, count + 1) - - if (count === 0) return match // Keep first occurrence - - // Rename subsequent occurrences - const newId = `${id}_dup${count}` - return match.replace(id, newId) - }) - fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) - } - - // 9. Fix empty id attributes by generating unique IDs - let emptyIdCount = 0 - fixed = fixed.replace( - /]*)\sid\s*=\s*["']\s*["']([^>]*)>/g, - (_match, before, after) => { - emptyIdCount++ - const newId = `cell_${Date.now()}_${emptyIdCount}` - return `` - }, - ) - if (emptyIdCount > 0) { - fixes.push(`Generated ${emptyIdCount} missing ID(s)`) - } - - // 13. Aggressive: drop broken mxCell elements that can't be fixed - // Only do this if DOM parser still finds errors after all other fixes - if (typeof DOMParser !== "undefined") { - let droppedCells = 0 - let maxIterations = MAX_DROP_ITERATIONS - while (maxIterations-- > 0) { - const parser = new DOMParser() - const doc = parser.parseFromString(fixed, "text/xml") - const parseError = doc.querySelector("parsererror") - if (!parseError) break // Valid now! - - const errText = parseError.textContent || "" - const match = errText.match(/(\d+):\d+:/) - if (!match) break - - const errLine = parseInt(match[1], 10) - 1 - const lines = fixed.split("\n") - - // Find the mxCell containing this error line - let cellStart = errLine - let cellEnd = errLine - - // Go back to find 0 && !lines[cellStart].includes(" or /> - while (cellEnd < lines.length - 1) { - if ( - lines[cellEnd].includes("") || - lines[cellEnd].trim().endsWith("/>") - ) { - break - } - cellEnd++ - } - - // Remove these lines - lines.splice(cellStart, cellEnd - cellStart + 1) - fixed = lines.join("\n") - droppedCells++ - } - if (droppedCells > 0) { - fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`) - } - } - - return { fixed, fixes } -} - -/** - * Validates XML and attempts to fix if invalid - * @param xml - The XML string to validate and potentially fix - * @returns Object with validation result, fixed XML if applicable, and fixes applied - */ -export function validateAndFixXml(xml: string): { - valid: boolean - error: string | null - fixed: string | null - fixes: string[] -} { - // First validation attempt - let error = validateMxCellStructure(xml) - - if (!error) { - return { valid: true, error: null, fixed: null, fixes: [] } - } - - // Try to fix - const { fixed, fixes } = autoFixXml(xml) - console.log("[validateAndFixXml] Fixes applied:", fixes) - - // Validate the fixed version - error = validateMxCellStructure(fixed) - if (error) { - console.log("[validateAndFixXml] Still invalid after fix:", error) - } - - if (!error) { - return { valid: true, error: null, fixed, fixes } - } - - // Still invalid after fixes - but return the partially fixed XML - // so we can see what was fixed and what error remains - return { - valid: false, - error, - fixed: fixes.length > 0 ? fixed : null, - fixes, - } -} - export function extractDiagramXML(xml_svg_string: string): string { try { // 1. Parse the SVG string (using built-in DOMParser in a browser-like environment) diff --git a/next.config.ts b/next.config.ts index 466596a4..366b1351 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,9 +10,11 @@ const nextConfig: NextConfig = { env: { APP_VERSION: packageJson.version, }, - // Include instrumentation.ts in standalone build for Langfuse telemetry + // Include instrumentation.ts in standalone build for Langfuse telemetry, + // and the shape library docs get_shape_library reads at run time outputFileTracingIncludes: { "*": ["./instrumentation.ts"], + "/api/chat": ["./docs/shape-libraries/*.md"], }, } diff --git a/package-lock.json b/package-lock.json index 01260003..4ed8ed73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,16 @@ "version": "0.4.16", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/amazon-bedrock": "^4.0.1", - "@ai-sdk/anthropic": "^3.0.0", - "@ai-sdk/azure": "^3.0.0", - "@ai-sdk/deepseek": "^2.0.0", - "@ai-sdk/gateway": "^3.0.0", - "@ai-sdk/google": "^3.0.0", - "@ai-sdk/google-vertex": "^4.0.16", - "@ai-sdk/openai": "^3.0.0", - "@ai-sdk/react": "^3.0.1", - "@aihubmix/ai-sdk-provider": "^2.1.0", + "@ai-sdk/amazon-bedrock": "^4.0.191", + "@ai-sdk/anthropic": "^3.0.127", + "@ai-sdk/azure": "^3.0.133", + "@ai-sdk/deepseek": "^2.0.71", + "@ai-sdk/google": "^3.0.130", + "@ai-sdk/google-vertex": "^4.0.210", + "@ai-sdk/openai": "^3.0.124", + "@ai-sdk/openai-compatible": "^2.0.81", + "@ai-sdk/react": "^3.0.303", + "@aihubmix/ai-sdk-provider": "^2.2.1", "@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/credential-providers": "^3.943.0", "@extractus/article-extractor": "^8.0.18", @@ -28,7 +28,7 @@ "@langfuse/tracing": "^4.4.9", "@next/third-parties": "^16.0.6", "@opennextjs/cloudflare": "^1.17.1", - "@openrouter/ai-sdk-provider": "^2.0.0", + "@openrouter/ai-sdk-provider": "^2.10.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", "@opentelemetry/sdk-trace-node": "^2.2.0", @@ -44,7 +44,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@xmldom/xmldom": "^0.9.8", - "ai": "^6.0.1", + "ai": "^6.0.300", "base-64": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -56,7 +56,7 @@ "nanoid": "^5.0.0", "negotiator": "^1.0.0", "next": "^16.0.7", - "ollama-ai-provider-v2": "^3.0.0", + "ollama-ai-provider-v2": "^3.6.0", "pako": "^2.1.0", "prism-react-renderer": "^2.4.1", "react": "^19.1.2", @@ -66,6 +66,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", + "saxes": "^6.0.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", @@ -123,15 +124,15 @@ "license": "MIT" }, "node_modules/@ai-sdk/amazon-bedrock": { - "version": "4.0.113", - "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.113.tgz", - "integrity": "sha512-qoeF2ghkYqHY4u68rasZopYsRuGnRwgqSfe6rhC/jM6W73Z7TU5v9QcfDYKt9dgp19YHY9EqiX3SXVC+uPGkRQ==", + "version": "4.0.191", + "resolved": "https://registry.npmjs.org/@ai-sdk/amazon-bedrock/-/amazon-bedrock-4.0.191.tgz", + "integrity": "sha512-7wgtq8On8oqmofGIGtYl8EOEbawnfiPbIbWpu2ksGNF065J8mbpE6DhkHNv0dpPbvnn4txL4sflUwUPwHw7/SQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/anthropic": "3.0.81", - "@ai-sdk/openai": "3.0.68", - "@ai-sdk/provider": "3.0.10", - "@ai-sdk/provider-utils": "4.0.27", + "@ai-sdk/anthropic": "3.0.127", + "@ai-sdk/openai": "3.0.124", + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" @@ -143,75 +144,14 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/anthropic": { - "version": "3.0.81", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.81.tgz", - "integrity": "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "3.0.10", - "@ai-sdk/provider-utils": "4.0.27" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, - "node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/openai": { - "version": "3.0.68", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.68.tgz", - "integrity": "sha512-FCs/DPr4M95UyZ/ABHJmTmCEYRCka/4J0Bna0nsd78QCdGIS0X/zhn+fVzB7mZJo7464uOWYUjROx9PGNGOb0w==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "3.0.10", - "@ai-sdk/provider-utils": "4.0.27" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, - "node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", - "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/amazon-bedrock/node_modules/@ai-sdk/provider-utils": { - "version": "4.0.27", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", - "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "3.0.10", - "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.8" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, "node_modules/@ai-sdk/anthropic": { - "version": "3.0.47", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.47.tgz", - "integrity": "sha512-E6Z3i/xvxGDxRskMMbuX9+xDK4l5LesrP2O7YQ0CcbAkYP25qTo/kYGf/AsJrLkNIY23HeO/kheUWtG1XZllDA==", + "version": "3.0.127", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.127.tgz", + "integrity": "sha512-Inff1DmPRVWi6QGr9YL2wiA4maILZuve9I8id/O4keQEoq6fJv6EfvkrSFw/nNvm2jiTcIUfDrpczeHTuhl9mg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57" }, "engines": { "node": ">=18" @@ -221,14 +161,15 @@ } }, "node_modules/@ai-sdk/azure": { - "version": "3.0.34", - "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.34.tgz", - "integrity": "sha512-nnOFtgvZYOa6XIeAm18i56NX77Yu4Bd+Tnbt85LGUEqwJFR54kFTlR1nm3BAJCphHrmQteJd1P3QErtyoXig8A==", + "version": "3.0.133", + "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-3.0.133.tgz", + "integrity": "sha512-iVCT1q6bRPWjzisu7Xy1bc1yWtegd3WSZzmi4+jkiFTKTxir703F5jH8mWPG0hPnqW/OjdkOrSzcQ063j7T/ng==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/openai": "3.0.33", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@ai-sdk/deepseek": "2.0.71", + "@ai-sdk/openai": "3.0.124", + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57" }, "engines": { "node": ">=18" @@ -238,13 +179,13 @@ } }, "node_modules/@ai-sdk/deepseek": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.20.tgz", - "integrity": "sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA==", + "version": "2.0.71", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-2.0.71.tgz", + "integrity": "sha512-2uLtZBgONfzEP7tZOg23sBzi33n1/gcW1zLNNw96/PLt9vdqDYIkGQnKRd6F+/0Wwy77jVulU+gzpiudk3f0UQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57" }, "engines": { "node": ">=18" @@ -254,14 +195,14 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.55", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.55.tgz", - "integrity": "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw==", + "version": "3.0.209", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.209.tgz", + "integrity": "sha512-CjCBzC35lRZ0LnUYLDfTB92p0Fr1Fu9Wrivc+LI0u3ox/DyZ0wPOHjt8DKBKHD/R4aZ86+JCFFuD9f8MFjAQFg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15", - "@vercel/oidc": "3.1.0" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57", + "@vercel/oidc": "3.2.0" }, "engines": { "node": ">=18" @@ -271,13 +212,13 @@ } }, "node_modules/@ai-sdk/google": { - "version": "3.0.31", - "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.31.tgz", - "integrity": "sha512-RVNz8WFSIRbXbYDBE6JvlE2escWPJimBCs22LzKEYH7DNfl/X7cHNa1LFho4PsY6Ib0JmbzB8s2+i0wHs/wNCg==", + "version": "3.0.130", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.130.tgz", + "integrity": "sha512-DOhGfFT667LbopxMZU7kIiT4K8F2RNN8ziwmOxAfMbp3xTvAaxQ4ynPKNWBVXcCkGYDmzPb7QiazN1IRX8y3lw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57" }, "engines": { "node": ">=18" @@ -287,15 +228,16 @@ } }, "node_modules/@ai-sdk/google-vertex": { - "version": "4.0.63", - "resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.63.tgz", - "integrity": "sha512-/RNi6KSB4162DDYeXHUKQc5jLPmiJMkhTswLwbfPUEPyyFjbxpBWgeAk/vS/u8jxT4IPyYQ8cD/uyvJibmtmww==", + "version": "4.0.210", + "resolved": "https://registry.npmjs.org/@ai-sdk/google-vertex/-/google-vertex-4.0.210.tgz", + "integrity": "sha512-7Jj41iWsTFUcv6pzji5AlW2QaMJt/PEMU/zwqji2H7U52WvZE345kyo+nKRJMFtHgSVqFmzmMUIM9gyeaXKRtw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/anthropic": "3.0.47", - "@ai-sdk/google": "3.0.31", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15", + "@ai-sdk/anthropic": "3.0.127", + "@ai-sdk/google": "3.0.130", + "@ai-sdk/openai-compatible": "2.0.81", + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57", "google-auth-library": "^10.5.0" }, "engines": { @@ -306,13 +248,13 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "3.0.33", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.33.tgz", - "integrity": "sha512-O/8SVKAiwFHkGAUfBnrLb7L2IjbpP9ySWbmOktOfa0KtzutZkmKNrJ5CtB5dj+lwuENbOuZeRsnsZdOjar7hig==", + "version": "3.0.124", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.124.tgz", + "integrity": "sha512-7DpRUPXzJ+S6XasEJBojzFCs04J3rNPs0FZFF3PEeix49IggZAwsMrqscUwB/+05+87r01SS/jeopQGOOL4AEQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57" }, "engines": { "node": ">=18" @@ -322,42 +264,13 @@ } }, "node_modules/@ai-sdk/openai-compatible": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.48.tgz", - "integrity": "sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==", + "version": "2.0.81", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.81.tgz", + "integrity": "sha512-L14Jd0lAFKNM42l9TxlpwkNiyMlSvSvGySLc9GN4FB0qOo4/WmYOeLyn/CPhghIr0K4O6vH1y0nChxGljq2ccg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.10", - "@ai-sdk/provider-utils": "4.0.27" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, - "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", - "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/openai-compatible/node_modules/@ai-sdk/provider-utils": { - "version": "4.0.27", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", - "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "3.0.10", - "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.8" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57" }, "engines": { "node": ">=18" @@ -367,9 +280,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", - "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.18.tgz", + "integrity": "sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -379,30 +292,40 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", - "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "version": "4.0.57", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.57.tgz", + "integrity": "sha512-89a7sPvZqXsP3PigcAQLWntu/duIVEcN/RyChAB+j6p3C7NwW767sVttvL8r0+BetTg63uAXQoYHCZZueQKBSw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider": "3.0.18", "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.6" + "eventsource-parser": "^3.0.8", + "undici": "^6.28.0" }, "engines": { - "node": ">=18" + "node": ">=18.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/provider-utils/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@ai-sdk/react": { - "version": "3.0.102", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.102.tgz", - "integrity": "sha512-WPSYJxk/HM3SWhhxE+WrLhIMbaRLpDHWWYzUlrptXowwEoyliYBYZAkzip/gV8hybT2NyrWnsUh5KRO5iBdsQA==", + "version": "3.0.303", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.303.tgz", + "integrity": "sha512-PeTBn25x8QxfIIpdUDOpWHfiIZxMRJCw04PhWNHI9hl+gtu+zGwAxWU+sy3PH/f/oEBBAp7eDDUDDjiuqwcw6g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "4.0.15", - "ai": "6.0.100", + "@ai-sdk/provider-utils": "4.0.57", + "ai": "6.0.300", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -414,9 +337,9 @@ } }, "node_modules/@aihubmix/ai-sdk-provider": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.1.0.tgz", - "integrity": "sha512-AqK10PV5B4zWFBav5PRUhrWGYTHjC0s6cIbd4v9hBo6D9SqKv5o41B+dkl6IeIPrnZrMGLEE9Mn5R+VaEkhbdg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@aihubmix/ai-sdk-provider/-/ai-sdk-provider-2.2.1.tgz", + "integrity": "sha512-VUkSYFbrigs5ZF44LRSHEI/q1fZyLkExWvrgBG4w0ysGEE/XXt7fi9NgMG9UH1PQVBgnc/PP03CUhTM1nhmGxQ==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/anthropic": "^3.0.0", @@ -5890,9 +5813,9 @@ } }, "node_modules/@openrouter/ai-sdk-provider": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.8.1.tgz", - "integrity": "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-2.10.0.tgz", + "integrity": "sha512-FMsAEjLUt5pWuRE2LDC/LCvVrFjLlrEzUITH5+5SZtfq7KZ2wrOHjQVxzz92sju8S9ltpzW87CLW8/b0oBXVCw==", "license": "Apache-2.0", "engines": { "node": ">=18" @@ -9326,9 +9249,9 @@ ] }, "node_modules/@vercel/oidc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", - "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", "license": "Apache-2.0", "engines": { "node": ">= 20" @@ -9594,15 +9517,15 @@ } }, "node_modules/ai": { - "version": "6.0.100", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.100.tgz", - "integrity": "sha512-BIxhG7M7wvcWCF+IEnZi7WpkRLOM3jR2vJ0mMuohl2UB2i1R/ZUa1cHFel1xI8nWvyUpOoQXKqsM0BAH50EYSQ==", + "version": "6.0.300", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.300.tgz", + "integrity": "sha512-ZBT30eQTy6exP+EWJJxm3DMb34m9td8STy+hfnvKdjhyEQoM8vQN5YfFLTRVVTCONHoM+y5K+Tudb6PTFrpikA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.55", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.15", - "@opentelemetry/api": "1.9.0" + "@ai-sdk/gateway": "3.0.209", + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.57", + "@opentelemetry/api": "^1.9.0" }, "engines": { "node": ">=18" @@ -9611,15 +9534,6 @@ "zod": "^3.25.76 || ^4.1.8" } }, - "node_modules/ai/node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -19589,39 +19503,26 @@ "license": "MIT" }, "node_modules/ollama-ai-provider-v2": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-3.5.0.tgz", - "integrity": "sha512-+s/aYIYa91z2Vk3AkGAz3BaPAQ0flS2eFZD3BN2mD/N6W6YQbcookyu6pc2cbc8SP5VGpNB857WJ0eHDjKXsXw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/ollama-ai-provider-v2/-/ollama-ai-provider-v2-3.6.0.tgz", + "integrity": "sha512-1Om3FVJYhBwkAr5kQ+BX1s/tdVdtVdoFQWrX4PBQHDHPISyGt24CjhtggEjUYpy5ait0YeVfZwEpIYjgD8Ih7Q==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "^3.0.8", - "@ai-sdk/provider-utils": "^4.0.19" + "@ai-sdk/provider": "^3.0.10", + "@ai-sdk/provider-utils": "^4.0.27" }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "funding": { + "type": "Buy Me a Coffee", + "url": "https://buymeacoffee.com/nordwestt" }, "peerDependencies": { "ai": "^5.0.0 || ^6.0.0", "zod": "^4.0.16" } }, - "node_modules/ollama-ai-provider-v2/node_modules/@ai-sdk/provider-utils": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.23.tgz", - "integrity": "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.6" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4.1.8" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -21478,7 +21379,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -25261,7 +25161,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/y18n": { diff --git a/package.json b/package.json index c131f12e..3db426aa 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,16 @@ "test:e2e": "playwright test" }, "dependencies": { - "@ai-sdk/amazon-bedrock": "^4.0.1", - "@ai-sdk/anthropic": "^3.0.0", - "@ai-sdk/azure": "^3.0.0", - "@ai-sdk/deepseek": "^2.0.0", - "@ai-sdk/gateway": "^3.0.0", - "@ai-sdk/google": "^3.0.0", - "@ai-sdk/google-vertex": "^4.0.16", - "@ai-sdk/openai": "^3.0.0", - "@ai-sdk/react": "^3.0.1", - "@aihubmix/ai-sdk-provider": "^2.1.0", + "@ai-sdk/amazon-bedrock": "^4.0.191", + "@ai-sdk/anthropic": "^3.0.127", + "@ai-sdk/azure": "^3.0.133", + "@ai-sdk/deepseek": "^2.0.71", + "@ai-sdk/google": "^3.0.130", + "@ai-sdk/google-vertex": "^4.0.210", + "@ai-sdk/openai": "^3.0.124", + "@ai-sdk/openai-compatible": "^2.0.81", + "@ai-sdk/react": "^3.0.303", + "@aihubmix/ai-sdk-provider": "^2.2.1", "@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/credential-providers": "^3.943.0", "@extractus/article-extractor": "^8.0.18", @@ -50,7 +50,7 @@ "@langfuse/tracing": "^4.4.9", "@next/third-parties": "^16.0.6", "@opennextjs/cloudflare": "^1.17.1", - "@openrouter/ai-sdk-provider": "^2.0.0", + "@openrouter/ai-sdk-provider": "^2.10.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-http": "^0.222.0", "@opentelemetry/sdk-trace-node": "^2.2.0", @@ -66,7 +66,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@xmldom/xmldom": "^0.9.8", - "ai": "^6.0.1", + "ai": "^6.0.300", "base-64": "^1.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -78,7 +78,7 @@ "nanoid": "^5.0.0", "negotiator": "^1.0.0", "next": "^16.0.7", - "ollama-ai-provider-v2": "^3.0.0", + "ollama-ai-provider-v2": "^3.6.0", "pako": "^2.1.0", "prism-react-renderer": "^2.4.1", "react": "^19.1.2", @@ -88,6 +88,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "remark-gfm": "^4.0.1", + "saxes": "^6.0.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.0.2", diff --git a/packages/claude-plugin/README.md b/packages/claude-plugin/README.md index db4de05b..752fabbc 100644 --- a/packages/claude-plugin/README.md +++ b/packages/claude-plugin/README.md @@ -25,10 +25,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest ## Features - **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them -- **Version History**: Restore previous diagram versions with visual thumbnails +- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more +- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong +- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. -- **Edit Support**: Modify existing diagrams with natural language instructions -- **Export**: Save diagrams as `.drawio` files +- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser +- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files +- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again - **Self-contained**: Embedded server, no external dependencies required ## Use Case Examples @@ -72,11 +75,16 @@ between user, client app, auth server, and resource server | Tool | Description | |------|-------------| -| `start_session` | Opens browser with real-time diagram preview | +| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules | +| `get_drawing_guide` | Return the drawing rules again | +| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` | | `create_new_diagram` | Create a new diagram from XML | -| `edit_diagram` | Edit diagram by ID-based operations | +| `load_diagram` | Load a `.drawio` file from disk | +| `edit_diagram` | Edit diagram by ID-based operations; all or nothing | | `get_diagram` | Get the current diagram XML | -| `export_diagram` | Save diagram to a `.drawio` file | +| `screenshot_diagram` | Return a PNG of a page so Claude can check the result | +| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file | +| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams | ## How It Works @@ -95,6 +103,8 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io) |----------|---------|-------------| | `PORT` | `6002` | Port for the embedded HTTP server | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) | +| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams; `off` turns auto-save off | +| `DEBUG` | unset | Set to `true` to log debug messages | ## Links diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 9e19b86c..968688a2 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -104,27 +104,41 @@ Use the standard MCP configuration with: ## Features - **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them -- **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states -- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. -- **Edit Support**: Modify existing diagrams with natural language instructions -- **Export**: Save diagrams as `.drawio` files +- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing +- **Self-check**: The AI can take a screenshot of the rendered diagram and fix overlapping shapes or edges that cross shapes +- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML +- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History +- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails +- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram` +- **Multi-page**: List, add, rename, and delete pages, and edit any page +- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/.drawio`, so it survives a restart of the MCP client +- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) ## Available Tools | Tool | Description | |------|-------------| -| `start_session` | Opens browser with real-time diagram preview | -| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) | +| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules | +| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted | +| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` | +| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough | | `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) | -| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) | -| `get_diagram` | Get the current diagram XML | -| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file | +| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing | +| `get_diagram` | Get the current diagram XML, including your edits in the browser | +| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram | +| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file | | `list_pages` | List every page (tab) with id, name, index, and cell count | | `add_page` | Append a new page without touching existing ones | | `rename_page` | Rename a page | | `delete_page` | Delete a page (refuses to delete the last one) | +## Continue a Diagram Later + +After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself. + +The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off. + ## How It Works ``` @@ -154,6 +168,8 @@ Use the standard MCP configuration with: |----------|---------|-------------| | `PORT` | `6002` | Port for the embedded HTTP server | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. | +| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files. Set to `off` to turn auto-save off. | +| `DEBUG` | unset | Set to `true` to log debug messages to stderr. | ### Private Deployment (Self-hosted draw.io) @@ -179,7 +195,7 @@ You can deploy your own draw.io instance using the official Docker image: docker run -d -p 8080:8080 jgraph/drawio ``` -Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). +Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`. ## Troubleshooting @@ -208,6 +224,10 @@ Call `start_session` first to open the browser window. Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server. +### Screenshot or PNG/SVG export times out + +PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again. + ## License Apache-2.0 diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json index fc7a3ada..fb2ded77 100644 --- a/packages/mcp-server/package-lock.json +++ b/packages/mcp-server/package-lock.json @@ -1,17 +1,19 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4", + "@modelcontextprotocol/sdk": "^1.31.0", "linkedom": "^0.18.0", "open": "^11.0.0", + "pako": "^2.2.0", + "saxes": "^6.0.0", "zod": "^4.0.0" }, "bin": { @@ -19,6 +21,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "@types/pako": "^2.0.4", "tsx": "^4.19.0", "typescript": "^5", "vitest": "^4.1.8" @@ -908,6 +911,13 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -2600,6 +2610,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2834,6 +2860,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -3379,6 +3417,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 3143eb2e..9349eb6f 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@next-ai-drawio/mcp-server", - "version": "0.2.3", + "version": "0.3.0", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "type": "module", "main": "dist/index.js", @@ -8,7 +8,8 @@ "next-ai-drawio-mcp": "./dist/index.js" }, "scripts": { - "build": "tsc", + "build": "tsc && node scripts/copy-assets.mjs", + "check-package": "node scripts/check-package.mjs", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "test": "vitest run", @@ -38,13 +39,16 @@ "access": "public" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4", + "@modelcontextprotocol/sdk": "^1.31.0", "linkedom": "^0.18.0", "open": "^11.0.0", + "pako": "^2.2.0", + "saxes": "^6.0.0", "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^24.0.0", + "@types/pako": "^2.0.4", "tsx": "^4.19.0", "typescript": "^5", "vitest": "^4.1.8" diff --git a/packages/mcp-server/scripts/check-package.mjs b/packages/mcp-server/scripts/check-package.mjs new file mode 100644 index 00000000..133cbdf2 --- /dev/null +++ b/packages/mcp-server/scripts/check-package.mjs @@ -0,0 +1,23 @@ +// Fail if the npm package would miss files the server reads at runtime. +// Tests run from src/ (tsx) and cannot notice a broken dist/ copy step. +// Run after `npm run build`. +import { execSync } from "node:child_process" + +const REQUIRED = [ + "dist/index.js", + "dist/shape-libraries/aws4.md", + "dist/preview/index.html", + "dist/preview/preview.css", + "dist/preview/preview.js", +] + +const [pack] = JSON.parse( + execSync("npm pack --dry-run --json", { encoding: "utf8" }), +) +const files = new Set(pack.files.map((f) => f.path)) +const missing = REQUIRED.filter((f) => !files.has(f)) +if (missing.length > 0) { + console.error(`npm package is missing: ${missing.join(", ")}`) + process.exit(1) +} +console.log(`npm package OK (${files.size} files)`) diff --git a/packages/mcp-server/scripts/copy-assets.mjs b/packages/mcp-server/scripts/copy-assets.mjs new file mode 100644 index 00000000..67f8e0e8 --- /dev/null +++ b/packages/mcp-server/scripts/copy-assets.mjs @@ -0,0 +1,22 @@ +// Copy non-TypeScript assets into dist/ after tsc, so they ship in the npm +// package ("files": ["dist"]). +import { cpSync, mkdirSync, readdirSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +const pkg = join(dirname(fileURLToPath(import.meta.url)), "..") + +// Shape library docs live at the repository root, shared with the web app +const libSrc = join(pkg, "../../docs/shape-libraries") +const libDest = join(pkg, "dist/shape-libraries") +mkdirSync(libDest, { recursive: true }) +for (const file of readdirSync(libSrc)) { + if (file.endsWith(".md") && file !== "README.md") { + cpSync(join(libSrc, file), join(libDest, file)) + } +} + +// Browser preview page (HTML, CSS and script) +cpSync(join(pkg, "src/preview"), join(pkg, "dist/preview"), { + recursive: true, +}) diff --git a/packages/mcp-server/src/diagram-operations.ts b/packages/mcp-server/src/diagram-operations.ts index 4711de7b..5315f191 100644 --- a/packages/mcp-server/src/diagram-operations.ts +++ b/packages/mcp-server/src/diagram-operations.ts @@ -7,7 +7,10 @@ * first page is targeted (the "active page by convention" — see pages.ts). */ -import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js" +import { decompressPageContent } from "./load-diagram.ts" +import { log } from "./logger.ts" +import { findPageElement, hasPageSelector, type PageSelector } from "./pages.ts" +import { getXmlSyntaxError } from "./xml-syntax.ts" export interface DiagramOperation { operation: "update" | "add" | "delete" @@ -26,6 +29,56 @@ export interface ApplyOperationsResult { errors: OperationError[] } +// Cells with links, tooltips or custom data are stored as +// (or ): the id sits +// on the wrapper, so the wrapper is treated as the cell. +const CELL_SELECTOR = "mxCell, UserObject, object" + +/** + * Return the of a page, creating it when missing. An empty + * page gets a blank model with the "0" and "1" root cells; a page whose text + * is draw.io's compressed format is decompressed in place. Returns null if + * the text is neither empty nor decompressible. + */ +function ensurePageRoot(doc: Document, page: Element): Element | null { + const existing = page.querySelector("root") + if (existing) return existing + + let model = page.querySelector("mxGraphModel") + if (!model) { + const text = page.textContent?.trim() ?? "" + if (text) { + const xml = decompressPageContent(text) + if (!xml || getXmlSyntaxError(xml)) return null + const parsed = new DOMParser().parseFromString(xml, "text/xml") + if (parsed.documentElement?.tagName !== "mxGraphModel") return null + page.textContent = "" + model = page.appendChild( + doc.importNode(parsed.documentElement, true), + ) as Element + const decompressedRoot = model.querySelector("root") + if (decompressedRoot) return decompressedRoot + } else { + model = page.appendChild(doc.createElement("mxGraphModel")) + } + } + + const blank = new DOMParser().parseFromString( + ``, + "text/xml", + ) + return model.appendChild( + doc.importNode(blank.documentElement, true), + ) as Element +} + +/** Read parent/source/target, which a wrapped cell keeps on its inner mxCell. */ +function cellAttr(cell: Element, name: string): string | null { + const inner = + cell.tagName === "mxCell" ? cell : cell.querySelector("mxCell") + return inner?.getAttribute(name) ?? null +} + /** * Apply diagram operations (update/add/delete) using ID-based lookup. * @@ -43,12 +96,8 @@ export function applyDiagramOperations( ): ApplyOperationsResult { const errors: OperationError[] = [] - // Parse the XML - const parser = new DOMParser() - const doc = parser.parseFromString(xmlContent, "text/xml") - - // Check for parse errors - const parseError = doc.querySelector("parsererror") + // Check for syntax errors, then parse the XML + const parseError = getXmlSyntaxError(xmlContent) if (parseError) { return { result: xmlContent, @@ -56,11 +105,13 @@ export function applyDiagramOperations( { type: "update", cellId: "", - message: `XML parse error: ${parseError.textContent}`, + message: `XML parse error: ${parseError}`, }, ], } } + const parser = new DOMParser() + const doc = parser.parseFromString(xmlContent, "text/xml") // Locate the element to operate on. // @@ -86,7 +137,7 @@ export function applyDiagramOperations( ], } } - root = found.element.querySelector("root") + root = ensurePageRoot(doc as unknown as Document, found.element) if (!root) { const pageId = found.element.getAttribute("id") || `(index ${found.index})` @@ -96,7 +147,7 @@ export function applyDiagramOperations( { type: "update", cellId: "", - message: `Page "${pageId}" has no element`, + message: `Page "${pageId}" has no element and its content could not be decompressed`, }, ], } @@ -132,10 +183,16 @@ export function applyDiagramOperations( // Build a map of cell IDs to elements (scoped to the resolved page). const cellMap = new Map() - root.querySelectorAll("mxCell").forEach((cell) => { + root.querySelectorAll(CELL_SELECTOR).forEach((cell) => { const id = cell.getAttribute("id") - if (id) cellMap.set(id, cell) + // A wrapped mxCell may repeat its wrapper's id; the wrapper is the cell + const wrapped = + cell.tagName === "mxCell" && + /^(UserObject|object)$/.test(cell.parentElement?.tagName ?? "") + if (id && !wrapped) cellMap.set(id, cell) }) + // Ids deleted so far in this batch; deleting one again is a no-op + const deletedIds = new Set() // Process each operation for (const op of operations) { @@ -164,7 +221,7 @@ export function applyDiagramOperations( `${op.new_xml}`, "text/xml", ) - const newCell = newDoc.querySelector("mxCell") + const newCell = newDoc.querySelector(CELL_SELECTOR) if (!newCell) { errors.push({ type: "update", @@ -216,7 +273,7 @@ export function applyDiagramOperations( `${op.new_xml}`, "text/xml", ) - const newCell = newDoc.querySelector("mxCell") + const newCell = newDoc.querySelector(CELL_SELECTOR) if (!newCell) { errors.push({ type: "add", @@ -256,8 +313,15 @@ export function applyDiagramOperations( const existingCell = cellMap.get(op.cell_id) if (!existingCell) { - // Cell not found - might have been cascade-deleted by a previous operation - // Skip silently instead of erroring (AI may redundantly list children/edges) + // Skip cells already cascade-deleted by a previous operation + // (AI may redundantly list children/edges); warn otherwise + if (!deletedIds.has(op.cell_id)) { + errors.push({ + type: "delete", + cellId: op.cell_id, + message: `Cell with id="${op.cell_id}" not found`, + }) + } continue } @@ -270,17 +334,17 @@ export function applyDiagramOperations( cellsToDelete.add(cellId) // Find children (cells where parent === cellId) - // Scoped to `root` so other pages' cells with the same parent id - // (notably "1") are never touched. - const children = root!.querySelectorAll( - `mxCell[parent="${cellId}"]`, - ) - children.forEach((child) => { - const childId = child.getAttribute("id") - if (childId && childId !== "0" && childId !== "1") { + // cellMap only holds this page's cells, so other pages' cells + // with the same parent id (notably "1") are never touched. + for (const [childId, child] of cellMap) { + if ( + childId !== "0" && + childId !== "1" && + cellAttr(child, "parent") === cellId + ) { collectDescendants(childId) } - }) + } } // Collect the target cell and all its descendants @@ -289,23 +353,23 @@ export function applyDiagramOperations( // Find edges referencing any of the cells to be deleted // Also recursively collect children of those edges (e.g., edge labels) for (const cellId of cellsToDelete) { - const referencingEdges = root.querySelectorAll( - `mxCell[source="${cellId}"], mxCell[target="${cellId}"]`, - ) - referencingEdges.forEach((edge) => { - const edgeId = edge.getAttribute("id") + for (const [edgeId, edge] of cellMap) { // Protect root cells from being added via edge references - if (edgeId && edgeId !== "0" && edgeId !== "1") { + if (edgeId === "0" || edgeId === "1") continue + if ( + cellAttr(edge, "source") === cellId || + cellAttr(edge, "target") === cellId + ) { // Recurse to collect edge's children (like labels) collectDescendants(edgeId) } - }) + } } - // Log what will be deleted + // Log what will be deleted (stderr: stdout carries JSON-RPC) if (cellsToDelete.size > 1) { - console.log( - `[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`, + log.debug( + `Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`, ) } @@ -316,6 +380,7 @@ export function applyDiagramOperations( cell.parentNode?.removeChild(cell) cellMap.delete(cellId) } + deletedIds.add(cellId) } } } diff --git a/packages/mcp-server/src/dom.ts b/packages/mcp-server/src/dom.ts new file mode 100644 index 00000000..13972b3a --- /dev/null +++ b/packages/mcp-server/src/dom.ts @@ -0,0 +1,98 @@ +/** + * DOM setup for Node. The XML helpers use the global DOMParser and + * XMLSerializer, which the browser has and Node gets from here. + * + * linkedom gives us a DOM with querySelector, but it is lenient: it never + * reports syntax errors (no ; xml-syntax.ts checks them), and + * its serializer writes raw newlines inside attribute values, which the + * browser reads back as spaces. serializeXml writes attribute values safely. + */ +import { DOMParser } from "linkedom" + +const ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "\t": " ", + "\n": " ", + "\r": " ", +} +const escapeChars = (text: string, chars: RegExp) => + text.replace(chars, (c) => ESCAPES[c]) + +/** + * Serialize a linkedom node as XML. Attribute values escape tabs and line + * breaks too, so multi-line labels (value="a b") survive a round trip. + */ +export function serializeXml(node: Node): string { + switch (node.nodeType) { + case 9: { + // Document + const root = (node as Document).documentElement + return root ? serializeXml(root) : "" + } + case 1: { + // Element + const el = node as Element + let out = `<${el.tagName}` + for (const attr of Array.from(el.attributes)) { + out += ` ${attr.name}="${escapeChars(attr.value, /[&<>"\t\n\r]/g)}"` + } + if (el.childNodes.length === 0) return `${out}/>` + out += ">" + for (const child of Array.from(el.childNodes)) { + out += serializeXml(child) + } + return `${out}` + } + case 3: + // Text + return escapeChars(node.textContent ?? "", /[&<>]/g) + case 4: + // CDATA + return `` + case 8: + // Comment + return `` + default: + return "" + } +} + +/** + * XML parsers read a literal tab or line break inside an attribute value as + * a space (a line break written as stays one). linkedom keeps it, and + * serializeXml would then write it as a real line break, so an edit would + * change labels it never touched. Applied to the text before linkedom. + */ +function normalizeAttributeWhitespace(xml: string): string { + return xml.replace( + /<[A-Za-z][^"'<>]*(?:(?:"[^"]*"|'[^']*')[^"'<>]*)*>/g, + (tag) => + tag.replace(/"[^"]*"|'[^']*'/g, (value) => + value.replace(/\r\n|[\t\n\r]/g, " "), + ), + ) +} + +class XmlDomParser extends DOMParser { + parseFromString(text: string, type: string) { + return super.parseFromString( + type.includes("xml") ? normalizeAttributeWhitespace(text) : text, + type as any, + ) + } +} + +class XMLSerializerPolyfill { + serializeToString(node: Node): string { + return serializeXml(node) + } +} + +/** Install the DOMParser and XMLSerializer globals the XML helpers use. */ +export function installDomPolyfill(): void { + ;(globalThis as any).DOMParser = XmlDomParser + ;(globalThis as any).XMLSerializer = XMLSerializerPolyfill +} diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts new file mode 100644 index 00000000..4265a64e --- /dev/null +++ b/packages/mcp-server/src/drawing-guide.ts @@ -0,0 +1,107 @@ +/** + * Drawing guide for the model, returned by start_session, get_drawing_guide + * and the diagram-workflow prompt. + * + * Adapted from the web app's system prompt (lib/system-prompts.ts) and its + * tool descriptions (app/api/chat/route.ts). When drawing rules change there, + * update this file too. + */ + +import { + indent, + SWIMLANE_EXAMPLE, + TWO_EDGES_EXAMPLE, + WAYPOINT_EXAMPLE, +} from "./xml-examples.ts" + +export const DRAWING_GUIDE = `# Draw.io drawing guide + +## Workflow +- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file. +- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes. +- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message. +- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4). +- After drawing or heavily editing a complex diagram, call screenshot_diagram once to see the result, and fix overlapping shapes and edges that cross shapes. +- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes. +- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost. + +## The XML you send +Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds , , and the root cells id="0" and id="1". + + + + + + + + +Several pages at once (create_new_diagram only): send a full with one per page. Every page's must start with . + +Rules (XML that breaks them is rejected): +1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell. +2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells). +3. parent="1" for top-level shapes, parent="" for shapes inside a container. +4. Edge source and target must reference existing cell ids. +5. Escape special characters in attribute values: < for <, > for >, & for &, " for ". +6. NEVER include XML comments (). draw.io strips them. +7. In tool arguments (JSON), every " inside the XML must be escaped as \\". + +Containers and swimlanes: children use the container id as parent and coordinates relative to the container. + +${indent(SWIMLANE_EXAMPLE)} + +## Layout +- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break. +- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall. +- Start near x=40, y=40 and keep elements grouped closely. +- For large diagrams, stack vertically or use a grid instead of spreading wide. + +## Edge routing rules +Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5). +Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1). +Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;". +Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box. +Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom). +Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical. +Rule 7: Use natural connection points. Never connect at corners (both X and Y 0 or 1). Top-to-bottom flow: exitY=1 into entryY=0. Left-to-right flow: exitX=1 into entryX=0. Diagonal: the side closest to the target. + +Before sending XML, check: +1. Does any edge cross a shape that is not its source or target? Add waypoints. +2. Do two edges share a path? Change their exit or entry points. +3. Is any connection point at a corner? Use the middle of a side. +4. Could moving shapes remove edge crossings? Revise the layout. + +Two edges between the same nodes: + +${indent(TWO_EDGES_EXAMPLE)} + +Waypoints go inside in the edge geometry. Example: Hotfix (right, bottom) connects to Main (center, top) while Develop (center, middle) is in between, so the edge goes right to x=750 first, then up, and enters Main from the right: + +${indent(WAYPOINT_EXAMPLE)} + +## Styles +- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1; +- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle +- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right +- Animated connectors: add flowAnimation=1 to the edge style. + +## Minimal style +When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above: +- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors. +- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;". +- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children. +- Keep at least 50px between elements, and follow all edge routing rules strictly. + +## Editing with edit_diagram +- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id. +- add inserts a new cell with a new id. One cell per operation. +- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id. +- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry. +- If the diagram is large, change it with edit_diagram instead of redrawing it. + + {"operations": [{"operation": "update", "cell_id": "3", "new_xml": ""}]} + {"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": ""}]} + {"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]} + +Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page. +` diff --git a/packages/mcp-server/src/edit-diagram.ts b/packages/mcp-server/src/edit-diagram.ts new file mode 100644 index 00000000..a77e8e92 --- /dev/null +++ b/packages/mcp-server/src/edit-diagram.ts @@ -0,0 +1,109 @@ +/** + * Core of the edit_diagram tool, kept free of session state so it can be + * unit tested. + * + * All-or-nothing, like the web app (hooks/use-diagram-tool-handlers.ts): + * if any operation fails, nothing is written and every failure is reported, + * so the model never builds on a half-applied edit. + */ + +import { + applyDiagramOperations, + type DiagramOperation, +} from "./diagram-operations.ts" +import { type PageSelector, projectPage } from "./pages.ts" +import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts" + +export type EditOutcome = + | { ok: true; xml: string; applied: number; fixes: string[] } + | { ok: false; errors: string[]; pageError: boolean } + +/** Number of top-level elements in an XML fragment. */ +function countTopLevelElements(fragment: string): number { + const doc = new DOMParser().parseFromString( + `${fragment}`, + "text/xml", + ) + // On a syntax error the browser adds a element (Chrome + // next to the cells, Firefox as the root); the syntax is checked later + const root = doc.documentElement + if (!root || root.tagName === "parsererror") return 1 + return Array.from(root.children).filter( + (el) => el.tagName !== "parsererror", + ).length +} + +/** The target page as a one-page , or the whole document. */ +export function targetPageXml(xml: string, selector: PageSelector): string { + const projection = projectPage(xml, selector) + return projection.ok ? projection.xml : xml +} + +export function editDiagram( + xml: string, + operations: DiagramOperation[], + selector: PageSelector, +): EditOutcome { + const errors: string[] = [] + const fixes: string[] = [] + const prepared: DiagramOperation[] = [] + + for (const op of operations) { + if (op.operation === "delete" || !op.new_xml) { + prepared.push(op) + continue + } + // Checked before validation: several cells fail the strict parser + // with a misleading "only one root" syntax error. + if (countTopLevelElements(op.new_xml) > 1) { + errors.push( + `${op.operation} ${op.cell_id}: new_xml must contain exactly one cell; use one add operation per cell`, + ) + continue + } + const check = validateAndFixXml(op.new_xml) + if (!check.valid) { + errors.push( + `${op.operation} ${op.cell_id}: invalid new_xml: ${check.error}`, + ) + continue + } + if (check.fixed) { + fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`) + } + prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml }) + } + if (errors.length > 0) return { ok: false, errors, pageError: false } + + const { result, errors: opErrors } = applyDiagramOperations( + xml, + prepared, + selector, + ) + // An empty cellId means the page itself could not be edited + const pageLevel = opErrors.find((e) => e.cellId === "") + if (pageLevel) { + return { ok: false, errors: [pageLevel.message], pageError: true } + } + if (opErrors.length > 0) { + return { + ok: false, + errors: opErrors.map((e) => `${e.type} ${e.cellId}: ${e.message}`), + pageError: false, + } + } + + // Validate only the target page, and reject only errors this edit + // introduced: problems already in other pages or in a loaded file must + // not block every edit. + const after = validateMxCellStructure(targetPageXml(result, selector)) + if (after && !validateMxCellStructure(targetPageXml(xml, selector))) { + return { + ok: false, + errors: [`the edit would make the page invalid: ${after}`], + pageError: false, + } + } + + return { ok: true, xml: result, applied: operations.length, fixes } +} diff --git a/packages/mcp-server/src/edit-gate.ts b/packages/mcp-server/src/edit-gate.ts index b3b75ee9..f80240b2 100644 --- a/packages/mcp-server/src/edit-gate.ts +++ b/packages/mcp-server/src/edit-gate.ts @@ -17,7 +17,14 @@ * change: the set of pages, each page's name, and each page's cell tree * (tags + sorted attributes + text). Byte equality is kept as a fast path. */ -import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js" +import { + findPageElement, + isMxGraphModel, + normalizeToMxfile, + type PageSelector, + parseMxfile, + serializeMxfile, +} from "./pages.ts" export type EditGateResult = | { ok: true } @@ -100,3 +107,38 @@ export function checkEditGate( } return { ok: true } } + +/** + * The model was shown only the selected page of liveXml (get_diagram with a + * page selector, or a rejected edit's error). It has seen the whole document + * if the other pages are as it last saw them, or if it saw nothing before + * (it then has no old copy of them to edit from). Returns the new + * lastSeenXml: liveXml, or lastSeenXml unchanged. + */ +export function markPageSeen( + lastSeenXml: string, + liveXml: string, + selector: PageSelector, +): string { + const parse = (xml: string) => { + const normalized = normalizeToMxfile(xml) + return normalized ? parseMxfile(normalized) : null + } + // An empty record also follows load_diagram and page tools that wrote + // over unseen changes, when the model may remember older pages: only + // a one-page document is then fully seen + if (!lastSeenXml) { + const pages = parse(liveXml)?.querySelectorAll("diagram").length ?? 1 + return pages <= 1 ? liveXml : lastSeenXml + } + const otherPages = (xml: string) => { + const doc = parse(xml) + if (!doc) return null + findPageElement(doc, selector)?.element.remove() + return contentFingerprint(serializeMxfile(doc)) + } + const before = otherPages(lastSeenXml) + return before !== null && before === otherPages(liveXml) + ? liveXml + : lastSeenXml +} diff --git a/packages/mcp-server/src/exclusive.ts b/packages/mcp-server/src/exclusive.ts new file mode 100644 index 00000000..364f1c15 --- /dev/null +++ b/packages/mcp-server/src/exclusive.ts @@ -0,0 +1,25 @@ +/** + * A queue for tool handlers: each call waits until the previous one ended. + * A tool call the client cancelled while it waited (the MCP SDK aborts its + * extra.signal, the handler's last argument) is skipped. + */ +export function createExclusive() { + let tail: Promise = Promise.resolve() + return function exclusive Promise>( + handler: T, + ): T { + return ((...args: unknown[]) => { + const extra = args.at(-1) as { signal?: AbortSignal } | undefined + const run = tail.then(() => + extra?.signal?.aborted + ? { + content: [{ type: "text", text: "Cancelled." }], + isError: true, + } + : handler(...args), + ) + tail = run.catch(() => {}) + return run + }) as T + } +} diff --git a/packages/mcp-server/src/history.ts b/packages/mcp-server/src/history.ts index 492eda1b..7fff09a8 100644 --- a/packages/mcp-server/src/history.ts +++ b/packages/mcp-server/src/history.ts @@ -3,10 +3,18 @@ * Stores {xml, svg} entries in a circular buffer */ -import { log } from "./logger.js" +import { log } from "./logger.ts" const MAX_HISTORY = 20 -const historyStore = new Map>() + +interface HistoryEntry { + id: number // Stable across shifts of the circular buffer + xml: string + svg: string +} + +let nextEntryId = 0 +const historyStore = new Map() export function addHistory(sessionId: string, xml: string, svg = ""): number { let history = historyStore.get(sessionId) @@ -15,13 +23,16 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number { historyStore.set(sessionId, history) } - // Dedupe: skip if same as last entry + // Dedupe: skip if same as last entry (any other change, also of page + // settings only, is a new version; keepInHistory leaves out draw.io's + // copy of a server write) const last = history[history.length - 1] - if (last?.xml === xml) { + if (last && last.xml === xml) { + if (svg && !last.svg) last.svg = svg return history.length - 1 } - history.push({ xml, svg }) + history.push({ id: nextEntryId++, xml, svg }) // Circular buffer if (history.length > MAX_HISTORY) { @@ -32,29 +43,35 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number { return history.length - 1 } -export function getHistory( - sessionId: string, -): Array<{ xml: string; svg: string }> { +export function getHistory(sessionId: string): HistoryEntry[] { return historyStore.get(sessionId) || [] } +/** Look up an entry by its id; the array index shifts as old entries drop. */ export function getHistoryEntry( sessionId: string, - index: number, -): { xml: string; svg: string } | undefined { - const history = historyStore.get(sessionId) - return history?.[index] + id: number, +): HistoryEntry | undefined { + return historyStore.get(sessionId)?.find((entry) => entry.id === id) } export function clearHistory(sessionId: string): void { historyStore.delete(sessionId) } -export function updateLastHistorySvg(sessionId: string, svg: string): boolean { +/** + * Give the last entry the image the browser took of shownXml, the diagram + * it just loaded, when that entry is this diagram + */ +export function updateLastHistorySvg( + sessionId: string, + svg: string, + shownXml: string, +): boolean { const history = historyStore.get(sessionId) if (!history || history.length === 0) return false const last = history[history.length - 1] - if (!last.svg) { + if (!last.svg && last.xml === shownXml) { last.svg = svg return true } diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index f23a2455..26170052 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -3,7 +3,11 @@ * Serves draw.io embed with state sync and history UI */ +import { randomUUID } from "node:crypto" +import { readFileSync } from "node:fs" import http from "node:http" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB @@ -12,29 +16,44 @@ function readBody( res: http.ServerResponse, cb: (body: string) => void, ): void { - let body = "" + // Decode once at the end: a multi-byte UTF-8 character can be split + // across two chunks. + const chunks: Buffer[] = [] let size = 0 + let tooLarge = false req.on("data", (chunk: Buffer) => { + if (tooLarge) return size += chunk.length if (size > MAX_BODY_BYTES) { - res.writeHead(413, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "Payload too large" })) - req.destroy() + // Read the rest without keeping it and answer at the end: a + // connection closed mid-upload reaches the browser as a network + // error, without this answer + tooLarge = true + chunks.length = 0 return } - body += chunk + chunks.push(chunk) + }) + req.on("end", () => { + if (tooLarge) { + res.writeHead(413, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Payload too large" })) + return + } + cb(Buffer.concat(chunks).toString("utf8")) }) - req.on("end", () => cb(body)) } +import { contentFingerprint } from "./edit-gate.ts" import { addHistory, clearHistory, getHistory, getHistoryEntry, updateLastHistorySvg, -} from "./history.js" -import { log } from "./logger.js" +} from "./history.ts" +import { log } from "./logger.ts" +import { BLANK_MXFILE } from "./pages.ts" // Configurable draw.io embed URL for private deployments const DRAWIO_BASE_URL = @@ -52,19 +71,17 @@ function getOrigin(url: string): string { const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL) -// Minimal blank diagram used to bootstrap new sessions. -// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent. -const DEFAULT_DIAGRAM_XML = `` - // Normalize URL for iframe src - ensure no double slashes function normalizeUrl(url: string): string { // Remove trailing slash to avoid double slashes return url.replace(/\/$/, "") } -function isLikelyMcpSessionId(sessionId: string): boolean { - // Keep this cheap and conservative to avoid creating state for arbitrary IDs. - return sessionId.startsWith("mcp-") && sessionId.length <= 128 +// Session ids look like "mcp--" (start_session). +// Only this charset is accepted, because ids are written into the page's +// HTML and script and into the redirect Location header. +function isValidSessionId(sessionId: string): boolean { + return /^mcp-[a-z0-9-]{1,64}$/.test(sessionId) } // Find the most recent active session (for auto-redirect when no sessionId provided) @@ -78,25 +95,75 @@ function getMostRecentSessionId(): string | null { return mostRecent?.id || null } +/** + * Give a session whose state is gone (it expired, or the MCP process + * restarted) its auto-saved diagram back. The MCP tools call this before + * they read the state, so they never build on an older copy and then + * overwrite the file. Not a change worth saving again. + */ +export function restoreSavedSession(sessionId: string): void { + if (stateStore.has(sessionId) || !isValidSessionId(sessionId)) return + const saved = savedStateLoader?.(sessionId) + if (saved) setState(sessionId, saved, undefined, false, false) +} + function ensureSessionStateInitialized(sessionId: string): void { if (!sessionId) return - if (!isLikelyMcpSessionId(sessionId)) return + if (!isValidSessionId(sessionId)) return + restoreSavedSession(sessionId) if (stateStore.has(sessionId)) return - setState(sessionId, DEFAULT_DIAGRAM_XML) + // Not a change worth saving: the browser fills it on its next push + // A blank diagram keeps the draw.io spinner (spin=1) from waiting + // forever when no load(xml) is ever sent + setState(sessionId, BLANK_MXFILE, undefined, false, false) + // Nothing is known about this session: a tab that still shows it keeps + // its diagram + const state = stateStore.get(sessionId) + if (state) state.blank = true } interface SessionState { xml: string version: number + // Made when the state is created (first use, or again after it expired + // or the MCP process restarted) and kept by every write. A tab tells by + // it that the server lost what it knew, and every push names the state + // it was based on, so one based on a lost state is refused. + stateId: string + // Created blank because nothing was saved; cleared by the first write + blank?: boolean + // Version of the last write the browser did not make itself (AI edit, + // restore). A browser push based on an older version is rejected. + serverVersion?: number + // The XML of that write: what a thumbnail taken after loading it shows + serverXml?: string + // The browser saved a change of the user's since that write (a sync + // reply is no change) + userEdited?: boolean lastUpdated: Date + lastPolled?: number // Last browser poll; an open tab keeps the session alive svg?: string // Cached SVG from last browser save syncRequested?: number // Timestamp when sync requested, cleared when browser responds - exportFormat?: "png" | "svg" // Set by MCP tool to request browser export + exportFormat?: ExportFormat // Set by MCP tool to request browser export exportXml?: string // Single-page projection to load before a page-targeted export + exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only) + exportId?: number // Number of the pending export, echoed with its result exportData?: string // Base64/SVG data returned by browser after export } +/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */ +export type ExportFormat = "png" | "svg" | "xmlsvg" + +/** + * draw.io's PNG export takes these directly: width caps the image size + * (never upscales), pageId renders a page other than the one on screen. + */ +export interface ExportOptions { + width?: number + pageId?: string +} + export const stateStore = new Map() let server: http.Server | null = null @@ -108,23 +175,76 @@ export function getState(sessionId: string): SessionState | undefined { return stateStore.get(sessionId) } -export function setState(sessionId: string, xml: string, svg?: string): number { +// Called after every state change (AI write, browser push, restore) +let stateListener: ((sessionId: string, xml: string) => void) | null = null + +export function onStateChange( + listener: (sessionId: string, xml: string) => void, +): void { + stateListener = listener +} + +// Reads a session's saved diagram when its state is created again (it +// expired, or the MCP process restarted) +let savedStateLoader: ((sessionId: string) => string | null) | null = null + +export function onSessionRecreate( + loader: (sessionId: string) => string | null, +): void { + savedStateLoader = loader +} + +export function setState( + sessionId: string, + xml: string, + svg?: string, + fromBrowser = false, + notify = true, +): number { const existing = stateStore.get(sessionId) const newVersion = (existing?.version || 0) + 1 stateStore.set(sessionId, { xml, version: newVersion, + stateId: existing?.stateId ?? randomUUID(), + serverVersion: fromBrowser ? existing?.serverVersion : newVersion, + serverXml: fromBrowser ? existing?.serverXml : xml, + userEdited: fromBrowser ? existing?.userEdited : false, lastUpdated: new Date(), - svg: svg || existing?.svg, // Preserve cached SVG if not provided + lastPolled: existing?.lastPolled, + // The image of this XML, never an older one's: a write without an + // image (AI write, sync reply) leaves none until the browser sends + // it, unless it is the same XML + svg: + svg || + (existing && existing.xml === xml ? existing.svg : undefined), syncRequested: undefined, // Clear sync request when browser pushes state exportFormat: existing?.exportFormat, // Preserve pending export request exportXml: existing?.exportXml, // Preserve pending projection + exportOptions: existing?.exportOptions, + exportId: existing?.exportId, exportData: existing?.exportData, // Preserve export result }) log.debug(`State updated: session=${sessionId}, version=${newVersion}`) + if (notify) stateListener?.(sessionId, xml) return newVersion } +/** + * Keep the session's diagram in History before a write replaces it. + * Nothing to keep when the browser saved no change of the user's since the + * last server write and History ends with that write: the state is that + * write, or draw.io's own copy of it from a sync (other text, same diagram). + */ +export function keepInHistory(sessionId: string, xml: string, svg = ""): void { + const state = stateStore.get(sessionId) + const last = getHistory(sessionId).at(-1) + if (state && !state.userEdited && last && last.xml === state.serverXml) { + return + } + addHistory(sessionId, xml, svg) +} + /** * Ask the browser bridge to export the current diagram as png/svg. * @@ -138,17 +258,24 @@ export function setState(sessionId: string, xml: string, svg?: string): number { */ export function requestExport( sessionId: string, - format: "png" | "svg", + format: ExportFormat, projectionXml?: string, + options?: ExportOptions, ): boolean { const state = stateStore.get(sessionId) if (!state) return false state.exportData = undefined state.exportXml = projectionXml + state.exportOptions = options state.exportFormat = format + // The browser sends this back with the result, so a late result of an + // export that timed out is not taken for this one + state.exportId = ++lastExportId return true } +let lastExportId = 0 + export function requestSync(sessionId: string): boolean { const state = stateStore.get(sessionId) if (state) { @@ -222,7 +349,11 @@ export function stopHttpServer(): void { function cleanupExpiredSessions(): void { const now = Date.now() for (const [sessionId, state] of stateStore) { - if (now - state.lastUpdated.getTime() > SESSION_TTL) { + const lastActive = Math.max( + state.lastUpdated.getTime(), + state.lastPolled ?? 0, + ) + if (now - lastActive > SESSION_TTL) { stateStore.delete(sessionId) clearHistory(sessionId) log.info(`Cleaned up expired session: ${sessionId}`) @@ -245,7 +376,49 @@ function handleRequest( req: http.IncomingMessage, res: http.ServerResponse, ): void { - const url = new URL(req.url || "/", `http://localhost:${serverPort}`) + // A bad request must never take down the MCP process + try { + routeRequest(req, res) + } catch (err) { + log.error("HTTP request failed:", err) + if (!res.headersSent) res.writeHead(500) + res.end() + } +} + +// Serve only requests addressed to localhost, sent by the preview page +// itself (Origin is the address it was opened at, the Host) or by a +// non-browser client (no Origin header). This blocks DNS rebinding, other +// websites, and pages on other localhost ports, whose plain text POSTs need +// no CORS preflight. +function isLocalRequest(req: http.IncomingMessage): boolean { + const host = req.headers.host ?? "" + const origin = req.headers.origin + return ( + /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host) && + (origin === undefined || origin === `http://${host}`) + ) +} + +function routeRequest( + req: http.IncomingMessage, + res: http.ServerResponse, +): void { + let url: URL + try { + url = new URL(req.url || "/", `http://localhost:${serverPort}`) + } catch { + // e.g. "//" is not a valid URL path + res.writeHead(400) + res.end("Bad Request") + return + } + + if (!isLocalRequest(req)) { + res.writeHead(403) + res.end("Forbidden") + return + } const requestOrigin = req.headers.origin if (requestOrigin === `http://localhost:${serverPort}`) { @@ -262,12 +435,19 @@ function handleRequest( if (url.pathname === "/" || url.pathname === "/index.html") { const sessionId = url.searchParams.get("mcp") || "" + if (sessionId && !isValidSessionId(sessionId)) { + res.writeHead(400) + res.end("Invalid session id") + return + } // Auto-redirect to most recent session if no sessionId provided if (!sessionId) { const recentSessionId = getMostRecentSessionId() if (recentSessionId) { - res.writeHead(302, { Location: `/?mcp=${recentSessionId}` }) + res.writeHead(302, { + Location: `/?mcp=${encodeURIComponent(recentSessionId)}`, + }) res.end() return } @@ -305,14 +485,21 @@ function handleStateApi( } ensureSessionStateInitialized(sessionId) const state = stateStore.get(sessionId) + // Polling counts as activity, so a session stays alive while its + // tab is open + if (state) state.lastPolled = Date.now() res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ xml: state?.xml || null, version: state?.version || 0, + stateId: state?.stateId ?? null, + blank: !!state?.blank, syncRequested: !!state?.syncRequested, exportFormat: state?.exportFormat || null, exportXml: state?.exportXml || null, + exportOptions: state?.exportOptions || null, + exportId: state?.exportId ?? null, }), ) } else if (req.method === "POST") { @@ -320,29 +507,124 @@ function handleStateApi( try { const data = JSON.parse(body) const { sessionId } = data - if (!sessionId) { + if (!sessionId || !isValidSessionId(sessionId)) { res.writeHead(400, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "sessionId required" })) + res.end( + JSON.stringify({ error: "valid sessionId required" }), + ) return } // Browser is returning export data (png/svg) if (data.exportData !== undefined) { const state = stateStore.get(sessionId) - if (state) { + if (state && data.exportId === state.exportId) { state.exportData = data.exportData state.exportFormat = undefined state.exportXml = undefined + state.exportOptions = undefined + state.exportId = undefined log.debug( `Export data received for session=${sessionId}`, ) + } else if (state) { + log.debug( + `Ignored a late export result for session=${sessionId}`, + ) } res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true })) return } - const version = setState(sessionId, data.xml, data.svg) + // A push can come before the tab's first poll after a + // restart: recover the saved file first, so it is compared + // with that and never overwrites it unseen + ensureSessionStateInitialized(sessionId) + const current = stateStore.get(sessionId) + + // A tab of this version names the state its push is based + // on. Another state (the server lost the one it knew, or + // the tab has not polled yet): refused, and the tab's next + // poll decides whose diagram wins. + if (current && "stateId" in data) { + if (data.stateId !== current.stateId) { + res.writeHead(409, { + "Content-Type": "application/json", + }) + res.end( + JSON.stringify({ + error: "Session was recreated", + stateChanged: true, + version: current.version, + }), + ) + return + } + // What a recovering tab showed: kept in history only + if (data.source === "recover") { + const saved = + typeof data.xml === "string" && + !!data.xml && + data.xml !== current.xml + if (saved) { + addHistory(sessionId, data.xml, data.svg || "") + } + res.writeHead(409, { + "Content-Type": "application/json", + }) + res.end( + JSON.stringify({ + error: "Diagram changed on the server", + version: current.version, + savedToHistory: saved, + }), + ) + return + } + } + + // The browser edited a version older than the latest AI write + // (it has not loaded that write yet). Keep the AI write; the + // browser loads it on its next poll. A sync reply is also + // stale after a newer write of the browser's own (a user + // edit saved while the export ran). + if ( + typeof data.baseVersion === "number" && + (data.baseVersion < (current?.serverVersion ?? 0) || + (data.source === "sync" && + data.baseVersion < (current?.version ?? 0))) + ) { + let savedToHistory = false + if (data.source === "sync") { + // A stale sync reply: the store already holds the + // newer AI write, so the sync is done. + if (current) current.syncRequested = undefined + } else if (typeof data.xml === "string" && data.xml) { + // A user edit lost the race with an AI write. Keep + // it in history so the user can restore it. + addHistory(sessionId, data.xml, data.svg || "") + savedToHistory = true + } + res.writeHead(409, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + error: "Diagram changed on the server", + version: current?.version, + savedToHistory, + }), + ) + return + } + + if (typeof data.xml !== "string") { + res.writeHead(400, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "xml must be a string" })) + return + } + const version = setState(sessionId, data.xml, data.svg, true) + const saved = stateStore.get(sessionId) + if (saved && data.source !== "sync") saved.userEdited = true res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, version })) } catch { @@ -378,7 +660,11 @@ function handleHistoryApi( res.writeHead(200, { "Content-Type": "application/json" }) res.end( JSON.stringify({ - entries: history.map((entry, i) => ({ index: i, svg: entry.svg })), + entries: history.map((entry, i) => ({ + index: i, + id: entry.id, + svg: entry.svg, + })), count: history.length, }), ) @@ -396,26 +682,36 @@ function handleRestoreApi( readBody(req, res, (body) => { try { - const { sessionId, index } = JSON.parse(body) - if (!sessionId || index === undefined) { + const { sessionId, id } = JSON.parse(body) + if (!sessionId || typeof id !== "number") { res.writeHead(400, { "Content-Type": "application/json" }) - res.end( - JSON.stringify({ error: "sessionId and index required" }), - ) + res.end(JSON.stringify({ error: "sessionId and id required" })) return } - const entry = getHistoryEntry(sessionId, index) + const entry = getHistoryEntry(sessionId, id) if (!entry) { res.writeHead(404, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "Entry not found" })) return } + // Edits in the browser since the last entry are not in history + // yet: keep them, so the restore can be undone + // (any state besides a blank page; a cleared document with its + // own pages counts) + const current = stateStore.get(sessionId) + if ( + current && + contentFingerprint(current.xml) !== + contentFingerprint(BLANK_MXFILE) + ) { + keepInHistory(sessionId, current.xml, current.svg) + } const newVersion = setState(sessionId, entry.xml) addHistory(sessionId, entry.xml, entry.svg) - log.info(`Restored session ${sessionId} to index ${index}`) + log.info(`Restored session ${sessionId} to history entry ${id}`) res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true, newVersion })) @@ -438,14 +734,27 @@ function handleHistorySvgApi( readBody(req, res, (body) => { try { - const { sessionId, svg } = JSON.parse(body) + const { sessionId, svg, stateId, version } = JSON.parse(body) if (!sessionId || !svg) { res.writeHead(400, { "Content-Type": "application/json" }) res.end(JSON.stringify({ error: "sessionId and svg required" })) return } - updateLastHistorySvg(sessionId, svg) + // The browser took it of the server write it loaded, named by + // the state and version. One that arrives after the next server + // write, or for a state since lost, is dropped; a browser write + // since (a sync reply) leaves that write's image valid. + const state = stateStore.get(sessionId) + if ( + state && + state.stateId === stateId && + state.serverVersion === version && + state.serverXml !== undefined + ) { + updateLastHistorySvg(sessionId, svg, state.serverXml) + if (state.xml === state.serverXml) state.svg = svg + } res.writeHead(200, { "Content-Type": "application/json" }) res.end(JSON.stringify({ success: true })) } catch { @@ -455,571 +764,34 @@ function handleHistorySvgApi( }) } -function getHtmlPage(sessionId: string): string { - return ` - - - - - Next AI Draw.io - - - -
- - -
-
- -
-
- -
- - -` +function loadPreviewTemplate(): string { + if (!previewTemplate) { + const read = (file: string) => + readFileSync(join(PREVIEW_DIR, file), "utf-8") + previewTemplate = read("index.html") + .replace("{{CSS}}", () => read("preview.css")) + .replace("{{SCRIPT}}", () => read("preview.js")) + } + return previewTemplate +} + +/** A JSON string literal that is safe inside a + + + \ No newline at end of file diff --git a/packages/mcp-server/src/preview/preview.css b/packages/mcp-server/src/preview/preview.css new file mode 100644 index 00000000..15e85066 --- /dev/null +++ b/packages/mcp-server/src/preview/preview.css @@ -0,0 +1,190 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { width: 100%; height: 100%; overflow: hidden; } +#container { width: 100%; height: 100%; display: flex; flex-direction: column; } +#header { + padding: 0 20px; height: 52px; + background: linear-gradient(to bottom, #ffffff, #fafbfc); + border-bottom: 1px solid #e8ecf0; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + display: flex; justify-content: space-between; align-items: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + position: relative; z-index: 10; +} +#header .brand { + display: flex; align-items: center; gap: 10px; +} +#header .logo { + width: 28px; height: 28px; border-radius: 6px; + background: #18181b; + display: flex; align-items: center; justify-content: center; + overflow: hidden; +} +#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); } +#header .title { + font-size: 15px; font-weight: 600; color: #1a1a2e; + letter-spacing: -0.3px; +} +#header .session { + font-size: 11px; color: #8b95a5; font-weight: 400; + background: #f1f3f9; padding: 3px 8px; border-radius: 4px; + margin-left: 12px; font-family: 'SF Mono', Monaco, monospace; +} +#header .right { display: flex; align-items: center; gap: 12px; } +#save-btn { + display: flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 8px; font-size: 13px; + background: linear-gradient(to bottom, #18181b, #27272a); + color: white; border: none; cursor: pointer; + font-weight: 500; font-family: inherit; + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1); + transition: all 0.15s ease; +} +#save-btn svg { width: 14px; height: 14px; } +#save-btn:hover { + background: linear-gradient(to bottom, #27272a, #3f3f46); + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1); +} +#save-btn:active { transform: translateY(0); } +#save-btn:disabled, #history-btn:disabled { + background: #e5e7eb; color: #9ca3af; + cursor: not-allowed; transform: none; box-shadow: none; +} +#history-btn { + display: flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 8px; font-size: 13px; + background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7; + cursor: pointer; font-weight: 500; font-family: inherit; + transition: all 0.15s ease; +} +#history-btn svg { width: 14px; height: 14px; } +#history-btn:hover { + background: #e4e4e7; border-color: #d4d4d8; +} +#drawio { flex: 1; border: none; } +#history-modal, #save-modal { + display: none; position: fixed; inset: 0; + background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); + z-index: 2000; align-items: center; justify-content: center; +} +#history-modal.open, #save-modal.open { display: flex; } +.modal-content { + background: white; border-radius: 16px; + width: 90%; max-width: 480px; max-height: 70vh; + display: flex; flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25); + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + animation: modalIn 0.2s ease-out; +} +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +.modal-header { + padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5; +} +.modal-header h2 { + font-size: 17px; font-weight: 600; margin: 0; color: #18181b; + letter-spacing: -0.3px; +} +.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; } +.modal-footer { + padding: 16px 24px; border-top: 1px solid #f1f3f5; + display: flex; gap: 10px; justify-content: flex-end; +} +.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.history-item { + border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px; + cursor: pointer; text-align: center; transition: all 0.15s ease; + background: #fafafa; +} +.history-item:hover { border-color: #a1a1aa; background: white; } +.history-item.selected { + border-color: #18181b; background: white; + box-shadow: 0 0 0 3px rgba(24,24,27,0.1); +} +.history-item .thumb { + aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + margin-bottom: 6px; overflow: hidden; +} +.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; } +.history-item .label { font-size: 11px; color: #71717a; font-weight: 500; } +.btn { + padding: 9px 18px; border-radius: 8px; font-size: 13px; + cursor: pointer; border: none; font-weight: 500; + font-family: inherit; transition: all 0.15s ease; +} +.btn-primary { + background: linear-gradient(to bottom, #18181b, #27272a); + color: white; + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1); +} +.btn-primary:hover { + background: linear-gradient(to bottom, #27272a, #3f3f46); + transform: translateY(-1px); +} +.btn-primary:disabled { + background: #e4e4e7; color: #a1a1aa; + cursor: not-allowed; transform: none; box-shadow: none; +} +.btn-secondary { + background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7; +} +.btn-secondary:hover { background: #e4e4e7; } +.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; } +.form-group { margin-bottom: 18px; } +.form-group label { + display: block; font-size: 13px; font-weight: 500; + margin-bottom: 8px; color: #3f3f46; +} +.form-group select, .form-group input { + width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7; + border-radius: 8px; font-size: 14px; outline: none; + font-family: inherit; background: white; + transition: all 0.15s ease; +} +.form-group select:focus, .form-group input:focus { + border-color: #18181b; + box-shadow: 0 0 0 3px rgba(24,24,27,0.08); +} +.filename-group { display: flex; } +.filename-group input { border-radius: 8px 0 0 8px; border-right: none; } +#notice { + display: none; position: fixed; left: 50%; bottom: 24px; + transform: translateX(-50%); z-index: 3000; max-width: 480px; + padding: 10px 16px; border-radius: 8px; font-size: 13px; + background: #18181b; color: white; + font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); +} +#notice.open { display: block; } +.filename-group .ext { + padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7; + border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a; + font-family: 'SF Mono', Monaco, monospace; +} + +/* Dark mode follows the system, like draw.io with dark=auto */ +@media (prefers-color-scheme: dark) { + #header { background: #1c1c20; border-bottom-color: #2e2e34; box-shadow: none; } + #header .title { color: #f4f4f5; } + #header .session { background: #2a2a30; color: #a1a1aa; } + #history-btn, .btn-secondary { background: #2a2a30; color: #e4e4e7; border-color: #3f3f46; } + #history-btn:hover, .btn-secondary:hover { background: #3f3f46; border-color: #52525b; } + #save-btn, .btn-primary { background: #f4f4f5; color: #18181b; } + #save-btn:hover, .btn-primary:hover { background: #e4e4e7; } + #save-btn:disabled, #history-btn:disabled, .btn-primary:disabled { background: #2a2a30; color: #71717a; } + .modal-content { background: #1c1c20; color: #e4e4e7; } + .modal-header, .modal-footer { border-color: #2e2e34; } + .modal-header h2 { color: #f4f4f5; } + .history-item { background: #27272a; border-color: #3f3f46; } + .history-item:hover { background: #2e2e34; border-color: #71717a; } + .history-item.selected { background: #2e2e34; border-color: #f4f4f5; box-shadow: 0 0 0 3px rgba(244,244,245,0.15); } + .history-item .label, .empty { color: #a1a1aa; } + .form-group label { color: #d4d4d8; } + .form-group select, .form-group input { background: #27272a; color: #f4f4f5; border-color: #3f3f46; } + .form-group select:focus, .form-group input:focus { border-color: #a1a1aa; box-shadow: 0 0 0 3px rgba(244,244,245,0.1); } + .filename-group .ext { background: #2a2a30; border-color: #3f3f46; color: #a1a1aa; } + #notice { background: #f4f4f5; color: #18181b; } +} diff --git a/packages/mcp-server/src/preview/preview.js b/packages/mcp-server/src/preview/preview.js new file mode 100644 index 00000000..0a88fb05 --- /dev/null +++ b/packages/mcp-server/src/preview/preview.js @@ -0,0 +1,527 @@ +const iframe = document.getElementById('drawio'); +let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; +// The server state this tab is in step with (see stateId in http-server.ts); +// null until the first poll +let stateId = null; +// The newest diagram on the canvas, saved to the server or not: lastXml is +// the last one the server has +let latestXml = null; +let pushFailing = false; // the last push could not reach the server +const pushesInFlight = []; // XML of pushes not answered yet +// After recovery replaced the canvas, until draw.io reports the load: an +// autosave still on its way belongs to the canvas being replaced +let awaitingLoad = false; +let pollSeq = 0, lastHandledPoll = 0; // polls overlap; older answers are dropped +let pendingSvgExport = null; +let pendingSvgBase = 0; // version the pending autosave was based on +let pendingSvgStateId = null; // and the state it belonged to +// The latest thumbnail export of a loaded server write: its number (echoed +// by draw.io), the state and version it showed, and the XML loaded +let thumbExportSeq = 0, thumbExport = null; +let pendingMcpExport = null; // 'png', 'svg' or 'xmlsvg' when MCP requested export +let mcpExportSeq = 0; // number of the latest MCP export +let mcpExportId = null; // the server's id for it, sent back with the result +let projectionExportActive = false; // page-targeted export: showing a transient single-page projection +let forceReload = false; // reload the server state on the next poll even if the version is unchanged +let noticeTimer = null; + +window.addEventListener('message', (e) => { + if (e.origin !== DRAWIO_ORIGIN) return; + try { + const msg = JSON.parse(e.data); + if (msg.event === 'init') { + isReady = true; + if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } + } else if (msg.event === 'load') { + awaitingLoad = false; + } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml) { + // Ignore autosave while a single-page projection is on screen + // for a page-targeted export — otherwise we'd push the + // transient projection back as the canonical session state. + if (projectionExportActive) return; + // An edit of the canvas that recovery is replacing: kept in + // History, never over the recovered diagram + if (awaitingLoad) { pushState(msg.xml, '', currentVersion, 'recover'); return; } + // Also an edit undone back to what the server has + latestXml = msg.xml; + if (msg.xml === lastXml) return; + // Request SVG export, then push state with SVG. Remember the + // version and state this edit is based on, so the server can + // reject it if the AI wrote a newer version that is not loaded + // yet, or if it lost that state. + pendingSvgExport = msg.xml; + pendingSvgBase = currentVersion; + pendingSvgStateId = stateId; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); + // Fallback if export doesn't respond + setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase, 'edit', pendingSvgStateId); pendingSvgExport = null; } }, 2000); + } else if (msg.event === 'export' && msg.format === 'xml') { + // Sync export requested by the server (get_diagram). + // draw.io returns the XML in msg.xml, with no msg.data. A late + // reply to an earlier request was taken at another version. + if (pendingSyncExport && msg.xml && msg.message?.syncExport === syncExportSeq) { + pendingSyncExport = false; + // Push with the version the export was taken at: a + // newer AI write may have loaded meanwhile, and this + // older XML must not overwrite it. + pushState(msg.xml, '', pendingSyncBase, 'sync', pendingSyncStateId); + } + } else if (msg.event === 'export' && msg.data) { + // Handle MCP server export request (png/svg). fireExport tags + // the request with mcpExport and draw.io echoes the request + // back in msg.message, which tells it apart from autosave and + // preview SVG exports. + if (msg.message && msg.message.mcpExport) { + // A late reply to an export that already timed out + if (msg.message.mcpExport !== mcpExportSeq) return; + const d = msg.data; + const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png'); + const isSvg = (pendingMcpExport === 'svg' || pendingMcpExport === 'xmlsvg') && (d.startsWith('data:image/svg') || d.startsWith(' {}).finally(() => { + // The timeout already ended this export + if (seq !== mcpExportSeq) return; + pendingMcpExport = null; + // Page-targeted export: restore the user's real + // multi-page document now that we have the image. + restoreFromProjection(); + }); + } + return; + } + // Handle file download export (PNG/SVG only, drawio uses + // lastXml directly). Tagged with dlExport like mcpExport, + // so an autosave SVG export can never be saved instead. + if (msg.message && msg.message.dlExport) { + if (!pendingDownload) return; + const dl = pendingDownload; + pendingDownload = null; + let dataUrl = msg.data; + if (!dataUrl.startsWith('data:')) { + const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml'; + dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data))); + } + const a = document.createElement('a'); + a.href = dataUrl; a.download = dl.filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + saveModal.classList.remove('open'); + saveConfirmBtn.disabled = false; + saveConfirmBtn.textContent = 'Save'; + return; + } + // Handle SVG export + let svg = msg.data; + if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + if (msg.message && msg.message.thumbExport) { + // Only for the latest load, and only if the canvas still + // shows it: the export pictures the canvas as it is now + const t = thumbExport; + if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return; + thumbExport = null; + fetch('/api/history-svg', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version }) + }).catch(() => {}); + } else if (pendingSvgExport) { + const xml = pendingSvgExport; + pendingSvgExport = null; + pushState(xml, svg, pendingSvgBase, 'edit', pendingSvgStateId); + } + } + } catch {} +}); + +function loadDiagram(xml, capturePreview = false) { + if (!isReady) { pendingXml = xml; return; } + lastXml = xml; + latestXml = xml; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml, autosave: 1 }), '*'); + if (capturePreview) { + // A server write: currentVersion is its version + const t = { n: ++thumbExportSeq, stateId, version: currentVersion, xml }; + thumbExport = t; + setTimeout(() => { + if (thumbExport !== t) return; // a newer load takes its own + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg', thumbExport: t.n }), '*'); + }, 500); + } +} + +// Restore the user's real document after a page-targeted projection +// export by reloading the server state. The server also has any +// autosave that was still in flight when the projection started, +// which a copy taken at that moment would miss. A flag is used +// because a push finishing meanwhile may update currentVersion. +// projectionExportActive stays set until the poll loads the document: +// an edit on the projection before that must not be pushed. +function restoreFromProjection() { + if (!projectionExportActive) return; + forceReload = true; + poll(); +} + +function showNotice(text) { + const el = document.getElementById('notice'); + el.textContent = text; + el.classList.add('open'); + clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => el.classList.remove('open'), 8000); +} + +// source is 'sync' for replies to a server sync request, 'recover' for the +// tab's copy after the server recovered the session, else 'edit'. sid is the +// server state the push is based on. +async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit', sid = stateId) { + if (!sessionId) return; + pushesInFlight.push(xml); + try { + const r = await fetch('/api/state', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid }) + }); + pushFailing = false; + if (r.ok) { + const d = await r.json(); + // An answer about a state this tab has left since, or one that + // comes after a newer version was loaded or saved + if (sid !== stateId || d.version < currentVersion) return; + currentVersion = d.version; + lastXml = xml; + // The canvas changed while this edit was on its way, to + // something no pending autosave will send (an undo back to the + // previous version): send it now. A sync reply is draw.io's + // export of the canvas, in another format than its autosave. + if (latestXml && latestXml !== xml && pendingSvgExport !== latestXml && source === 'edit') { + pushState(latestXml); + } + } + // Over the server's size limit: the image is most of it, so try once + // without it + else if (r.status === 413) { + if (svg) pushState(xml, '', baseVersion, source, sid); + else showNotice('This diagram is too large to save to the MCP server (over 10 MB). Use Download to keep it.'); + } + // 409: the AI wrote a newer version, or the server lost the state + // this push was based on; the next poll sorts it out + else if (r.status === 409) { + const d = await r.json().catch(() => ({})); + if (d.savedToHistory) { + showNotice(source === 'recover' + ? 'The diagram was restored from its saved file. What this tab showed before is in History.' + : 'The AI changed the diagram while you were editing. Your last change was saved in History.'); + } + poll(); + } + } catch (e) { + console.error('Push failed:', e); + if (!pushFailing) { + pushFailing = true; + showNotice("Can't reach the MCP server. Your changes are only in this tab for now; use Download to keep a copy."); + } + } finally { + pushesInFlight.splice(pushesInFlight.indexOf(xml), 1); + } +} + +// The server made a new state for this session: it expired, or the MCP +// process restarted. Decide whose diagram wins. +function recoverState(s) { + stateId = s.stateId; + // The old state's pending work is gone with it + const projectionShown = projectionExportActive; + projectionExportActive = false; + forceReload = false; + pendingMcpExport = null; + pendingSyncExport = false; + const mine = latestXml; + currentVersion = s.version; + if (s.blank || s.xml === lastXml) { + // The server knows nothing, or exactly what this tab last saved: + // the canvas can only be newer, so it wins (edits made while the + // server was down are saved now) + if (projectionShown && mine) { + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: mine, autosave: 1 }), '*'); + expectLoad(); + } + if (mine && mine !== s.xml) pushState(mine, '', s.version); + } else { + // The server has a diagram this tab never showed (an AI write it + // missed, a saved file): show that, and keep this tab's copy in + // History unless it is the same + loadDiagram(s.xml, true); + expectLoad(); + if (mine && mine !== s.xml) pushState(mine, '', s.version, 'recover'); + } +} + +// Until draw.io reports the load (its messages come in order), an autosave +// is from the canvas being replaced; in case no report comes, not for long +function expectLoad() { + awaitingLoad = true; + setTimeout(() => { awaitingLoad = false; }, 5000); +} + +let pendingSyncExport = false; +let pendingSyncBase = 0; // version the pending sync export was taken at +let pendingSyncStateId = null; // and the state it belonged to +let syncExportSeq = 0; // number of the latest sync export + +async function poll() { + if (!sessionId) return; + const seq = ++pollSeq; + try { + const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId)); + if (!r.ok) return; + const s = await r.json(); + // An older answer than one already handled (the interval, the 409 + // handler and the projection restore each poll): it could name a + // state that is gone + if (seq < lastHandledPoll) return; + lastHandledPoll = seq; + if (stateId === null) stateId = s.stateId; + else if (s.stateId && s.stateId !== stateId) recoverState(s); + // Load new diagram from server (before export, so we export latest). + // While a page-targeted projection is on screen, only the restore + // (forceReload) replaces it, so a new version doesn't fight the + // projection; currentVersion stays unadvanced until then, so the + // bump is applied with the real document. + // The tab's own push still on its way is not loaded back: the + // canvas may have moved on since (an undo), and its answer follows + const ownPush = pushesInFlight.includes(s.xml); + if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) { + forceReload = false; + projectionExportActive = false; + currentVersion = s.version; + loadDiagram(s.xml, true); + } + // Handle sync request - server needs fresh state. After the load + // above, so draw.io exports what it just loaded; never while a + // one-page projection is on screen, which would be sent as the + // whole document. Reset after a while in case draw.io never + // answers, so later syncs still run. + if (s.syncRequested && !pendingSyncExport && isReady && !projectionExportActive) { + pendingSyncExport = true; + pendingSyncBase = currentVersion; + pendingSyncStateId = stateId; + // draw.io echoes the request in msg.message, so the reply can + // be matched to this request + const seq = ++syncExportSeq; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml', syncExport: seq }), '*'); + setTimeout(() => { if (seq === syncExportSeq) pendingSyncExport = false; }, 5000); + } + // Handle export request from MCP server (png/svg). + // + // Plain export: capture whatever tab is currently displayed. + // + // Page-targeted export: the server sends a single-page + // projection in s.exportXml. We load it into the iframe, let + // draw.io render it, export, then reload the user's real + // document — all browser-side. The canonical session state is + // never mutated, so there is no server-side restore race and no + // dependence on poll timing. autosave is suppressed while the + // projection is showing (see projectionExportActive guard). + if (s.exportFormat && !pendingMcpExport && isReady) { + pendingMcpExport = s.exportFormat; + const seq = ++mcpExportSeq; + mcpExportId = s.exportId; + const extra = s.exportOptions || {}; + const fireExport = () => { + // mcpExport carries this export's number and is echoed + // back in msg.message (see the handler). PNG: width + // caps the size, pageId picks a page; without one + // draw.io would use the first page. + const exportOpts = pendingMcpExport === 'png' + ? { action: 'export', format: 'png', scale: 2, currentPage: !extra.pageId, ...extra, mcpExport: seq } + : { action: 'export', format: pendingMcpExport, mcpExport: seq }; + iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*'); + }; + if (s.exportXml) { + projectionExportActive = true; + // Load the projection without touching lastXml/server state. + iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*'); + // Let draw.io render the loaded page before exporting + // (same proven settle delay as the AI-preview path). + setTimeout(fireExport, 600); + } else { + fireExport(); + } + // Timeout: reset if draw.io never responds, and restore the + // real document if a projection was left showing. Only for + // this export: a later one may be running by then. + setTimeout(() => { + if (pendingMcpExport && seq === mcpExportSeq) { + pendingMcpExport = null; + restoreFromProjection(); + } + }, 10000); + } + } catch {} +} + +if (sessionId) { poll(); setInterval(poll, 2000); } + +// Save modal +const saveBtn = document.getElementById('save-btn'); +const saveModal = document.getElementById('save-modal'); +const saveFormat = document.getElementById('save-format'); +const saveFilename = document.getElementById('save-filename'); +const saveExt = document.getElementById('save-ext'); +const saveCancelBtn = document.getElementById('save-cancel-btn'); +const saveConfirmBtn = document.getElementById('save-confirm-btn'); +let pendingDownload = null; + +const extMap = { drawio: '.drawio', png: '.png', svg: '.svg', xmlsvg: '.drawio.svg' }; + +saveBtn.onclick = () => { + if (!sessionId || !isReady) return; + // Local date as YYYY-MM-DD, like the web app's default name + saveFilename.value = 'diagram-' + new Date().toLocaleDateString('sv-SE'); + saveModal.classList.add('open'); + saveFilename.focus(); + saveFilename.select(); +}; + +saveFilename.onkeydown = (e) => { + if (e.key === 'Enter' && !e.isComposing && !saveConfirmBtn.disabled) saveConfirmBtn.onclick(); +}; + +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (saveModal.classList.contains('open')) saveCancelBtn.onclick(); + if (historyModal.classList.contains('open')) cancelBtn.onclick(); +}); + +saveFormat.onchange = () => { + saveExt.textContent = extMap[saveFormat.value] || '.drawio'; +}; + +saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); }; +saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); }; + +saveConfirmBtn.onclick = () => { + const format = saveFormat.value; + const filename = (saveFilename.value.trim() || 'diagram') + extMap[format]; + saveConfirmBtn.disabled = true; + saveConfirmBtn.textContent = 'Exporting...'; + + if (format === 'drawio') { + // Use the XML directly instead of requesting export (avoids race with SVG exports). + // session.xml is canonically after the multi-page refactor, + // so no wrapper injection is needed. The legacy fallback below + // remains only for documents that somehow slipped past + // normalisation (e.g. an older session loaded from external state). + // The canvas as it is, also edits not saved to the server yet + let xmlData = latestXml || lastXml || ''; + if (xmlData && !xmlData.includes(''; + } + const blob = new Blob([xmlData], { type: 'application/xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + URL.revokeObjectURL(url); + saveModal.classList.remove('open'); + saveConfirmBtn.disabled = false; + saveConfirmBtn.textContent = 'Save'; + } else if (format === 'png') { + pendingDownload = { format: 'png', filename }; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2, currentPage: true, dlExport: true }), '*'); + setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000); + } else { + // svg, or xmlsvg: an SVG with the diagram embedded, which draw.io can open again + pendingDownload = { format, filename }; + iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format, dlExport: true }), '*'); + setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000); + } +}; + +// History UI +const historyBtn = document.getElementById('history-btn'); +const historyModal = document.getElementById('history-modal'); +const historyGrid = document.getElementById('history-grid'); +const historyEmpty = document.getElementById('history-empty'); +const restoreBtn = document.getElementById('restore-btn'); +const cancelBtn = document.getElementById('cancel-btn'); +let historyData = [], selectedId = null; + +historyBtn.onclick = async () => { + if (!sessionId) return; + try { + const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId)); + if (r.ok) { + const d = await r.json(); + historyData = d.entries || []; + renderHistory(); + } + } catch {} + historyModal.classList.add('open'); +}; + +cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedId = null; restoreBtn.disabled = true; }; +historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); }; + +function renderHistory() { + if (historyData.length === 0) { + historyGrid.style.display = 'none'; + historyEmpty.style.display = 'block'; + return; + } + historyGrid.style.display = 'grid'; + historyEmpty.style.display = 'none'; + // Built element by element: a stored image is never read as HTML, and + // only an SVG data URL is shown as one + historyGrid.replaceChildren(...historyData.map((e) => { + const item = document.createElement('div'); + item.className = 'history-item'; + item.dataset.id = String(e.id); + const thumb = document.createElement('div'); + thumb.className = 'thumb'; + if (typeof e.svg === 'string' && e.svg.startsWith('data:image/svg+xml;base64,')) { + const img = document.createElement('img'); + img.src = e.svg; + thumb.appendChild(img); + } else { + thumb.textContent = '#' + e.index; + } + const label = document.createElement('div'); + label.className = 'label'; + label.textContent = '#' + e.index; + item.append(thumb, label); + return item; + })); + historyGrid.querySelectorAll('.history-item').forEach(item => { + item.onclick = () => { + const id = parseInt(item.dataset.id); + if (selectedId === id) { selectedId = null; restoreBtn.disabled = true; } + else { selectedId = id; restoreBtn.disabled = false; } + historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.id) === selectedId)); + }; + }); +} + +restoreBtn.onclick = async () => { + if (selectedId === null) return; + restoreBtn.disabled = true; + restoreBtn.textContent = 'Restoring...'; + try { + const r = await fetch('/api/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, id: selectedId }) + }); + if (r.ok) { cancelBtn.onclick(); await poll(); } + else { showNotice('Restore failed. Please try again.'); } + } catch { showNotice('Restore failed. Please try again.'); } + restoreBtn.textContent = 'Restore'; +}; diff --git a/packages/mcp-server/src/shape-library.ts b/packages/mcp-server/src/shape-library.ts new file mode 100644 index 00000000..dc1d935c --- /dev/null +++ b/packages/mcp-server/src/shape-library.ts @@ -0,0 +1,73 @@ +/** + * Shape and icon library docs (docs/shape-libraries/*.md), the same files + * the web app's get_shape_library tool reads (app/api/chat/route.ts). + */ + +import { readFile } from "node:fs/promises" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +export const SHAPE_LIBRARY_GROUPS: Record = { + Cloud: [ + "aws4", + "azure2", + "gcp2", + "alibaba_cloud", + "openstack", + "salesforce", + ], + Networking: ["cisco19", "network", "kubernetes", "vvd", "rack"], + Business: ["bpmn", "lean_mapping"], + General: ["flowchart", "basic", "arrows2", "infographic", "sitemap"], + "UI/Mockups": ["android", "material_design"], + Enterprise: ["citrix", "sap", "mscae", "atlassian"], + Engineering: ["fluidpower", "electrical", "pid", "cabinets", "floorplan"], + Icons: ["webicons"], +} + +/** The libraries by group, one "- Group: a, b" line each, for tool descriptions */ +export const SHAPE_LIBRARY_LIST = Object.entries(SHAPE_LIBRARY_GROUPS) + .map(([group, names]) => `- ${group}: ${names.join(", ")}`) + .join("\n") + +const LIBRARIES = new Set(Object.values(SHAPE_LIBRARY_GROUPS).flat()) + +/** + * The build copies the docs to dist/shape-libraries; running from src (tsx) + * reads them from the repository instead. The web app passes its own folder. + */ +function libraryDirs(dir?: string): string[] { + if (dir) return [dir] + const here = dirname(fileURLToPath(import.meta.url)) + return [ + join(here, "shape-libraries"), + resolve(here, "../../../docs/shape-libraries"), + ] +} + +export async function getShapeLibrary( + name: string, + dir?: string, +): Promise<{ ok: true; text: string } | { ok: false; error: string }> { + const library = name.trim().toLowerCase() + if (!LIBRARIES.has(library)) { + return { + ok: false, + error: `Library "${name}" not found. Available: ${Array.from(LIBRARIES).join(", ")}`, + } + } + for (const folder of libraryDirs(dir)) { + try { + return { + ok: true, + text: await readFile(join(folder, `${library}.md`), "utf-8"), + } + } catch { + // Try the next location + } + } + return { + ok: false, + error: `Library "${library}" is missing from this installation.`, + } +} diff --git a/packages/mcp-server/src/xml-attributes.ts b/packages/mcp-server/src/xml-attributes.ts new file mode 100644 index 00000000..1fa679ff --- /dev/null +++ b/packages/mcp-server/src/xml-attributes.ts @@ -0,0 +1,27 @@ +/** + * The attributes of one tag as written: name="value" or name='value' + * pairs. A quoted value is read whole, so text inside it such as + * value="Use parent='1'" is never taken for an attribute. + */ +export interface TagAttribute { + name: string + value: string + // The attribute's text in the tag, with the whitespace before it + start: number + end: number +} + +export function readAttributes(tag: string): TagAttribute[] { + const attributes: TagAttribute[] = [] + for (const m of tag.matchAll( + /\s*([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g, + )) { + attributes.push({ + name: m[1], + value: m[2] ?? m[3], + start: m.index, + end: m.index + m[0].length, + }) + } + return attributes +} diff --git a/packages/mcp-server/src/xml-examples.ts b/packages/mcp-server/src/xml-examples.ts new file mode 100644 index 00000000..f1758693 --- /dev/null +++ b/packages/mcp-server/src/xml-examples.ts @@ -0,0 +1,43 @@ +/** + * XML examples shared by the MCP drawing guide and the web app's system + * prompt and tool descriptions, so both teach the model the same thing. + */ + +export const SWIMLANE_EXAMPLE = ` + + + + + + + + + + + + + +` + +export const TWO_EDGES_EXAMPLE = ` + + + + +` + +export const WAYPOINT_EXAMPLE = ` + + + + + + +` + +/** Indent every line, for an indented code block in Markdown */ +export const indent = (text: string, prefix = " ") => + text + .split("\n") + .map((line) => prefix + line) + .join("\n") diff --git a/packages/mcp-server/src/xml-syntax.ts b/packages/mcp-server/src/xml-syntax.ts new file mode 100644 index 00000000..7d43985f --- /dev/null +++ b/packages/mcp-server/src/xml-syntax.ts @@ -0,0 +1,21 @@ +/** + * Strict XML syntax check, used in Node and in the web app's browser code. + * saxes reports syntax errors the way draw.io's DOMParser will, including + * an attribute prefix such as xlink: that was never declared. + */ +import { SaxesParser } from "saxes" + +/** + * Returns the first XML syntax error as "line:column: message", or null if + * the XML is well-formed. Surrounding whitespace is ignored because every + * caller trims before the XML reaches the browser. + */ +export function getXmlSyntaxError(xml: string): string | null { + let error: string | null = null + const parser = new SaxesParser({ xmlns: true }) + parser.on("error", (err) => { + error ??= err.message + }) + parser.write(xml.trim()).close() + return error +} diff --git a/packages/mcp-server/src/xml-validation.ts b/packages/mcp-server/src/xml-validation.ts index 8577d621..6962a59d 100644 --- a/packages/mcp-server/src/xml-validation.ts +++ b/packages/mcp-server/src/xml-validation.ts @@ -3,6 +3,9 @@ * Copied from lib/utils.ts to avoid cross-package imports */ +import { readAttributes } from "./xml-attributes.ts" +import { getXmlSyntaxError } from "./xml-syntax.ts" + // ============================================================================ // Constants // ============================================================================ @@ -10,9 +13,6 @@ /** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */ const MAX_XML_SIZE = 1_000_000 -/** Maximum iterations for aggressive cell dropping to prevent infinite loops */ -const MAX_DROP_ITERATIONS = 10 - /** Structural attributes that should not be duplicated in draw.io */ const STRUCTURAL_ATTRS = [ "edge", @@ -26,6 +26,23 @@ const STRUCTURAL_ATTRS = [ /** Valid XML entity names */ const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"]) +/** Element names draw.io understands (case-sensitive) */ +const VALID_DRAWIO_TAGS = new Set([ + "mxfile", + "diagram", + "mxGraphModel", + "root", + "mxCell", + "mxGeometry", + "mxPoint", + "Array", + "Object", + "mxRectangle", + // Wrappers draw.io writes for cells with links, tooltips or data + "UserObject", + "object", +]) + // ============================================================================ // XML Parsing Helpers // ============================================================================ @@ -91,6 +108,48 @@ function parseXmlTags(xml: string): ParsedTag[] { return tags } +/** + * Returns a function telling whether a position lies inside a quoted + * attribute value. Positions must be queried in increasing order. + */ +function createQuoteTracker(str: string): (pos: number) => boolean { + let i = 0 + let inQuote = false + let quoteChar = "" + return (pos: number) => { + for (; i < pos && i < str.length; i++) { + const c = str[i] + if (inQuote) { + if (c === quoteChar) inQuote = false + } else if (c === '"' || c === "'") { + // Only quotes that follow "=" open an attribute value + let j = i - 1 + while (j >= 0 && /\s/.test(str[j])) j-- + if (j >= 0 && str[j] === "=") { + inQuote = true + quoteChar = c + } + } + } + return inQuote + } +} + +/** Rewrite every opening tag with fn, leaving text and closing tags as is. */ +function replaceInOpeningTags( + xml: string, + fn: (tag: string) => string, +): string { + let out = "" + let last = 0 + for (const { tag, isClosing, startIndex, endIndex } of parseXmlTags(xml)) { + if (isClosing) continue + out += xml.slice(last, startIndex) + fn(tag) + last = endIndex + 1 + } + return out + xml.slice(last) +} + // ============================================================================ // Validation Helper Functions // ============================================================================ @@ -98,16 +157,10 @@ function parseXmlTags(xml: string): ParsedTag[] { /** Check for duplicate structural attributes in a tag */ function checkDuplicateAttributes(xml: string): string | null { const structuralSet = new Set(STRUCTURAL_ATTRS) - const tagPattern = /<[^>]+>/g - let tagMatch - while ((tagMatch = tagPattern.exec(xml)) !== null) { - const tag = tagMatch[0] - const attrPattern = /\s([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=/g + for (const [tag] of xml.matchAll(/<[^>]+>/g)) { const attributes = new Map() - let attrMatch - while ((attrMatch = attrPattern.exec(tag)) !== null) { - const attrName = attrMatch[1] - attributes.set(attrName, (attributes.get(attrName) || 0) + 1) + for (const { name } of readAttributes(tag)) { + attributes.set(name, (attributes.get(name) || 0) + 1) } const duplicates = Array.from(attributes.entries()) .filter(([name, count]) => count > 1 && structuralSet.has(name)) @@ -128,8 +181,7 @@ function checkDuplicateAttributes(xml: string): string | null { * scope the cell-ID uniqueness check per , and additionally check * that the ids themselves are unique. * - * The legacy regex-based check is kept as a fallback for non-mxfile inputs - * and for XML that won't DOM-parse. + * The legacy regex-based check is kept as a fallback for non-mxfile inputs. */ function checkDuplicateIds(xml: string): string | null { // The DOM-aware path only matters for wrappers; for legacy @@ -142,51 +194,57 @@ function checkDuplicateIds(xml: string): string | null { if (mightBeMxFile) try { const doc = new DOMParser().parseFromString(xml, "text/xml") - if (!doc.querySelector("parsererror")) { - const rootEl = doc.documentElement - if (rootEl && rootEl.tagName === "mxfile") { - const diagrams = doc.querySelectorAll("diagram") + const rootEl = doc.documentElement + if (rootEl && rootEl.tagName === "mxfile") { + const diagrams = doc.querySelectorAll("diagram") - // 1) ids must be unique across the file. - const diagramIds = new Map() - diagrams.forEach((d) => { - const id = d.getAttribute("id") - if (id) - diagramIds.set(id, (diagramIds.get(id) || 0) + 1) - }) - const dupDiagrams = Array.from(diagramIds.entries()) - .filter(([, c]) => c > 1) - .map(([id]) => `'${id}'`) - if (dupDiagrams.length > 0) { - return `Invalid XML: Found duplicate id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.` - } - - // 2) Within each page, mxCell ids must be unique. - for (let i = 0; i < diagrams.length; i++) { - const diagram = diagrams[i] - const pageId = - diagram.getAttribute("id") || `(index ${i})` - const cells = diagram.querySelectorAll("mxCell") - const cellIds = new Map() - cells.forEach((c) => { - const id = c.getAttribute("id") - if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1) - }) - const dups = Array.from(cellIds.entries()) - .filter(([, c]) => c > 1) - .map(([id, count]) => `'${id}' (${count}x)`) - if (dups.length > 0) { - return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.` - } - } - return null + // 1) ids must be unique across the file. + const diagramIds = new Map() + diagrams.forEach((d) => { + const id = d.getAttribute("id") + if (id) diagramIds.set(id, (diagramIds.get(id) || 0) + 1) + }) + const dupDiagrams = Array.from(diagramIds.entries()) + .filter(([, c]) => c > 1) + .map(([id]) => `'${id}'`) + if (dupDiagrams.length > 0) { + return `Invalid XML: Found duplicate id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.` } + + // 2) Within each page, cell ids must be unique. A cell with + // a link or custom data is a UserObject/object holding the + // id, around an mxCell whose own id does not count. + for (let i = 0; i < diagrams.length; i++) { + const diagram = diagrams[i] + const pageId = diagram.getAttribute("id") || `(index ${i})` + const cells = diagram.querySelectorAll( + "mxCell, UserObject, object", + ) + const cellIds = new Map() + cells.forEach((c) => { + const wrapped = + c.tagName === "mxCell" && + /^(UserObject|object)$/.test( + c.parentElement?.tagName ?? "", + ) + const id = c.getAttribute("id") + if (id && !wrapped) + cellIds.set(id, (cellIds.get(id) || 0) + 1) + }) + const dups = Array.from(cellIds.entries()) + .filter(([, c]) => c > 1) + .map(([id, count]) => `'${id}' (${count}x)`) + if (dups.length > 0) { + return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.` + } + } + return null } } catch { // fall through to regex } - // Legacy regex-based check for bare and parse-error cases. + // Legacy regex-based check for bare inputs. const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi const ids = new Map() let idMatch @@ -297,6 +355,38 @@ function checkNestedMxCells(xml: string): string | null { return null } +/** Check for element names draw.io does not know (e.g. a lowercase ) */ +function checkUnknownElements(xml: string): string | null { + const tags = parseXmlTags(xml.replace(//g, "")) + for (const { tagName } of tags) { + if (!VALID_DRAWIO_TAGS.has(tagName)) { + return `Invalid XML: Unknown element <${tagName}>. draw.io only understands ${Array.from(VALID_DRAWIO_TAGS).join(", ")} (names are case-sensitive).` + } + } + return null +} + +/** + * Find elements without an "as" attribute outside . draw.io rejects them with "Could not add object mxPoint". + */ +function findOrphanMxPoints( + xml: string, +): Array<{ start: number; end: number }> { + const arrays: Array<[number, number]> = [] + // (?, which has no points inside + for (const m of xml.matchAll(/]*(?[\s\S]*?<\/Array>/g)) { + arrays.push([m.index, m.index + m[0].length]) + } + const orphans: Array<{ start: number; end: number }> = [] + for (const m of xml.matchAll(/]*?(?:\/>|>\s*<\/mxPoint>)/g)) { + if (/\sas\s*=/.test(m[0])) continue + if (arrays.some(([s, e]) => m.index > s && m.index < e)) continue + orphans.push({ start: m.index, end: m.index + m[0].length }) + } + return orphans +} + // ============================================================================ // Main Validation Function // ============================================================================ @@ -305,9 +395,35 @@ function checkNestedMxCells(xml: string): string | null { * Validates draw.io XML structure for common issues * Uses DOM parsing + additional regex checks for high accuracy * @param xml - The XML string to validate + * @param opts.strict - Also reject unknown element names and orphan + * s. Used for XML the model wrote, not for files or browser state. * @returns null if valid, error message string if invalid */ -export function validateMxCellStructure(xml: string): string | null { +/** The first non-blank text under el, skipping a page's compressed data */ +function findTextBetweenTags(el: Element | null): string | null { + if (!el) return null + // A with only text holds the page compressed + const compressed = el.tagName === "diagram" && el.children.length === 0 + for (const node of Array.from(el.childNodes)) { + if (node.nodeType === 1) { + const text = findTextBetweenTags(node as Element) + if (text) return text + } else if ( + // Text, or a CDATA section (draw.io reads it as text too) + (node.nodeType === 3 || node.nodeType === 4) && + !compressed + ) { + const text = node.textContent?.trim() + if (text) return text.slice(0, 40) + } + } + return null +} + +export function validateMxCellStructure( + xml: string, + opts: { strict?: boolean } = {}, +): string | null { // Size check for performance if (xml.length > MAX_XML_SIZE) { console.warn( @@ -315,14 +431,11 @@ export function validateMxCellStructure(xml: string): string | null { ) } - // 0. First use DOM parser to catch syntax errors (most accurate) + // 0. DOM-based checks. Syntax errors are caught by the strict check at + // the end: linkedom's DOMParser never reports them. try { const parser = new DOMParser() const doc = parser.parseFromString(xml, "text/xml") - const parseError = doc.querySelector("parsererror") - if (parseError) { - return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.` - } // DOM-based checks for nested mxCell const allCells = doc.querySelectorAll("mxCell") @@ -332,6 +445,15 @@ export function validateMxCellStructure(xml: string): string | null { return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.` } } + + // draw.io reads any text inside a page as compressed page data and + // then fails to open the page + if (!doc.querySelector("parsererror")) { + const text = findTextBetweenTags(doc.documentElement) + if (text) { + return `Invalid XML: Found text "${text}" between tags. Labels belong in the value attribute; remove any other text between tags.` + } + } } catch (error) { console.warn( "[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:", @@ -404,6 +526,24 @@ export function validateMxCellStructure(xml: string): string | null { return nestedCellError } + if (opts.strict) { + const unknownError = checkUnknownElements(xml) + if (unknownError) { + return unknownError + } + if (findOrphanMxPoints(xml).length > 0) { + return 'Invalid XML: Found without an "as" attribute outside . Put waypoints inside or remove the point.' + } + } + + // 11. Strict XML syntax check, run last so the checks above can give + // more specific messages. Catches what they miss, e.g. duplicate or + // unquoted attributes, which make draw.io refuse to load the diagram. + const syntaxError = getXmlSyntaxError(xml) + if (syntaxError) { + return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (< for <, & for &, " for "), quote every attribute value, and do not repeat an attribute.` + } + return null } @@ -427,6 +567,15 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push("Fixed JSON-escaped XML") } + // 0b. Literal \n, \t or \r between tags, from escaping the XML twice + const unescaped = fixed.replace(/>(?:\s|\\[nrt])+ + gap.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, ""), + ) + if (unescaped !== fixed) { + fixed = unescaped + fixes.push("Replaced literal \\n between tags with line breaks") + } + // 1. Remove CDATA wrapper if (/^\s*\s*$/, "") @@ -442,27 +591,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { // 3. Fix duplicate attributes let dupAttrFixed = false + const structural = new Set(STRUCTURAL_ATTRS) fixed = fixed.replace(/<[^>]+>/g, (tag) => { - let newTag = tag - for (const attr of STRUCTURAL_ATTRS) { - const attrRegex = new RegExp( - `\\s${attr}\\s*=\\s*["'][^"']*["']`, - "gi", - ) - const matches = tag.match(attrRegex) - if (matches && matches.length > 1) { - let firstKept = false - newTag = newTag.replace(attrRegex, (m) => { - if (!firstKept) { - firstKept = true - return m - } - dupAttrFixed = true - return "" - }) + // Keep the first of each, drop the later ones + const seen = new Set() + let newTag = "" + let last = 0 + for (const attr of readAttributes(tag)) { + if (!structural.has(attr.name)) continue + if (!seen.has(attr.name)) { + seen.add(attr.name) + continue } + newTag += tag.slice(last, attr.start) + last = attr.end + dupAttrFixed = true } - return newTag + return newTag + tag.slice(last) }) if (dupAttrFixed) { fixes.push("Removed duplicate structural attributes") @@ -494,13 +639,21 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { } } - // 6. Fix malformed attribute quotes - const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/ - if (malformedQuotePattern.test(fixed)) { - fixed = fixed.replace( - /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g, - '$1="$2"', - ) + // 6. Fix malformed attribute quotes (name="value"). Quoted + // values are matched first and kept, so " inside a rich-text + // label like value="<font style="...">" is left alone. + let quotesFixed = false + fixed = replaceInOpeningTags(fixed, (tag) => + tag.replace( + /("[^"]*"|'[^']*')|(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g, + (match, quoted, name, value) => { + if (quoted) return match + quotesFixed = true + return `${name}="${value}"` + }, + ), + ) + if (quotesFixed) { fixes.push("Fixed malformed attribute quotes") } @@ -511,10 +664,21 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push("Fixed malformed closing tags") } - // 8. Fix missing space between attributes - const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g - if (missingSpacePattern.test(fixed)) { - fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2") + // 8. Fix missing space between attributes (id="2"vertex="1"). Every + // quoted value is consumed whole, so quotes always pair up within one + // attribute. + let spaceAdded = false + fixed = replaceInOpeningTags(fixed, (tag) => + tag.replace( + /("[^"]*"|'[^']*')([a-zA-Z_:])?/g, + (match, quoted, next) => { + if (!next) return match + spaceAdded = true + return `${quoted} ${next}` + }, + ), + ) + if (spaceAdded) { fixes.push("Added missing space between attributes") } @@ -596,62 +760,67 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push("Fixed tags to ") } - // 15. Fix common closing tag typos (MUST run before foreign tag removal) - const tagTypos = [ - { wrong: /<\/mxElement>/gi, right: "", name: "" }, - { wrong: /<\/mxcell>/g, right: "", name: "
" }, - { - wrong: /<\/mxgeometry>/g, - right: "", - name: "", - }, - { wrong: /<\/mxpoint>/g, right: "", name: "" }, - { - wrong: /<\/mxgraphmodel>/gi, - right: "
", - name: "", - }, - ] - for (const { wrong, right, name } of tagTypos) { - const before = fixed - fixed = fixed.replace(wrong, right) - if (fixed !== before) { - fixes.push(`Fixed typo ${name} to ${right}`) + // 15. Fix closing tag typos and wrong tag case, e.g. (MUST run + // before foreign tag removal, which would otherwise delete them) + const before15 = fixed + fixed = fixed.replace(/<\/mxElement>/gi, "") + if (fixed !== before15) { + fixes.push("Fixed typo to ") + } + for (const name of ["mxCell", "mxGeometry", "mxPoint", "mxGraphModel"]) { + let changed = false + fixed = fixed.replace( + new RegExp(`<(/?)${name}(?=[\\s/>])`, "gi"), + (match, slash) => { + const right = `<${slash}${name}` + if (match !== right) changed = true + return right + }, + ) + if (changed) { + fixes.push(`Fixed tag case of <${name}>`) } } - // 16. Remove non-draw.io tags (after typo fixes so lowercase variants are fixed first) - const validDrawioTags = new Set([ - "mxfile", - "diagram", - "mxGraphModel", - "root", - "mxCell", - "mxGeometry", - "mxPoint", - "Array", - "Object", - "mxRectangle", - ]) + // 16. Remove non-draw.io tags (after the case fixes above). Removes only + // the exact tag occurrences and skips quoted attribute values, so a stray + // never takes with it and inside + // value="..." stays. + const isInsideQuotesFor16 = createQuoteTracker(fixed) const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g let foreignMatch const foreignTags = new Set() + const foreignTagPositions: Array<{ start: number; end: number }> = [] while ((foreignMatch = foreignTagPattern.exec(fixed)) !== null) { const tagName = foreignMatch[1] - if (!validDrawioTags.has(tagName)) { - foreignTags.add(tagName) - } + if (VALID_DRAWIO_TAGS.has(tagName)) continue + if (isInsideQuotesFor16(foreignMatch.index)) continue + foreignTags.add(tagName) + foreignTagPositions.push({ + start: foreignMatch.index, + end: foreignMatch.index + foreignMatch[0].length, + }) } - if (foreignTags.size > 0) { - for (const tag of foreignTags) { - fixed = fixed.replace(new RegExp(`<${tag}[^>]*>`, "gi"), "") - fixed = fixed.replace(new RegExp(``, "gi"), "") + if (foreignTagPositions.length > 0) { + // Remove from the end so earlier positions stay valid + for (const { start, end } of foreignTagPositions.reverse()) { + fixed = fixed.slice(0, start) + fixed.slice(end) } fixes.push( `Removed foreign tags: ${Array.from(foreignTags).join(", ")}`, ) } + // 16b. Remove orphan s (no "as" attribute, not inside + // ), which draw.io refuses to load + const orphanPoints = findOrphanMxPoints(fixed) + if (orphanPoints.length > 0) { + for (const { start, end } of orphanPoints.reverse()) { + fixed = fixed.slice(0, start) + fixed.slice(end) + } + fixes.push(`Removed ${orphanPoints.length} orphan (s)`) + } + // 17. Fix unclosed tags const tagStack: string[] = [] const parsedTags = parseXmlTags(fixed) @@ -689,19 +858,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { } } - // 18. Remove extra closing tags + // 18. Remove extra closing tags. Counts only draw.io tags outside quoted + // attribute values (value="Title" holds HTML, not elements). const tagCounts = new Map< string, { opens: number; closes: number; selfClosing: number } >() const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g + const isInsideQuotesFor18 = createQuoteTracker(fixed) let tagCountMatch while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) { + if (isInsideQuotesFor18(tagCountMatch.index)) continue const fullMatch = tagCountMatch[0] const tagPart = tagCountMatch[1] const isClosing = tagPart.startsWith("/") const isSelfClosing = fullMatch.endsWith("/>") const tagName = isClosing ? tagPart.slice(1) : tagPart + if (!VALID_DRAWIO_TAGS.has(tagName)) continue let counts = tagCounts.get(tagName) if (!counts) { @@ -796,8 +969,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`) } - // 21. Fix true nested mxCell (different IDs) - const lines2 = fixed.split("\n") + // 21. Fix true nested mxCell (different IDs). Runs only when the nesting + // check finds real nesting, because this line-based rewrite can break + // valid cells written over several lines. + const lines2 = checkNestedMxCells(fixed) ? fixed.split("\n") : [] newLines = [] let trueNestedFixed = 0 let cellDepth = 0 @@ -807,7 +982,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { const line = lines2[i] const trimmed = line.trim() - const isOpenCell = /") + // A line holding a whole cell (...) opens nothing + const isOpenCell = + /") && + !trimmed.endsWith("") const isCloseCell = trimmed === "" if (isOpenCell) { @@ -860,9 +1039,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { if (duplicateIds.length > 0) { const idCounters = new Map() + // Rebuild from the captured parts so only the value changes (an id + // like "d" or "i" also occurs in the attribute name itself) fixed = fixed.replace( - /\bid\s*=\s*["']([^"']+)["']/gi, - (match, id) => { + /(\bid\s*=\s*["'])([^"']+)(["'])/gi, + (match, before, id, after) => { if (!duplicateIds.includes(id)) return match const count = idCounters.get(id) || 0 @@ -870,8 +1051,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { if (count === 0) return match - const newId = `${id}_dup${count}` - return match.replace(id, newId) + return `${before}${id}_dup${count}${after}` }, ) fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) @@ -892,49 +1072,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { fixes.push(`Generated ${emptyIdCount} missing ID(s)`) } - // 24. Aggressive: drop broken mxCell elements - if (typeof DOMParser !== "undefined") { - let droppedCells = 0 - let maxIterations = MAX_DROP_ITERATIONS - while (maxIterations-- > 0) { - const parser = new DOMParser() - const doc = parser.parseFromString(fixed, "text/xml") - const parseError = doc.querySelector("parsererror") - if (!parseError) break - - const errText = parseError.textContent || "" - const match = errText.match(/(\d+):\d+:/) - if (!match) break - - const errLine = parseInt(match[1], 10) - 1 - const lines = fixed.split("\n") - - let cellStart = errLine - let cellEnd = errLine - - while (cellStart > 0 && !lines[cellStart].includes("") || - lines[cellEnd].trim().endsWith("/>") - ) { - break - } - cellEnd++ - } - - lines.splice(cellStart, cellEnd - cellStart + 1) - fixed = lines.join("\n") - droppedCells++ - } - if (droppedCells > 0) { - fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`) - } - } - return { fixed, fixes } } @@ -943,18 +1080,23 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } { // ============================================================================ /** - * Validates XML and attempts to fix if invalid + * Validates XML and attempts to fix if invalid. By default runs the strict + * checks (unknown elements, orphan mxPoints), meant for XML the model wrote. + * Pass strict: false for a diagram that also holds the user's own content. * @param xml - The XML string to validate and potentially fix * @returns Object with validation result, fixed XML if applicable, and fixes applied */ -export function validateAndFixXml(xml: string): { +export function validateAndFixXml( + xml: string, + { strict = true }: { strict?: boolean } = {}, +): { valid: boolean error: string | null fixed: string | null fixes: string[] } { // First validation attempt - let error = validateMxCellStructure(xml) + let error = validateMxCellStructure(xml, { strict }) if (!error) { return { valid: true, error: null, fixed: null, fixes: [] } @@ -964,7 +1106,7 @@ export function validateAndFixXml(xml: string): { const { fixed, fixes } = autoFixXml(xml) // Validate the fixed version - error = validateMxCellStructure(fixed) + error = validateMxCellStructure(fixed, { strict }) if (!error) { return { valid: true, error: null, fixed, fixes } @@ -978,33 +1120,3 @@ export function validateAndFixXml(xml: string): { fixes, } } - -/** - * Check if mxCell XML output is complete (not truncated). - * Uses a robust approach that handles any LLM provider's wrapper tags - * by finding the last valid mxCell ending and checking if suffix is just closing tags. - * @param xml - The XML string to check (can be undefined/null) - * @returns true if XML appears complete, false if truncated or empty - */ -export function isMxCellXmlComplete(xml: string | undefined | null): boolean { - const trimmed = xml?.trim() || "" - if (!trimmed) return false - - // Find position of last complete mxCell ending (either /> or ) - const lastSelfClose = trimmed.lastIndexOf("/>") - const lastMxCellClose = trimmed.lastIndexOf("") - - const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) - - // No valid ending found at all - if (lastValidEnd === -1) return false - - // Check what comes after the last valid ending - // For />: add 2 chars, for : add 9 chars - const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 - const suffix = trimmed.slice(lastValidEnd + endOffset) - - // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete - // This regex matches any sequence of closing XML tags like , , - return /^(\s*<\/[^>]+>)*\s*$/.test(suffix) -} diff --git a/packages/mcp-server/tests/diagram-operations.test.ts b/packages/mcp-server/tests/diagram-operations.test.ts new file mode 100644 index 00000000..77d4de13 --- /dev/null +++ b/packages/mcp-server/tests/diagram-operations.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for edit_diagram operations on cells that draw.io wraps in + * or (cells with links, tooltips or custom data). + * The id sits on the wrapper; the inner mxCell has none. + */ + +import { deflateRawSync } from "node:zlib" +import { beforeAll, describe, expect, it, vi } from "vitest" +import { installDomPolyfill } from "../src/dom.ts" + +beforeAll(() => { + installDomPolyfill() +}) + +import { applyDiagramOperations } from "../src/diagram-operations.ts" + +const DOC = `` + +describe("wrapped cells", () => { + it("deletes a UserObject cell with its edges and children", () => { + const { result, errors } = applyDiagramOperations(DOC, [ + { operation: "delete", cell_id: "a" }, + ]) + expect(errors).toEqual([]) + expect(result).not.toContain('id="a"') + expect(result).not.toContain('id="e1"') + expect(result).not.toContain('id="child"') + expect(result).toContain('id="b"') + }) + + it("cascades to a wrapped edge when deleting a plain cell", () => { + const { result, errors } = applyDiagramOperations(DOC, [ + { operation: "delete", cell_id: "b" }, + { operation: "delete", cell_id: "e1" }, + ]) + // e1 was already removed by the cascade, so no warning for it + expect(errors).toEqual([]) + expect(result).not.toContain('id="e1"') + expect(result).toContain('id="a"') + }) + + it("warns when deleting a cell that does not exist", () => { + const { errors } = applyDiagramOperations(DOC, [ + { operation: "delete", cell_id: "missing" }, + ]) + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ type: "delete", cellId: "missing" }) + }) + + it("updates a UserObject cell", () => { + const { result, errors } = applyDiagramOperations(DOC, [ + { + operation: "update", + cell_id: "a", + new_xml: ``, + }, + ]) + expect(errors).toEqual([]) + expect(result).toContain('label="A2"') + expect(result.match(/id="a"/g)).toHaveLength(1) + }) + + it("refuses to add a cell whose id a UserObject already uses", () => { + const { errors } = applyDiagramOperations(DOC, [ + { + operation: "add", + cell_id: "a", + new_xml: ``, + }, + ]) + expect(errors[0]?.message).toContain("already exists") + }) +}) + +describe("cascade delete logging", () => { + it("does not write cascade logs to stdout (the JSON-RPC channel)", () => { + const plain = `` + const spy = vi.spyOn(console, "log").mockImplementation(() => {}) + const { result } = applyDiagramOperations(plain, [ + { operation: "delete", cell_id: "x" }, + ]) + expect(result).not.toContain('id="e"') + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) +}) + +describe("pages without a ", () => { + const ADD_A = { + operation: "add" as const, + cell_id: "a", + new_xml: ``, + } + + it("treats an empty page as a blank page", () => { + const doc = `` + const { result, errors } = applyDiagramOperations(doc, [ADD_A]) + expect(errors).toEqual([]) + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain(' { + const model = `` + const compressed = deflateRawSync( + Buffer.from(encodeURIComponent(model)), + ).toString("base64") + const doc = `${compressed}` + const { result, errors } = applyDiagramOperations(doc, [ADD_A]) + expect(errors).toEqual([]) + expect(result).not.toContain(compressed) + expect(result).toContain(' { + const doc = `not base64 !!` + const { errors } = applyDiagramOperations(doc, [ADD_A]) + expect(errors[0]?.cellId).toBe("") + expect(errors[0]?.message).toContain("could not be decompressed") + }) +}) + +describe("a wrapped mxCell with its wrapper's id", () => { + const doc = `` + + it("deletes the whole wrapper", () => { + const { result, errors } = applyDiagramOperations(doc, [ + { operation: "delete", cell_id: "u" }, + ]) + expect(errors).toEqual([]) + expect(result).not.toContain("UserObject") + }) + + it("replaces the wrapper on update", () => { + const { result, errors } = applyDiagramOperations(doc, [ + { + operation: "update", + cell_id: "u", + new_xml: ``, + }, + ]) + expect(errors).toEqual([]) + expect(result.match(/ { + installDomPolyfill() +}) + +import { editDiagram, targetPageXml } from "../src/edit-diagram.ts" +import { validateMxCellStructure } from "../src/xml-validation.ts" + +const cell = (id: string, extra = "") => + `` + +const page = (id: string, cells: string) => + `${cells}` + +const DOC = `${page("p1", cell("a") + cell("b"))}` + +describe("editDiagram", () => { + it("applies every operation and counts them", () => { + const out = editDiagram( + DOC, + [ + { operation: "add", cell_id: "c", new_xml: cell("c") }, + { operation: "delete", cell_id: "b" }, + ], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.applied).toBe(2) + expect(out.xml).toContain('id="c"') + expect(out.xml).not.toContain('id="b"') + }) + + it("applies nothing when one operation fails", () => { + const out = editDiagram( + DOC, + [ + { operation: "add", cell_id: "c", new_xml: cell("c") }, + { operation: "delete", cell_id: "missing" }, + ], + {}, + ) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.pageError).toBe(false) + expect(out.errors).toEqual([ + 'delete missing: Cell with id="missing" not found', + ]) + }) + + it("rejects new_xml that is still invalid after auto-fix", () => { + const out = editDiagram( + DOC, + [ + { + operation: "update", + cell_id: "a", + new_xml: ``, + }, + ], + {}, + ) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.errors[0]).toMatch(/^update a: invalid new_xml: /) + }) + + it("rejects several cells in one new_xml", () => { + const out = editDiagram( + DOC, + [ + { + operation: "add", + cell_id: "c", + new_xml: cell("c") + cell("d"), + }, + ], + {}, + ) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.errors[0]).toContain("exactly one cell") + }) + + it("accepts a UserObject that wraps one mxCell", () => { + const wrapped = `` + const out = editDiagram( + DOC, + [{ operation: "add", cell_id: "u", new_xml: wrapped }], + {}, + ) + expect(out.ok).toBe(true) + }) + + it("is not blocked by a problem on another page", () => { + // Page p2 has a duplicate cell id, which fails validation + const doc = `${page("p1", cell("a"))}${page("p2", cell("x") + cell("x"))}` + expect(validateMxCellStructure(doc)).not.toBeNull() + const out = editDiagram( + doc, + [{ operation: "add", cell_id: "c", new_xml: cell("c") }], + { page_id: "p1" }, + ) + expect(out.ok).toBe(true) + }) + + it("fixes a literal \\n between tags, as gpt-5-mini sends it", () => { + const newXml = `\\n \\n` + const out = editDiagram( + DOC, + [{ operation: "add", cell_id: "c", new_xml: newXml }], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.xml).toContain('value="Reset password"') + expect(out.xml).not.toContain("\\n") + }) + + it("reports a missing page as a page-level error", () => { + const out = editDiagram(DOC, [{ operation: "delete", cell_id: "a" }], { + page_id: "nope", + }) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.pageError).toBe(true) + }) +}) + +describe("targetPageXml", () => { + it("returns only the selected page", () => { + const doc = `${page("p1", cell("a"))}${page("p2", cell("z"))}` + const xml = targetPageXml(doc, { page_id: "p2" }) + expect(xml).toContain('id="z"') + expect(xml).not.toContain('id="a"') + }) +}) + +describe("labels an edit does not touch", () => { + it("keep their line breaks and spaces as draw.io reads them", () => { + // A literal line break in an attribute reads as a space; is a + // real line break + const labels = + `` + + `` + const out = editDiagram( + `${page("p1", labels)}`, + [{ operation: "add", cell_id: "c", new_xml: cell("c") }], + {}, + ) + expect(out.ok).toBe(true) + if (!out.ok) return + expect(out.xml).toContain(`value="Hello world"`) + expect(out.xml).toContain(`value="Line 1 Line 2"`) + }) +}) diff --git a/packages/mcp-server/tests/edit-gate.test.ts b/packages/mcp-server/tests/edit-gate.test.ts index 78c65143..66df1fce 100644 --- a/packages/mcp-server/tests/edit-gate.test.ts +++ b/packages/mcp-server/tests/edit-gate.test.ts @@ -9,14 +9,18 @@ * reads as a user edit. */ -import { DOMParser } from "linkedom" import { beforeAll, describe, expect, it } from "vitest" +import { installDomPolyfill } from "../src/dom.ts" beforeAll(() => { - ;(globalThis as any).DOMParser = DOMParser + installDomPolyfill() }) -import { checkEditGate, contentFingerprint } from "../src/edit-gate.js" +import { + checkEditGate, + contentFingerprint, + markPageSeen, +} from "../src/edit-gate.ts" const XML_A = `` @@ -130,3 +134,37 @@ describe("contentFingerprint", () => { expect(contentFingerprint("not xml at all")).toBe("not xml at all") }) }) + +describe("markPageSeen", () => { + const page = (id: string, label: string) => + `` + const doc = (a: string, b: string) => + `${page("A", a)}${page("B", b)}` + + it("counts the whole document as seen when the other pages are unchanged", () => { + const seen = doc("a1", "b1") + const live = doc("a2", "b1") + expect(markPageSeen(seen, live, { page_id: "A" })).toBe(live) + }) + + it("does not count a changed page the model was not shown", () => { + // The user edited page B; the model looked at page A only + const seen = doc("a1", "b1") + const live = doc("a1", "b2") + const marked = markPageSeen(seen, live, { page_id: "A" }) + expect(marked).toBe(seen) + expect(checkEditGate(marked, live).ok).toBe(false) + }) + + it("does not count other pages when the record of what was seen is empty", () => { + // Empty also after load_diagram or a page tool on unseen changes, + // when the model may still remember an older copy of the pages + const live = doc("a1", "b1") + expect(markPageSeen("", live, { page_id: "A" })).toBe("") + }) + + it("counts a one-page document as seen from its only page", () => { + const live = `${page("A", "a1")}` + expect(markPageSeen("", live, { page_id: "A" })).toBe(live) + }) +}) diff --git a/packages/mcp-server/tests/exclusive.test.ts b/packages/mcp-server/tests/exclusive.test.ts new file mode 100644 index 00000000..dce451ae --- /dev/null +++ b/packages/mcp-server/tests/exclusive.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for the queue the write tools run in (index.ts registerWriteTool). + */ +import { describe, expect, it } from "vitest" +import { createExclusive } from "../src/exclusive.ts" + +const extra = (signal = new AbortController().signal) => ({ signal }) +const tick = () => new Promise((r) => setTimeout(r, 5)) + +describe("createExclusive", () => { + it("runs the calls one at a time, in order", async () => { + const exclusive = createExclusive() + const events: string[] = [] + // Reads the document, waits, then writes it back + const addPage = exclusive(async (args: { name: string }, _extra) => { + events.push(`read ${args.name}`) + await tick() + events.push(`write ${args.name}`) + return { content: [] } + }) + await Promise.all([ + addPage({ name: "A" }, extra()), + addPage({ name: "B" }, extra()), + ]) + expect(events).toEqual(["read A", "write A", "read B", "write B"]) + }) + + it("goes on after a call that threw", async () => { + const exclusive = createExclusive() + const failing = exclusive(async (_extra: unknown) => { + throw new Error("broken") + }) + const working = exclusive(async (_extra: unknown) => ({ content: [] })) + const first = failing(extra()) + const second = working(extra()) + await expect(first).rejects.toThrow("broken") + await expect(second).resolves.toEqual({ content: [] }) + }) + + it("skips a call cancelled while it waited", async () => { + const exclusive = createExclusive() + let ran = false + const slow = exclusive(async (_extra: unknown) => { + await tick() + return { content: [] } + }) + const deletePage = exclusive(async (_extra: unknown) => { + ran = true + return { content: [] } + }) + const cancel = new AbortController() + const first = slow(extra()) + const second = deletePage(extra(cancel.signal)) + cancel.abort() + await first + expect(await second).toMatchObject({ isError: true }) + expect(ran).toBe(false) + }) +}) diff --git a/packages/mcp-server/tests/http-server.test.ts b/packages/mcp-server/tests/http-server.test.ts new file mode 100644 index 00000000..1f4f2bca --- /dev/null +++ b/packages/mcp-server/tests/http-server.test.ts @@ -0,0 +1,710 @@ +/** + * Tests for the embedded HTTP server (browser bridge). + * + * The server runs in-process on a random high port (never 6002, which is + * also the default port of the Next.js dev server). Requests go through + * node:http so tests can set raw paths and Host/Origin headers. + */ + +import http from "node:http" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { installDomPolyfill } from "../src/dom.ts" +import { addHistory, getHistory } from "../src/history.ts" +import { + getState, + keepInHistory, + onSessionRecreate, + requestExport, + requestSync, + setState, + shutdown, + startHttpServer, + waitForSync, +} from "../src/http-server.ts" + +let port = 0 + +beforeAll(async () => { + // XML parsing, as the server installs it at startup + installDomPolyfill() + port = await startHttpServer(40000 + Math.floor(Math.random() * 10000)) +}) + +afterAll(() => { + shutdown() +}) + +interface Response { + status: number + headers: http.IncomingHttpHeaders + body: string +} + +/** Send a request; `body` may be split into several writes. */ +function request( + path: string, + opts: { + method?: string + headers?: Record + body?: Buffer[] + } = {}, +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + path, + method: opts.method ?? "GET", + headers: { host: `localhost:${port}`, ...opts.headers }, + }, + (res) => { + const chunks: Buffer[] = [] + res.on("data", (c: Buffer) => chunks.push(c)) + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString("utf8"), + }), + ) + }, + ) + req.on("error", reject) + const parts = opts.body ?? [] + // Pause between parts so the server reads them as separate chunks + const writeNext = (i: number) => { + if (i >= parts.length) return req.end() + req.write(parts[i]) + setTimeout(() => writeNext(i + 1), 30) + } + writeNext(0) + }) +} + +const postJson = (path: string, data: unknown, headers = {}) => + request(path, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: [Buffer.from(JSON.stringify(data))], + }) + +describe("session id in the page URL", () => { + it("rejects a session id that could inject script", async () => { + const res = await request(`/?mcp=${encodeURIComponent('";alert(1)//')}`) + expect(res.status).toBe(400) + expect(res.body).not.toContain("alert") + }) + + it("writes a valid session id into the page script as a JSON string", async () => { + const res = await request("/?mcp=mcp-test-page") + expect(res.status).toBe(200) + expect(res.body).toContain('const sessionId = "mcp-test-page";') + }) +}) + +describe("requests that used to crash the process", () => { + it("answers 400 for a path that is not a valid URL", async () => { + const res = await request("//") + expect(res.status).toBe(400) + // The server is still alive + expect((await request("/api/state?sessionId=mcp-alive")).status).toBe( + 200, + ) + }) + + it("never creates sessions with ids unsafe for the Location header", async () => { + const badId = "mcp-中" + await request(`/api/state?sessionId=${encodeURIComponent(badId)}`) + expect(getState(badId)).toBeUndefined() + const post = await postJson("/api/state", { + sessionId: badId, + xml: "", + }) + expect(post.status).toBe(400) + expect(getState(badId)).toBeUndefined() + + const res = await request("/") + expect([200, 302]).toContain(res.status) + }) +}) + +describe("request origin checks", () => { + it("refuses a foreign Host header (DNS rebinding)", async () => { + const res = await request("/api/state?sessionId=mcp-alive", { + headers: { host: `evil.example:${port}` }, + }) + expect(res.status).toBe(403) + }) + + it("refuses writes from another website", async () => { + const res = await postJson( + "/api/state", + { sessionId: "mcp-csrf", xml: "" }, + { origin: "https://evil.example" }, + ) + expect(res.status).toBe(403) + expect(getState("mcp-csrf")).toBeUndefined() + }) + + it("accepts writes from the page itself", async () => { + const res = await postJson( + "/api/state", + { sessionId: "mcp-same-origin", xml: "" }, + { origin: `http://localhost:${port}` }, + ) + expect(res.status).toBe(200) + // Opened as 127.0.0.1, or through a forwarded port: Origin and + // Host name the same host + for (const host of [`127.0.0.1:${port}`, "localhost:7000"]) { + const page = await postJson( + "/api/state", + { sessionId: "mcp-same-origin", xml: "" }, + { origin: `http://${host}`, host }, + ) + expect(page.status).toBe(200) + } + }) + + it("refuses writes from a page on another localhost port", async () => { + // A plain text POST needs no CORS preflight, so the server must + // refuse it itself + setState("mcp-other-port", "kept") + for (const path of ["/api/state", "/api/history-svg"]) { + const res = await postJson( + path, + { + sessionId: "mcp-other-port", + xml: "replaced", + svg: "x", + }, + { origin: "http://localhost:3000" }, + ) + expect(res.status).toBe(403) + } + expect(getState("mcp-other-port")?.xml).toBe("kept") + expect(getState("mcp-other-port")?.svg).toBeUndefined() + }) +}) + +describe("POST /api/state", () => { + it("refuses a push without xml and keeps the diagram", async () => { + setState("mcp-no-xml", "kept") + const res = await postJson("/api/state", { + sessionId: "mcp-no-xml", + baseVersion: 99, + }) + expect(res.status).toBe(400) + expect(getState("mcp-no-xml")?.xml).toBe("kept") + }) + + it("decodes UTF-8 characters split across body chunks", async () => { + const xml = `${"数据".repeat(30000)}` + const body = Buffer.from(JSON.stringify({ sessionId: "mcp-utf8", xml })) + // Cut inside a 3-byte character + const cut = body.indexOf(Buffer.from("数")) + 1 + const res = await request("/api/state", { + method: "POST", + headers: { "content-type": "application/json" }, + body: [body.subarray(0, cut), body.subarray(cut)], + }) + expect(res.status).toBe(200) + expect(getState("mcp-utf8")?.xml).toBe(xml) + }) + + it("rejects a browser push based on a version older than an AI write", async () => { + const id = "mcp-conflict" + setState(id, "user v1", undefined, true) + const aiVersion = setState(id, "AI edit") + + const stale = await postJson("/api/state", { + sessionId: id, + xml: "user edit on old version", + baseVersion: aiVersion - 1, + }) + expect(stale.status).toBe(409) + expect(getState(id)?.xml).toBe("AI edit") + + // Pushes based on the AI version are accepted, including a second + // push sent before the first one's response updated the browser + for (const xml of ["a", "b"]) { + const ok = await postJson("/api/state", { + sessionId: id, + xml, + baseVersion: aiVersion, + }) + expect(ok.status).toBe(200) + expect(getState(id)?.xml).toBe(xml) + } + }) + + it("keeps a rejected user edit in history", async () => { + const id = "mcp-conflict-history" + setState(id, "user v1", undefined, true) + const aiVersion = setState(id, "AI edit") + const before = getHistory(id).length + + const stale = await postJson("/api/state", { + sessionId: id, + xml: "lost user edit", + baseVersion: aiVersion - 1, + }) + expect(stale.status).toBe(409) + expect(JSON.parse(stale.body).savedToHistory).toBe(true) + const history = getHistory(id) + expect(history).toHaveLength(before + 1) + expect(history.at(-1)?.xml).toBe("lost user edit") + }) + + it("ends a pending sync when the sync reply is older than an AI write", async () => { + const id = "mcp-stale-sync" + setState(id, "before", undefined, true) + const aiVersion = setState(id, "AI edit") + requestSync(id) + const before = getHistory(id).length + + // The browser exported its old diagram, then loaded the AI write + const stale = await postJson("/api/state", { + sessionId: id, + xml: "before", + baseVersion: aiVersion - 1, + source: "sync", + }) + expect(stale.status).toBe(409) + expect(JSON.parse(stale.body).savedToHistory).toBe(false) + expect(getState(id)?.xml).toBe("AI edit") + expect(getState(id)?.syncRequested).toBeUndefined() + expect(getHistory(id)).toHaveLength(before) + expect(await waitForSync(id, 200)).toBe(true) + }) + + it("ignores a sync reply older than a user edit saved meanwhile", async () => { + const id = "mcp-late-sync" + const version = setState(id, "A", undefined, true) + requestSync(id) + // The user's edit is saved before the sync reply arrives + const edit = await postJson("/api/state", { + sessionId: id, + xml: "B", + baseVersion: version, + }) + expect(edit.status).toBe(200) + const late = await postJson("/api/state", { + sessionId: id, + xml: "A", + baseVersion: version, + source: "sync", + }) + expect(late.status).toBe(409) + expect(getState(id)?.xml).toBe("B") + }) +}) + +describe("export requests", () => { + it("hands draw.io export options to the page and clears them after", async () => { + const id = "mcp-export-options" + setState(id, "x") + requestExport(id, "png", undefined, { width: 1000, pageId: "p2" }) + const poll = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + expect(poll.exportFormat).toBe("png") + expect(poll.exportOptions).toEqual({ width: 1000, pageId: "p2" }) + + await postJson("/api/state", { + sessionId: id, + exportData: "data:image/png;base64,AAAA", + exportId: poll.exportId, + }) + expect(getState(id)?.exportOptions).toBeUndefined() + }) + + it("ignores a late result of an export that already timed out", async () => { + const id = "mcp-export-late" + setState(id, "x") + requestExport(id, "png") + const first = JSON.parse( + (await request(`/api/state?sessionId=${id}`)).body, + ) + // The server gave up on the first export and asked for the next + requestExport(id, "svg") + await postJson("/api/state", { + sessionId: id, + exportData: "data:image/png;base64,LATE", + exportId: first.exportId, + }) + expect(getState(id)?.exportData).toBeUndefined() + expect(getState(id)?.exportFormat).toBe("svg") + }) +}) + +describe("a session state recreated after it was lost", () => { + const SAVED = `` + const getJson = async (id: string) => + JSON.parse((await request(`/api/state?sessionId=${id}`)).body) + + it("names each state, and says when it was made blank", async () => { + const first = await getJson("mcp-sid-blank") + expect(first.stateId).toMatch(/^[0-9a-f-]{36}$/) + expect(first.blank).toBe(true) + setState("mcp-sid-blank", "AI write") + const after = await getJson("mcp-sid-blank") + // Same state, no longer blank + expect(after.stateId).toBe(first.stateId) + expect(after.blank).toBe(false) + }) + + it("refuses a push made for another state, also before any poll", async () => { + // The MCP process restarted; the tab's push comes before its poll + onSessionRecreate((id) => (id === "mcp-sid-restart" ? SAVED : null)) + try { + for (const stateId of ["from-before", null]) { + const res = await postJson("/api/state", { + sessionId: "mcp-sid-restart", + xml: "tab's old copy", + baseVersion: 7, + stateId, + }) + expect(res.status).toBe(409) + expect(JSON.parse(res.body).stateChanged).toBe(true) + // The saved file was recovered first and is kept + expect(getState("mcp-sid-restart")?.xml).toBe(SAVED) + } + } finally { + onSessionRecreate(() => null) + } + }) + + it("accepts a push for the current state", async () => { + const { stateId, version } = await getJson("mcp-sid-ok") + const res = await postJson("/api/state", { + sessionId: "mcp-sid-ok", + xml: "user edit", + baseVersion: version, + stateId, + }) + expect(res.status).toBe(200) + expect(getState("mcp-sid-ok")?.xml).toBe("user edit") + }) + + it("keeps a recovering tab's copy in history, never on the canvas", async () => { + setState("mcp-sid-recover", SAVED) + const { stateId, version } = await getJson("mcp-sid-recover") + const before = getHistory("mcp-sid-recover").length + const res = await postJson("/api/state", { + sessionId: "mcp-sid-recover", + xml: "what the tab showed", + baseVersion: version, + stateId, + source: "recover", + }) + expect(res.status).toBe(409) + expect(JSON.parse(res.body).savedToHistory).toBe(true) + expect(getState("mcp-sid-recover")?.xml).toBe(SAVED) + expect(getHistory("mcp-sid-recover")).toHaveLength(before + 1) + expect(getHistory("mcp-sid-recover").at(-1)?.xml).toBe( + "what the tab showed", + ) + }) + + it("keeps the old rules for a tab from an older version", async () => { + // Its pushes have no stateId field + const version = setState("mcp-sid-legacy", "AI") + const res = await postJson("/api/state", { + sessionId: "mcp-sid-legacy", + xml: "edit", + baseVersion: version, + }) + expect(res.status).toBe(200) + }) +}) + +describe("preview page", () => { + it("shows the saved diagram of a session whose state expired", async () => { + const saved = `` + onSessionRecreate((id) => (id === "mcp-expired" ? saved : null)) + try { + await request("/?mcp=mcp-expired") + expect(getState("mcp-expired")?.xml).toBe(saved) + await request("/?mcp=mcp-never-saved") + expect(getState("mcp-never-saved")?.xml).not.toContain("kept") + } finally { + onSessionRecreate(() => null) + } + }) + + it("serves scripts that parse, with every placeholder filled", async () => { + const res = await request("/?mcp=mcp-test-script") + expect(res.body).not.toContain("{{") + // Both scripts share one global scope in the page + const scripts = [...res.body.matchAll(/