Compare commits

..
Author SHA1 Message Date
dayuan.jiang 61c43d3b2a test(mcp-server): get_selection wiring test expects the classic page's answer when the shell is not built 2026-10-11 21:08:01 +09:00
dayuan.jiang 876d3dec8c fix(mcp-server): the shell shows the versions once, in the side panel 2026-10-11 20:56:07 +09:00
dayuan.jiang 24d1850518 fix(mcp-server): bundle open.html and js/open.js, the picker of Open Library from > Browser 2026-10-11 20:56:07 +09:00
dayuan.jiang fdc974e85a fix(mcp-server): the download waits for a page export's projection to end, and the dialog's styles are bundled 2026-10-11 20:56:07 +09:00
dayuan.jiang 70d392729a fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart 2026-10-11 20:56:07 +09:00
dayuan.jiang 00aa3c22e6 fix(mcp-server): the version cards keep only the current History in memory, picture the newest copy, and keep a clear the buffer scrolled to 2026-10-11 20:56:07 +09:00
dayuan.jiang 4bd15ffcf6 fix(mcp-server): a hidden tab's delayed selection answer checks the canvas again first 2026-10-11 20:56:07 +09:00
dayuan.jiang c9c610e929 fix(mcp-server): get_selection lists every selected id, with detail for the first 100 2026-10-11 20:56:07 +09:00
dayuan.jiang e93f7547c2 ci: the version bump reminder watches every path publish-mcp.yml does, and compares the version field
The reminder step left out the root lockfile and scripts/, which change
what is bundled into the shell and trigger the publish workflow; and it
took any change of packages/mcp-server/package.json (a dependency bump)
for a version bump. It now compares the version field between the two
commits.
2026-10-11 20:56:07 +09:00
dayuan.jiang f257d8ee99 fix(mcp-server): review fixes for the shell page
- a download button in the header opens the web app's export dialog
  (.drawio, .png, .svg, .drawio.svg), which the classic page had and the
  shell lacked when it became the default
- the shell asks draw.io for the custom library menu (libraries=1), as
  the classic page did; the web app keeps libraries=0
- the newest card no longer shows "Rendering preview" for good: the sync
  takes the thumbnail of a diagram the server recovered from its file
  (saved without pictures) while the canvas kept it, and of a write whose
  picture was skipped because an edit came first, once the canvas shows
  the write again
- e2e: the get_selection test covers a shape in a container the user
  entered; a download test saves a .drawio file
2026-10-11 20:56:07 +09:00
dayuan.jiang 467fd25fd1 fix(mcp-server): review fixes for get_selection
Server (src/http-server.ts, src/index.ts, src/selection.ts,
src/new-diagram.ts):
- overlapping get_selection calls take turns (readSelection, one slot per
  session as the export slot) instead of replacing each other's request,
  which left one of them with a false "tab not in front" timeout
- an answer is taken only while a request is pending (both ids undefined
  compared equal)
- the tool sees the page start_session actually opens: with dist/shell
  missing the classic page is in use, which never answers, so the tool
  says so instead of timing out
- the result lists at most 100 cells and counts the rest: a whole large
  diagram selected would fill the model's context
- every <diagram> the model sends without an id gets one, so the page id
  the shell reports exists in the server's document

Shell (shell/mcp-sync-core.ts, shell/use-mcp-sync.ts,
contexts/diagram-context.tsx, lib/drawio/editor-bridge.ts):
- an answer whose POST failed is sent again at the next poll
- no answer while a full load has yet to reach the editor: it still shows
  the previous document, whose cells and pages the answer would name
- a hidden tab (the same session open twice) answers a poll later, so the
  tab in front answers first; alone, it still answers within the timeout
- a cell's container is reported by the model's isLayer, not by comparing
  with the default parent, which is the group the user entered
2026-10-11 20:56:07 +09:00
dayuan.jiang 734a31d4eb fix(mcp-server): review fixes for the version cards and History
The shell's version cards (shell/node-versions-source.ts):
- a version's change and undo target are the state it replaced, the
  History entry right before its first copy, not the card before it: after
  a restore those differ, and undo went to the wrong version (and not
  where restore_version steps_back=1 goes)
- a card restores the newest copy of its content, as restore_version
  does, so page settings the user changed (a "user" copy) are kept
- a blank page after a drawing is a clear of the canvas, a version of its
  own; only the blank page before any drawing is hidden
- numbers and changes are keyed by content, not by the first copy's id,
  so a version keeps them when its first copy drops out of the server's
  20-entry buffer; the caches start over for another server state (the
  process restarted: entry ids name other content)

The server's History (src/history.ts):
- firstCopyIds compares each entry with the first of every group only: a
  bare model matches any page name, so "same content" is not transitive,
  and a card could show one document and restore another
- the time and pages fields had no reader; pages parsed every XML once
  more on every write

Reading History (src/http-server.ts, shell/mcp-sync-core.ts):
- GET /api/state and a push's answer carry a History key (entry count,
  newest id, the entry on the canvas); the shell reads History again only
  when it changes, so a hand edit no longer downloads every entry's XML and
  thumbnail
- a failed History read is told again at the next poll
- a History list from a state the poll has not seen yet is dropped
2026-10-11 20:56:07 +09:00
dayuan.jiang f7178510f4 ci: an mcp-shell job builds the package, runs its tests, the draw.io file guard and the shell e2e 2026-10-11 20:56:07 +09:00
dayuan.jiang 0ed9643af1 chore(mcp-server): version 0.4.0; check-package requires the draw.io version stamp 2026-10-11 20:56:07 +09:00
dayuan.jiang 58cef00873 docs: the MCP preview is the web app's canvas; get_selection, DRAWIO_BASE_URL and the classic page 2026-10-11 20:56:07 +09:00
dayuan.jiang 27c49a68d0 feat(mcp-server): the canvas shell is the default preview, the classic page behind DRAWIO_PREVIEW_UI=classic 2026-10-11 20:56:07 +09:00
dayuan.jiang af842ac287 test(mcp-server): selection requests, get_selection texts, and page screenshots that leave the view alone
Unit tests for the request plumbing (random id, one answer, timeout), the tool's texts (cells, nothing selected, external draw.io) and the shell's side (one answer per request, after draw.io is up, never during a projection); the wiring test starts a session with BROWSER=none to see the no-tab message. Shell e2e: cells selected in the editor reach get_selection; a PNG of another page differs from the page on screen, which stays.
2026-10-11 20:56:07 +09:00
dayuan.jiang b3dcd91f54 feat(mcp-server): get_selection reads the cells the user selected in the shell
The server asks the preview tab for the selection the way it asks for an export (a random request id in GET /api/state, the answer in a POST with that id, 10 s to answer). The shell answers through the editor bridge with each cell's id, label, an edge's ends, a shape's geometry and the page on screen; without a same-origin editor it says so, and the tool names the external draw.io. The classic page cannot answer, so the tool says that at once. screenshot_diagram's description now says a page selector renders that page without changing the page on screen (PNG exports by pageId already did).
2026-10-11 20:56:07 +09:00
dayuan.jiang 5c923adf7b test(mcp-server): e2e of the shell's version cards
In a session of its own: three writes give three cards; undoing the newest
restores the one before on the server too, redo brings it back, a hand edit
turns the undo into a restore, and restoring the first version keeps that
edit as a version of its own, which restore_version also finds.
2026-10-11 20:56:07 +09:00
dayuan.jiang 0de1cdfe7c feat(mcp-server): version cards in the shell from the server's History
The shell's VersionsSource (shell/node-versions-source.ts) reads GET
/api/history through the sync and shows one version per distinct content
(a restore's copy folds into the entry it copies; the blank page is none),
numbered as they first appeared, with what changed since the one before.
The entry the server says is on the canvas marks the version; undo and
redo of the newest version restore the one before it, or it again, through
POST /api/restore, so hand edits stay as versions of their own. The cards
sit in a panel beside the canvas (toggle in the header); the strip and
Compare come from the shared canvas components. The sync tells listeners
once per server version, and when History changed without one, so the
list is read again only then.
2026-10-11 20:56:07 +09:00
dayuan.jiang 66c9daf5ca feat(mcp-server): history entries name their maker, and /api/history the entry on the canvas
Each History entry records who made it (a user edit, a recovering tab's
copy, a restored copy; none for the AI's writes), when, and its page
count. GET /api/history now also returns each entry's XML, the first entry
with the same content (a restore adds a copy of an older one), the state
the ids belong to, and the entry whose content the canvas shows, by the
rule restore_version uses. The shell's version cards read these; the
classic page keeps using index, id and svg.
2026-10-11 20:56:07 +09:00
dayuan.jiang a96dcbb21f ci: create the tarball directory before npm pack
npm pack does not create its --pack-destination; on a fresh runner the step failed with ENOENT before the packed shell tests ran.
2026-10-11 20:56:07 +09:00
dayuan.jiang 3422b5c55b fix(mcp-server): review fixes for the shell's commits and export timeout
A write taken in place that leaves the canvas as it is gets no autosave from draw.io, so the marker for its copy stayed armed and ate the user's next paper-size edit: arm it only when the write changed the document. The 15 s export timeout now retires the export's sequence number, so a result POST that answers late no longer restores the document a second time.
2026-10-11 20:56:07 +09:00
dayuan.jiang e533aac6c4 fix(canvas): ignore autosaves of the document a pending full load replaces
An autosave that arrives after loadDiagram sent a full load, and before draw.io reports it, is of the canvas being replaced (draw.io reports each load, in order). Until now it overwrote chartXMLRef: in the MCP shell, a late autosave of a one-page projection replaced the cached document, and a remount of an external draw.io then put the projection on the canvas as the whole document.
2026-10-11 20:56:07 +09:00
dayuan.jiang 3b3219c73d ci: run the shell's browser tests on the packed tarball, publish on shared code changes
The e2e job's first shard builds the MCP package, packs it, installs the
tarball in an empty directory and runs packages/mcp-server/tests/e2e
against the installed dist/index.js (MCP_SERVER_ENTRY), so a shell that
builds but fails in the browser, or a tarball missing a runtime
dependency, fails the PR. Its traces go with the Playwright report. The
publish workflow also triggers on the web app code the shell is bundled
from (components, contexts, hooks, lib, stores, globals.css) and the
root lockfile.
2026-10-11 20:56:07 +09:00
dayuan.jiang 349ea26d8b fix(mcp-server): review fixes for the shell's build
tsc -p shell checked nothing: the inherited exclude dropped every shell
file; the shell's tsconfig now excludes node_modules only. Tailwind scans
app-toaster.tsx too, so the sync notices look like the web app's toasts.
The bundle keeps its dependencies' license comments (esbuild appends them
at the end). postcss, which build-shell.mjs loads from the root, is a
declared devDependency.
2026-10-11 20:56:07 +09:00
dayuan.jiang 3ef14318f9 fix(mcp-server): review fixes for the preview URL and the theme menu
The stalled-tab note names the page start_session opened (the shell
with DRAWIO_PREVIEW_UI=shell) instead of the classic page. With the
shell not built, start_session opens the classic page and the server
says so at startup, as it does for a missing dist/drawio. BROWSER=none
skips the system browser, as Vite and CRA dev servers do: the e2e tests
set it. Without a fixed DRAWIO_UI, the shell's draw.io URL carries
themes=1 so Extras > Theme is offered, as on the classic page.
2026-10-11 20:56:07 +09:00
dayuan.jiang b6bd032c2c fix(mcp-server): review fixes for the shell's sync core
A recovery loads the server's diagram in full and waits for draw.io's
load report, as the classic page does: whether the canvas takes a write
in place is decided inside DiagramProvider, and an autosave of the
canvas being replaced went to the server as an edit when the decision
was wrong. A paper size change alone is pushed again: autosaves are
compared as documents only for draw.io's own copy of a committed write.
A projection stays on screen until draw.io reports the restore load
(5 s at most), the whole export ends in 15 s even when its result POST
hangs, and an export in flight is dropped when draw.io starts over. A
refused poll shows the tab offline. With several pages and the page on
screen unknown, a write loads in full. hasLoadOnlySettings and the
highlight of an AI change are shared with the editor bridge.
2026-10-11 20:56:07 +09:00
dayuan.jiang 5ed8940b48 test(mcp-server): e2e of the shell's sync with the server
The shell connects, shows what create_new_diagram draws, marks an
edit_diagram change and takes it back with one Ctrl+Z (which get_diagram
then reflects), pushes a shape inserted in the editor, serves
screenshot_diagram, and shows another page only for its SVG export before
the user's page comes back.
2026-10-11 20:56:07 +09:00
dayuan.jiang 21807417c5 test(mcp-server): port the preview protocol tests to the sync core
The recovery, thumbnail, size limit and History cases of
tests/unit/mcp-preview-recovery.test.ts, driven against mcp-sync-core.ts
with a stubbed server and canvas, plus the stale 409 recovery, an export
request answered once, a projection that ignores edits and restores the
document, the thumbnail of a write loaded before draw.io was ready, writes
taken in place as commits (draw.io's own copy is not pushed), and the
commit-or-load decision table of decideLoad.
2026-10-11 20:56:07 +09:00
dayuan.jiang a2ca229830 feat(mcp-server): the shell syncs with the server like the classic page
shell/mcp-sync-core.ts ports preview.js's protocol without the page or
React: polling GET /api/state every 2 s with the state id and version,
pushes of the user's edits with their base version and state, 409 and 413
handling, recovery of a recreated session (the tab's copy goes to History),
the token refresh after a 403, sync and export requests (PNG by page id,
SVG of another page through a transient one-page projection with autosave
ignored, the 600 ms wait for icons), thumbnails for History, and History
reads and restores that name the state the list belongs to.

A server write goes on the canvas as one undo step (loadDiagram commit,
with the change marked) when decideLoad finds it changes only the page on
screen, keeps the file variables and needs no load-only setting; the other
pages are compared with the MCP core's contentFingerprint. draw.io's own
re-serialized copy of such a write is recognized with isSameDocument and
not pushed back as an edit.

shell/use-mcp-sync.ts mounts the sync once inside DiagramProvider, reads
draw.io's autosave and load messages, and reports the connection state,
which the shell's status bar shows; notices come as toasts from the
dictionaries.
2026-10-11 20:56:07 +09:00
dayuan.jiang b4c34c9361 feat(canvas): DiagramProvider exports with options and shows a transient document
requestExport(request, timeoutMs) runs one draw.io export with the
request's own parameters (a PNG's pageId and width) and resolves with its
answer (data, or xml for format "xml"); null when draw.io does not answer.
showTransient(xml) puts a document on the canvas for an export only: it is
not recorded, its autosaves are ignored, and the next loadDiagram brings
the user back to the page they were on. The MCP's canvas shell answers the
server's export requests with both.
2026-10-11 20:56:07 +09:00
dayuan.jiang 1f27036de1 ci: install the root dependencies before building the mcp package
The shell build needs the root's esbuild, Tailwind and React.
2026-10-11 20:56:07 +09:00
dayuan.jiang 7be9b5ace8 test(mcp-server): open the built shell with playwright
npm run test:e2e starts dist/index.js over the MCP SDK with
DRAWIO_PREVIEW_UI=shell, opens the URL start_session returns in headless
Chromium, and checks that the bundled draw.io loads without console
errors and that the theme toggle switches draw.io in place.
2026-10-11 20:56:07 +09:00
dayuan.jiang b6f42bcddc feat(mcp-server): serve the canvas shell at /shell/ behind DRAWIO_PREVIEW_UI
GET /shell/ fills the page template's {{CONFIG_JSON}} with the session,
the API token, where draw.io comes from and the host's editor settings,
with the classic page's security headers; /shell/<file> serves the built
files like the draw.io copy. start_session opens the shell when
DRAWIO_PREVIEW_UI=shell; the classic page stays the default.
2026-10-11 20:56:07 +09:00
dayuan.jiang 77c6a2d807 feat(mcp-server): canvas shell bundled from the web app's canvas with esbuild
The shell (packages/mcp-server/shell) renders DrawioFrame inside the
shared providers without the chat: its config comes from
window.__MCP_CONFIG__, the four dictionaries ship in the bundle, the
theme is kept under an mcp: localStorage key, and system fonts stand in
for the web fonts. scripts/build-shell.mjs bundles it into dist/shell
with the root's esbuild and Tailwind; check-package caps shell.js at
1.5 MB and shell.css at 300 KB.
2026-10-11 20:56:07 +09:00
dayuan.jiang 7e2edbf8a1 refactor(canvas): getDrawioSrc takes the editor's source, and CanvasStage can be given one 2026-10-11 20:56:07 +09:00
dayuan.jiang 20ce02a602 docs(mcp-server): mermaid is in the bundled draw.io, which has no image proxy
- Mermaid's converter is in js/extensions.min.js, which the editor loads at
  startup, so the README no longer lists it as left out; the org chart
  layout (js/orgchart.min.js) is the example of a feature that is.
- Note that the bundled copy, like the web app's, has no /drawio/proxy:
  images from other websites, including those in some templates, are left
  out of exports and thumbnails. The guard's comment names this as a known
  limitation it does not cover.
2026-10-11 20:56:07 +09:00
dayuan.jiang a407f66268 fix(mcp-server): name exports at random and stamp the draw.io version into ETags
- Export requests carried a per-process counter. The preview page retries a
  result refused with 403 against the process that took over the port, so a
  late result of the old process's export could be taken for the new
  process's export with the same number. The id is a random UUID now.
- The ETag of a bundled draw.io file now starts with the version the fetch
  script stamps into dist/drawio/.version. An install that keeps the
  archive's dates (npm does not) would otherwise answer 304 for a changed
  file of the same size after an upgrade.
2026-10-11 20:56:07 +09:00
dayuan.jiang 6e375fb963 refactor(canvas): review fixes for the busy flag, the comparison note and the bundle size check
- busyReason had no reader: the chat engine sets isBusy through the store's
  generic set, like every other flag.
- The isSameDocument comment says the two "same document" rules disagree
  in both directions, so neither is a subset of the other.
- The import boundary test bundles minified with one pako (the MCP core
  resolves its own copy) and caps the canvas core at 160 KB (134 KB now).
2026-10-11 20:56:07 +09:00
dayuan.jiang 48c7e43521 docs: review fixes for the draw.io version pointer and the preview's embedding
- The offline deployment guides point at packages/mcp-server/src/
  drawio-version.json for the draw.war version; scripts/fetch-drawio.mjs
  no longer names it.
- The MCP README says what the bundled draw.io copy includes, and that the
  preview page cannot be shown inside an editor's built-in browser since it
  is served with frame-ancestors 'self'.
2026-10-11 20:56:07 +09:00
dayuan.jiang 8171693ef6 fix(mcp-server): review fixes for the draw.io file list and its guard
- The list now ships the templates of Insert > Template, the PlantUML
  parser of Insert > Advanced and the template dialog's icon: the menu
  items were shown but failed with 404s. 15.1 MB packed; the tarball cap
  goes from 40 MB to 20 MB, where it still catches a list that grew by a
  whole js/ directory.
- The guard drives both features, fails when the copy it runs against
  lacks files the editor asked for or when an export does not answer,
  checks that the copy is the pinned draw.io version, and runs in CI
  (npm run check-drawio, one E2E shard) so a list regression cannot reach
  a release.
2026-10-11 20:56:07 +09:00
dayuan.jiang 7fb78c2de6 fix(mcp-server): review fixes for the preview token and the draw.io static files
- The preview page fetches a fresh copy of itself and retries once when an
  API request is refused with 403: another MCP process, with its own token,
  now answers on this port, and the recovery logic (recoverState) needs its
  polls to go through. The page is sent with Cache-Control: no-store.
- draw.io files are served with an ETag and Cache-Control: no-cache instead
  of a 24 hour max-age: their names do not change between versions, so a
  package upgrade must reach the browser on the next preview. HEAD and
  If-None-Match (304) are answered.
- The file read stream goes through stream.pipeline, so a read error no
  longer ends the MCP process and a client that leaves mid-download no
  longer leaks the file handle.
2026-10-11 20:56:07 +09:00
dayuan.jiang 7f84be7001 build(mcp-server): require the bundled draw.io in the package and cap the tarball at 40 MB 2026-10-11 20:56:07 +09:00
dayuan.jiang d415f19cf5 feat(mcp-server): serve the bundled draw.io same-origin behind an api token
GET /drawio/<path> serves dist/drawio with a MIME table, a day of
caching and nosniff; paths are normalized and never reach WEB-INF or
META-INF. The preview embeds /drawio/index.html when the copy exists and
DRAWIO_BASE_URL is unset, else the external draw.io as before (and
start_session says so). Every /api request must carry the per-process
X-Drawio-Token the page gets in its HTML; pages send
frame-ancestors 'self' and nosniff.
2026-10-11 20:56:07 +09:00
dayuan.jiang 38fe675d6b feat(mcp-server): bundle a trimmed draw.io into dist/drawio at build time
scripts/fetch-drawio.mjs downloads the pinned draw.war into a cache
(DRAWIO_WAR_CACHE, or DRAWIO_WAR for a local file), checks its SHA-256
and extracts the files named in drawio-files.txt plus a LICENSE with the
Apache-2.0 text. scripts/check-drawio-files.mjs drives the embedded
editor in headless Chromium from a full copy, records every requested
file and checks or rewrites (--update) the list.
2026-10-11 20:56:07 +09:00
dayuan.jiang f89d23880e refactor(scripts): pin the draw.io release in one json file and share the zip reader 2026-10-11 20:56:07 +09:00
dayuan.jiang 1aa8abcd6e test(unit): canvas import boundary and the shared version card
The boundary test scans the canvas modules' imports and bundles the canvas components with esbuild; both fail on anything from components/chat, the tool handlers, next/navigation, next/font, next/script, next/headers or a server-only module. The card test renders VersionCard and VersionStrip on a fake VersionsSource.
2026-10-11 20:56:07 +09:00
dayuan.jiang 899b6a3b5b refactor(canvas): decouple the canvas from the chat engine
The canvas components will be reused by the MCP server's browser shell,
which has no chat and no Next.js. They now read everything they need
from small contexts and the canvas store instead of the chat engine:

- components/canvas/versions-context.tsx: VersionsProvider and
  useVersionsContext give the version cards, the strip and the compare
  dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy,
  canUndo, canRedo, restore, undo, redo). The chat fills it from the
  versions store and the engine in components/chat/chat-versions.tsx.
- stores/canvas-store.ts: isBusy and busyReason; the chat engine sets
  them while a turn runs, SelectionAsk and the version UI read them.
- components/canvas/version-card.tsx: the visual version card and the
  thumbnail, out of tool-activity.tsx; the chat's card composes it and
  adds its "Show XML" link and code panel.
- compare-dialog.tsx and version-strip.tsx move to components/canvas;
  the strip takes the minimum number of versions to show as a prop, the
  chat panel computes it from the cards it has.
- components/canvas/locale-context.tsx: LocaleProvider and useLocale,
  fed by the [lang] layout; CanvasStage no longer uses next/navigation.
  The overlay (SelectionAsk) is a slot and the wait for the saved
  language is a prop, so the shell can leave both out.
- lib/version-text.ts: describeChanges is now describeChangeSummary, so
  it can be imported next to the MCP core's describeChanges.
2026-10-11 20:56:07 +09:00
233 changed files with 19298 additions and 6513 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+42
View File
@@ -0,0 +1,42 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Type check
run: npx tsc --noEmit
- name: Lint check
run: npm run check
- name: Build
run: npm run build
+3
View File
@@ -4,11 +4,14 @@ on:
push:
branches:
- main
- master
- dev
tags:
- 'v*'
pull_request:
branches:
- main
- master
env:
REGISTRY: ghcr.io
+2 -12
View File
@@ -6,13 +6,6 @@ on:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint-and-unit:
name: Lint & Unit Tests
@@ -23,15 +16,12 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run lint
run: npm run check
@@ -151,7 +141,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
node-version: "20"
cache: "npm"
- name: Install dependencies
+336
View File
@@ -0,0 +1,336 @@
import type { Metadata } from "next"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "关于 - Next AI Draw.io",
description:
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
keywords: ["AI图表", "draw.io", "AWS架构", "GCP图表", "Azure图表", "LLM"],
}
export default function AboutCN() {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
编辑器
</Link>
<Link
href="/about/cn"
className="text-blue-600 font-semibold"
>
关于
</Link>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
aria-label="在GitHub上查看"
>
<FaGithub className="w-5 h-5" />
</a>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article className="prose prose-lg max-w-none">
{/* Title */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium">
AI驱动的图表创建工具 - 对话、绘制、可视化
</p>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Bring Your Own Key */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
使用自己的 API Key
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
您也可以使用自己的 API
Key,支持多种服务商。点击聊天面板中的设置图标即可配置。
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
您的 Key
仅保存在浏览器本地,不会被存储在服务器上。
</p>
</div>
</div>
</div>
<p className="text-gray-700">
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。
</p>
{/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
功能特性
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>LLM驱动的图表创建</strong>
:利用大语言模型通过自然语言命令直接创建和操作draw.io图表
</li>
<li>
<strong>基于图像的图表复制</strong>
:上传现有图表或图像,让AI自动复制和增强
</li>
<li>
<strong>版本与撤销</strong>
:AI
的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按
Ctrl+Z 也能撤回
</li>
<li>
<strong>交互式聊天界面</strong>
:与AI实时对话来完善您的图表
</li>
<li>
<strong>AWS架构图支持</strong>
:专门支持生成AWS架构图
</li>
<li>
<strong>动画连接器</strong>
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
</li>
</ul>
{/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
示例
</h2>
<p className="text-gray-700 mb-6">
以下是一些示例提示词及其生成的图表:
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ResNet50模型架构动画
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50模型架构图"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG技术图
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
React和AWS认证流程
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="认证架构图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
敏捷Scrum流程
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="敏捷Scrum流程图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
开放式创新
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="开放式创新图"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
工作原理
</h2>
<p className="text-gray-700 mb-4">本应用使用以下技术:</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>Next.js</strong>:用于前端框架和路由
</li>
<li>
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
<code>@ai-sdk/*</code>
):用于流式AI响应和多提供商支持
</li>
<li>
<strong>draw.io</strong>
:内置在应用里的编辑器,应用可以直接控制它
</li>
</ul>
<p className="text-gray-700 mt-4">
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
</p>
{/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
多提供商支持
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>字节跳动豆包</li>
<li>AWS Bedrock(默认)</li>
<li>
OpenAI / OpenAI兼容API(通过{" "}
<code>OPENAI_BASE_URL</code>)
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
注意:<code>claude-sonnet-4-5</code>{" "}
已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。
</p>
{/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
支持与联系
</h2>
<p className="text-gray-700">
如果您觉得这个项目有用,请考虑{" "}
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
赞助
</a>{" "}
来帮助托管在线演示站点!
</p>
<p className="text-gray-700 mt-2">
如需支持或咨询,请在{" "}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHub仓库
</a>{" "}
上提交issue或联系:me[at]jiang.jp
</p>
{/* CTA */}
<div className="mt-12 text-center">
<Link
href="/"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
打开编辑器
</Link>
</div>
</article>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm">
Next AI Draw.io - 开源AI驱动的图表生成器
</p>
</div>
</footer>
</div>
)
}
+350
View File
@@ -0,0 +1,350 @@
import type { Metadata } from "next"
import Link from "next/link"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
export const metadata: Metadata = {
title: "概要 - Next AI Draw.io",
description:
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
keywords: [
"AIダイアグラム",
"draw.io",
"AWSアーキテクチャ",
"GCPダイアグラム",
"Azureダイアグラム",
"LLM",
],
}
export default function AboutJA() {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<header className="bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
エディタ
</Link>
<Link
href="/about/ja"
className="text-blue-600 font-semibold"
>
概要
</Link>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
aria-label="GitHubで見る"
>
<FaGithub className="w-5 h-5" />
</a>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<article className="prose prose-lg max-w-none">
{/* Title */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium">
AI搭載のダイアグラム作成ツール -
チャット、描画、可視化
</p>
</div>
<div className="relative mb-8 rounded-2xl bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 p-[1px] shadow-lg">
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-amber-400 via-orange-400 to-yellow-400 opacity-20" />
<div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Bring Your Own Key */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
自分のAPIキーを使用
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
キーはブラウザのローカルに保存され、サーバーには保存されません。
</p>
</div>
</div>
</div>
<p className="text-gray-700">
AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。
</p>
{/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
機能
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>LLM搭載のダイアグラム作成</strong>
:大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作
</li>
<li>
<strong>画像ベースのダイアグラム複製</strong>
:既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化
</li>
<li>
<strong>バージョンと取り消し</strong>
:AI
の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで
Ctrl+Z を押しても元に戻せます
</li>
<li>
<strong>
インタラクティブなチャットインターフェース
</strong>
:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
</li>
<li>
<strong>
AWSアーキテクチャダイアグラムサポート
</strong>
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
</li>
<li>
<strong>アニメーションコネクタ</strong>
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
</li>
</ul>
{/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
例
</h2>
<p className="text-gray-700 mb-6">
以下はいくつかのプロンプト例と生成されたダイアグラムです:
</p>
<div className="space-y-8">
{/* ResNet50 Architecture */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ResNet50モデルアーキテクチャアニメーション
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src="/resnet50.svg"
alt="ResNet50モデルアーキテクチャ図"
width={480}
height={360}
className="mx-auto"
/>
</div>
</div>
{/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG技術ダイアグラム
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAGアーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
ReactとAWSによる認証
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="認証アーキテクチャ図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
アジャイルスクラムプロセス
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="アジャイルスクラム図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
オープンイノベーション
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="オープンイノベーション図"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
仕組み
</h2>
<p className="text-gray-700 mb-4">
本アプリケーションは以下の技術を使用しています:
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>Next.js</strong>
:フロントエンドフレームワークとルーティング
</li>
<li>
<strong>Vercel AI SDK</strong>(<code>ai</code> +{" "}
<code>@ai-sdk/*</code>
):ストリーミングAIレスポンスとマルチプロバイダーサポート
</li>
<li>
<strong>draw.io</strong>
:アプリに同梱されたエディター。アプリから直接操作できます
</li>
</ul>
<p className="text-gray-700 mt-4">
ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。
</p>
{/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
マルチプロバイダーサポート
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>ByteDance Doubao</li>
<li>AWS Bedrock(デフォルト)</li>
<li>
OpenAI / OpenAI互換API(<code>OPENAI_BASE_URL</code>
経由)
</li>
<li>Anthropic</li>
<li>Google AI</li>
<li>Google Vertex AI</li>
<li>Azure OpenAI</li>
<li>Ollama</li>
<li>OpenRouter</li>
<li>DeepSeek</li>
<li>SiliconFlow</li>
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
注:<code>claude-sonnet-4-5</code>
はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。
</p>
{/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
サポート&お問い合わせ
</h2>
<p className="text-gray-700">
このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために{" "}
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
スポンサー
</a>{" "}
をご検討ください!
</p>
<p className="text-gray-700 mt-2">
サポートやお問い合わせについては、{" "}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
GitHubリポジトリ
</a>{" "}
でissueを開くか、ご連絡ください:me[at]jiang.jp
</p>
{/* CTA */}
<div className="mt-12 text-center">
<Link
href="/"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
エディタを開く
</Link>
</div>
</article>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm">
Next AI Draw.io -
オープンソースAI搭載ダイアグラムジェネレーター
</p>
</div>
</footer>
</div>
)
}
+194 -491
View File
@@ -1,420 +1,23 @@
import type { Metadata } from "next"
import Link from "next/link"
import type { ReactNode } from "react"
import { GitHubIcon } from "@/components/icons/github"
import { FaGithub } from "react-icons/fa"
import Image from "@/components/image-with-basepath"
import type { Locale } from "@/lib/i18n/config"
import { hasLocale } from "@/lib/i18n/dictionaries"
type ExampleId = "resnet50" | "rag" | "auth" | "scrum" | "inno"
type AboutTexts = {
title: string
description: string
keywords: string[]
nav: { editor: string; about: string; github: string }
tagline: string
byok: { title: string; body: string; note: string }
intro: string
// Punctuation differs between Latin and CJK text
colon: string
parenOpen: string
parenClose: string
featuresHeading: string
features: { title: string; text: string }[]
examplesHeading: string
examplesIntro: string
examples: Record<ExampleId, { title: string; alt: string }>
howHeading: string
howIntro: string
tech: { nextjs: string; aiSdk: string; drawio: string }
howOutro: string
providersHeading: string
doubao: string
bedrock: string
// Text around the OPENAI_BASE_URL code span
openai: { before: string; after: string }
// Text around the claude-sonnet-4-5 code span
modelNote: { before: string; after: string }
supportHeading: string
// Text around the sponsor link
sponsor: { before: string; link: string; after: string }
// Text around the GitHub repository link
contact: { before: string; link: string; after: string }
openEditor: string
footer: string
export const metadata: Metadata = {
title: "About - Next AI Draw.io",
description:
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
keywords: [
"AI diagram",
"draw.io",
"AWS architecture",
"GCP diagram",
"Azure diagram",
"LLM",
],
}
const texts: Record<"en" | "zh" | "ja", AboutTexts> = {
en: {
title: "About - Next AI Draw.io",
description:
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
keywords: [
"AI diagram",
"draw.io",
"AWS architecture",
"GCP diagram",
"Azure diagram",
"LLM",
],
nav: { editor: "Editor", about: "About", github: "View on GitHub" },
tagline: "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize",
byok: {
title: "Bring Your Own API Key",
body: "You can also use your own API key with any supported provider. Click the Settings icon in the chat panel to configure your provider and API key.",
note: "Your key is stored locally in your browser and is never stored on the server.",
},
intro: "A Next.js web application that integrates AI capabilities with draw.io diagrams. Create, modify, and enhance diagrams through natural language commands and AI-assisted visualization.",
colon: ": ",
parenOpen: " (",
parenClose: ")",
featuresHeading: "Features",
features: [
{
title: "LLM-Powered Diagram Creation",
text: "Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands",
},
{
title: "Image-Based Diagram Replication",
text: "Upload existing diagrams or images and have the AI replicate and enhance them automatically",
},
{
title: "Versions and Undo",
text: "Every AI change becomes a version in the chat that you can compare, restore or undo; Ctrl+Z on the canvas takes it back too",
},
{
title: "Interactive Chat Interface",
text: "Communicate with AI to refine your diagrams in real-time",
},
{
title: "AWS Architecture Diagram Support",
text: "Specialized support for generating AWS architecture diagrams",
},
{
title: "Animated Connectors",
text: "Create dynamic and animated connectors between diagram elements for better visualization",
},
],
examplesHeading: "Examples",
examplesIntro:
"Here are some example prompts and their generated diagrams:",
examples: {
resnet50: {
title: "Animated ResNet50 Model Architecture",
alt: "Architecture diagram for ResNet50 model",
},
rag: {
title: "RAG Technique Diagram",
alt: "RAG Architecture Diagram",
},
auth: {
title: "Authentication using React and AWS",
alt: "Authentication Architecture Diagram",
},
scrum: { title: "Agile Scrum Process", alt: "Agile Scrum Diagram" },
inno: { title: "Open Innovation", alt: "Open Innovation Diagram" },
},
howHeading: "How It Works",
howIntro: "The application uses the following technologies:",
tech: {
nextjs: "For the frontend framework and routing",
aiSdk: "For streaming AI responses and multi-provider support",
drawio: "The editor, bundled with the app so it can be driven directly",
},
howOutro:
"Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.",
providersHeading: "Multi-Provider Support",
doubao: "ByteDance Doubao",
bedrock: "AWS Bedrock (default)",
openai: { before: "OpenAI / OpenAI-compatible APIs (via ", after: ")" },
modelNote: {
before: "Note that ",
after: " has trained on draw.io diagrams with AWS logos, so if you want to create AWS architecture diagrams, this is the best choice.",
},
supportHeading: "Support & Contact",
sponsor: {
before: "If you find this project useful, please consider",
link: "sponsoring",
after: "to help host the live demo site!",
},
contact: {
before: "For support or inquiries, please open an issue on the",
link: "GitHub repository",
after: "or contact: me[at]jiang.jp",
},
openEditor: "Open Editor",
footer: "Next AI Draw.io - Open Source AI-Powered Diagram Generator",
},
zh: {
title: "关于 - Next AI Draw.io",
description:
"AI驱动的图表创建工具 - 对话、绘制、可视化。使用自然语言创建AWS、GCP和Azure架构图。",
keywords: [
"AI图表",
"draw.io",
"AWS架构",
"GCP图表",
"Azure图表",
"LLM",
],
nav: { editor: "编辑器", about: "关于", github: "在GitHub上查看" },
tagline: "AI驱动的图表创建工具 - 对话、绘制、可视化",
byok: {
title: "使用自己的 API Key",
body: "您也可以使用自己的 API Key,支持多种服务商。点击聊天面板中的设置图标即可配置。",
note: "您的 Key 仅保存在浏览器本地,不会被存储在服务器上。",
},
intro: "一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。",
colon: ":",
parenOpen: "(",
parenClose: ")",
featuresHeading: "功能特性",
features: [
{
title: "LLM驱动的图表创建",
text: "利用大语言模型通过自然语言命令直接创建和操作draw.io图表",
},
{
title: "基于图像的图表复制",
text: "上传现有图表或图像,让AI自动复制和增强",
},
{
title: "版本与撤销",
text: "AI 的每次修改都会成为聊天里的一个版本,可以对比、恢复或撤销;在画布上按 Ctrl+Z 也能撤回",
},
{
title: "交互式聊天界面",
text: "与AI实时对话来完善您的图表",
},
{ title: "AWS架构图支持", text: "专门支持生成AWS架构图" },
{
title: "动画连接器",
text: "在图表元素之间创建动态动画连接器,实现更好的可视化效果",
},
],
examplesHeading: "示例",
examplesIntro: "以下是一些示例提示词及其生成的图表:",
examples: {
resnet50: {
title: "ResNet50模型架构动画",
alt: "ResNet50模型架构图",
},
rag: { title: "RAG技术图", alt: "RAG架构图" },
auth: { title: "React和AWS认证流程", alt: "认证架构图" },
scrum: { title: "敏捷Scrum流程", alt: "敏捷Scrum流程图" },
inno: { title: "开放式创新", alt: "开放式创新图" },
},
howHeading: "工作原理",
howIntro: "本应用使用以下技术:",
tech: {
nextjs: "用于前端框架和路由",
aiSdk: "用于流式AI响应和多提供商支持",
drawio: "内置在应用里的编辑器,应用可以直接控制它",
},
howOutro:
"图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。",
providersHeading: "多提供商支持",
doubao: "字节跳动豆包",
bedrock: "AWS Bedrock(默认)",
openai: { before: "OpenAI / OpenAI兼容API(通过 ", after: ")" },
modelNote: {
before: "注意:",
after: " 已在带有AWS标志的draw.io图表上进行训练,因此如果您想创建AWS架构图,这是最佳选择。",
},
supportHeading: "支持与联系",
sponsor: {
before: "如果您觉得这个项目有用,请考虑",
link: "赞助",
after: "来帮助托管在线演示站点!",
},
contact: {
before: "如需支持或咨询,请在",
link: "GitHub仓库",
after: "上提交issue或联系:me[at]jiang.jp",
},
openEditor: "打开编辑器",
footer: "Next AI Draw.io - 开源AI驱动的图表生成器",
},
ja: {
title: "概要 - Next AI Draw.io",
description:
"AI搭載のダイアグラム作成ツール - チャット、描画、可視化。自然言語でAWS、GCP、Azureアーキテクチャ図を作成。",
keywords: [
"AIダイアグラム",
"draw.io",
"AWSアーキテクチャ",
"GCPダイアグラム",
"Azureダイアグラム",
"LLM",
],
nav: { editor: "エディタ", about: "概要", github: "GitHubで見る" },
tagline: "AI搭載のダイアグラム作成ツール - チャット、描画、可視化",
byok: {
title: "自分のAPIキーを使用",
body: "お好みのプロバイダーで自分のAPIキーを使用することもできます。チャットパネルの設定アイコンをクリックして設定してください。",
note: "キーはブラウザのローカルに保存され、サーバーには保存されません。",
},
intro: "AI機能とdraw.ioダイアグラムを統合したNext.jsウェブアプリケーションです。自然言語コマンドとAI支援の可視化により、ダイアグラムを作成、修正、強化できます。",
colon: ":",
parenOpen: "(",
parenClose: ")",
featuresHeading: "機能",
features: [
{
title: "LLM搭載のダイアグラム作成",
text: "大規模言語モデルを活用して、自然言語コマンドで直接draw.ioダイアグラムを作成・操作",
},
{
title: "画像ベースのダイアグラム複製",
text: "既存のダイアグラムや画像をアップロードし、AIが自動的に複製・強化",
},
{
title: "バージョンと取り消し",
text: "AI の変更はそれぞれチャット内のバージョンになり、比較・復元・取り消しができます。キャンバスで Ctrl+Z を押しても元に戻せます",
},
{
title: "インタラクティブなチャットインターフェース",
text: "AIとリアルタイムでコミュニケーションしてダイアグラムを改善",
},
{
title: "AWSアーキテクチャダイアグラムサポート",
text: "AWSアーキテクチャダイアグラムの生成を専門的にサポート",
},
{
title: "アニメーションコネクタ",
text: "より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成",
},
],
examplesHeading: "例",
examplesIntro:
"以下はいくつかのプロンプト例と生成されたダイアグラムです:",
examples: {
resnet50: {
title: "ResNet50モデルアーキテクチャアニメーション",
alt: "ResNet50モデルアーキテクチャ図",
},
rag: { title: "RAG技術ダイアグラム", alt: "RAGアーキテクチャ図" },
auth: {
title: "ReactとAWSによる認証",
alt: "認証アーキテクチャ図",
},
scrum: {
title: "アジャイルスクラムプロセス",
alt: "アジャイルスクラム図",
},
inno: {
title: "オープンイノベーション",
alt: "オープンイノベーション図",
},
},
howHeading: "仕組み",
howIntro: "本アプリケーションは以下の技術を使用しています:",
tech: {
nextjs: "フロントエンドフレームワークとルーティング",
aiSdk: "ストリーミングAIレスポンスとマルチプロバイダーサポート",
drawio: "アプリに同梱されたエディター。アプリから直接操作できます",
},
howOutro:
"ダイアグラムはdraw.ioでレンダリングできるXMLとして表現されます。AIがコマンドを処理し、それに応じてこのXMLを生成または変更します。",
providersHeading: "マルチプロバイダーサポート",
doubao: "ByteDance Doubao",
bedrock: "AWS Bedrock(デフォルト)",
openai: { before: "OpenAI / OpenAI互換API(", after: "経由)" },
modelNote: {
before: "注:",
after: "はAWSロゴ付きのdraw.ioダイアグラムで学習されているため、AWSアーキテクチャダイアグラムを作成したい場合は最適な選択です。",
},
supportHeading: "サポート&お問い合わせ",
sponsor: {
before: "このプロジェクトが役に立ったら、ライブデモサイトのホスティングを支援するために",
link: "スポンサー",
after: "をご検討ください!",
},
contact: {
before: "サポートやお問い合わせについては、",
link: "GitHubリポジトリ",
after: "でissueを開くか、ご連絡ください:me[at]jiang.jp",
},
openEditor: "エディタを開く",
footer: "Next AI Draw.io - オープンソースAI搭載ダイアグラムジェネレーター",
},
}
// The example prompts stay in English in every language
const examples: { id: ExampleId; src: string; prompt: ReactNode }[] = [
{
id: "resnet50",
src: "/resnet50.svg",
prompt: (
<>
Give me an <strong>animated</strong> architecture diagram of the
ResNet50 model.
</>
),
},
{
id: "rag",
src: "/rag_prod.svg",
prompt: (
<>
Generate a RAG architecture diagram for{" "}
<strong>chat application</strong>. Use connected diagram for
data ingestion
</>
),
},
{
id: "auth",
src: "/auth.svg",
prompt: (
<>
Generate authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless architecture.
</>
),
},
{
id: "scrum",
src: "/agile_scrum.svg",
prompt: (
<>
Generate agile scrum workflow diagram for software development
team.
</>
),
},
{
id: "inno",
src: "/inno.svg",
prompt: (
<>
Create visualization of Henry Chesbrough&apos;s Open Innovation
model.
</>
),
},
]
// Traditional Chinese has no About text of its own and shows the Simplified one
function textsFor(rawLang: string): AboutTexts {
const lang: Locale = hasLocale(rawLang) ? rawLang : "en"
return texts[lang === "zh-Hant" ? "zh" : lang]
}
type Props = { params: Promise<{ lang: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang } = await params
const t = textsFor(lang)
return { title: t.title, description: t.description, keywords: t.keywords }
}
export default async function About({ params }: Props) {
const { lang } = await params
const t = textsFor(lang)
const [hero, ...grid] = examples
export default function About() {
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
@@ -422,32 +25,32 @@ export default async function About({ params }: Props) {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<Link
href={`/${lang}`}
href="/"
className="text-xl font-bold text-gray-900 hover:text-gray-700"
>
Next AI Draw.io
</Link>
<nav className="flex items-center gap-6 text-sm">
<Link
href={`/${lang}`}
href="/"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
{t.nav.editor}
Editor
</Link>
<Link
href={`/${lang}/about`}
href="/about"
className="text-blue-600 font-semibold"
>
{t.nav.about}
About
</Link>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
aria-label={t.nav.github}
aria-label="View on GitHub"
>
<GitHubIcon className="w-5 h-5" />
<FaGithub className="w-5 h-5" />
</a>
</nav>
</div>
@@ -463,7 +66,8 @@ export default async function About({ params }: Props) {
Next AI Draw.io
</h1>
<p className="text-xl text-gray-600 font-medium">
{t.tagline}
AI-Powered Diagram Creation Tool - Chat, Draw,
Visualize
</p>
</div>
@@ -473,53 +77,92 @@ export default async function About({ params }: Props) {
{/* Bring Your Own Key */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
{t.byok.title}
Bring Your Own API Key
</h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
{t.byok.body}
You can also use your own API key with any
supported provider. Click the Settings icon
in the chat panel to configure your provider
and API key.
</p>
<p className="text-xs text-gray-500 max-w-md mx-auto">
{t.byok.note}
Your key is stored locally in your browser
and is never stored on the server.
</p>
</div>
</div>
</div>
<p className="text-gray-700">{t.intro}</p>
<p className="text-gray-700">
A Next.js web application that integrates AI
capabilities with draw.io diagrams. Create, modify, and
enhance diagrams through natural language commands and
AI-assisted visualization.
</p>
{/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
{t.featuresHeading}
Features
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
{t.features.map((feature) => (
<li key={feature.title}>
<strong>{feature.title}</strong>
{t.colon}
{feature.text}
</li>
))}
<li>
<strong>LLM-Powered Diagram Creation</strong>:
Leverage Large Language Models to create and
manipulate draw.io diagrams directly through natural
language commands
</li>
<li>
<strong>Image-Based Diagram Replication</strong>:
Upload existing diagrams or images and have the AI
replicate and enhance them automatically
</li>
<li>
<strong>Versions and Undo</strong>: Every AI change
becomes a version in the chat that you can compare,
restore or undo; Ctrl+Z on the canvas takes it back
too
</li>
<li>
<strong>Interactive Chat Interface</strong>:
Communicate with AI to refine your diagrams in
real-time
</li>
<li>
<strong>AWS Architecture Diagram Support</strong>:
Specialized support for generating AWS architecture
diagrams
</li>
<li>
<strong>Animated Connectors</strong>: Create dynamic
and animated connectors between diagram elements for
better visualization
</li>
</ul>
{/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
{t.examplesHeading}
Examples
</h2>
<p className="text-gray-700 mb-6">{t.examplesIntro}</p>
<p className="text-gray-700 mb-6">
Here are some example prompts and their generated
diagrams:
</p>
<div className="space-y-8">
{/* First example on its own */}
{/* ResNet50 Architecture */}
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{t.examples[hero.id].title}
Animated ResNet50 Model Architecture
</h3>
<p className="text-gray-600 mb-4">
<strong>Prompt:</strong> {hero.prompt}
<strong>Prompt:</strong> Give me an{" "}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image
src={hero.src}
alt={t.examples[hero.id].alt}
src="/resnet50.svg"
alt="Architecture diagram for ResNet50 model"
width={480}
height={360}
className="mx-auto"
@@ -529,67 +172,125 @@ export default async function About({ params }: Props) {
{/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6">
{grid.map((example) => (
<div key={example.id} className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{t.examples[example.id].title}
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong>{" "}
{example.prompt}
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src={example.src}
alt={t.examples[example.id].alt}
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG Technique Diagram
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG
architecture diagram for{" "}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/rag_prod.svg"
alt="RAG Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
))}
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Authentication using React and AWS
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate
authentication process using React with{" "}
<strong>AWS</strong>. Use Serverless
architecture.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/auth.svg"
alt="Authentication Architecture Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Agile Scrum Process
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate agile
scrum workflow diagram for software
development team.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/agile_scrum.svg"
alt="Agile Scrum Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Open Innovation
</h3>
<p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Create
visualization of Henry Chesbrough&apos;s
Open Innovation model.
</p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image
src="/inno.svg"
alt="Open Innovation Diagram"
width={480}
height={360}
className="max-w-full max-h-full object-contain"
/>
</div>
</div>
</div>
</div>
{/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
{t.howHeading}
How It Works
</h2>
<p className="text-gray-700 mb-4">{t.howIntro}</p>
<p className="text-gray-700 mb-4">
The application uses the following technologies:
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2">
<li>
<strong>Next.js</strong>
{t.colon}
{t.tech.nextjs}
<strong>Next.js</strong>: For the frontend framework
and routing
</li>
<li>
<strong>Vercel AI SDK</strong>
{t.parenOpen}
<code>ai</code> + <code>@ai-sdk/*</code>
{t.parenClose}
{t.colon}
{t.tech.aiSdk}
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "}
<code>@ai-sdk/*</code>): For streaming AI responses
and multi-provider support
</li>
<li>
<strong>draw.io</strong>
{t.colon}
{t.tech.drawio}
<strong>draw.io</strong>: The editor, bundled with
the app so it can be driven directly
</li>
</ul>
<p className="text-gray-700 mt-4">{t.howOutro}</p>
<p className="text-gray-700 mt-4">
Diagrams are represented as XML that can be rendered in
draw.io. The AI processes your commands and generates or
modifies this XML accordingly.
</p>
{/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
{t.providersHeading}
Multi-Provider Support
</h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>{t.doubao}</li>
<li>{t.bedrock}</li>
<li>ByteDance Doubao</li>
<li>AWS Bedrock (default)</li>
<li>
{t.openai.before}
<code>OPENAI_BASE_URL</code>
{t.openai.after}
OpenAI / OpenAI-compatible APIs (via{" "}
<code>OPENAI_BASE_URL</code>)
</li>
<li>Anthropic</li>
<li>Google AI</li>
@@ -602,47 +303,48 @@ export default async function About({ params }: Props) {
<li>ModelScope</li>
</ul>
<p className="text-gray-700 mt-4">
{t.modelNote.before}
<code>claude-sonnet-4-5</code>
{t.modelNote.after}
Note that <code>claude-sonnet-4-5</code> has trained on
draw.io diagrams with AWS logos, so if you want to
create AWS architecture diagrams, this is the best
choice.
</p>
{/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
{t.supportHeading}
Support &amp; Contact
</h2>
<p className="text-gray-700">
{t.sponsor.before}{" "}
If you find this project useful, please consider{" "}
<a
href="https://github.com/sponsors/DayuanJiang"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{t.sponsor.link}
sponsoring
</a>{" "}
{t.sponsor.after}
to help host the live demo site!
</p>
<p className="text-gray-700 mt-2">
{t.contact.before}{" "}
For support or inquiries, please open an issue on the{" "}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{t.contact.link}
GitHub repository
</a>{" "}
{t.contact.after}
or contact: me[at]jiang.jp
</p>
{/* CTA */}
<div className="mt-12 text-center">
<Link
href={`/${lang}`}
href="/"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
{t.openEditor}
Open Editor
</Link>
</div>
</article>
@@ -652,7 +354,8 @@ export default async function About({ params }: Props) {
<footer className="bg-white border-t border-gray-200 mt-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-gray-600 text-sm">
{t.footer}
Next AI Draw.io - Open Source AI-Powered Diagram
Generator
</p>
</div>
</footer>
+17 -6
View File
@@ -1,5 +1,3 @@
import type { MaskedSecret as SecretValue } from "@/lib/admin/auth"
import type { AdminProviderInput as AdminProvider } from "@/lib/admin/providers"
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
@@ -7,10 +5,7 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
// The same shapes the admin API validates and returns. Secret fields of a
// provider hold either a masked marker (unchanged) or a plaintext string
// (new value).
export type { AdminProvider, SecretValue }
export type SecretValue = { isSet: true; hint: string }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
@@ -29,6 +24,22 @@ export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
+27
View File
@@ -3,6 +3,13 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
@@ -199,6 +206,26 @@ export function SettingField({
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
case "secret":
control = (
<div className="w-full max-w-md">
+16 -13
View File
@@ -41,13 +41,6 @@ export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale }))
}
const OG_LOCALE: Record<Locale, string> = {
en: "en_US",
zh: "zh_CN",
ja: "ja_JP",
"zh-Hant": "zh_HK",
}
// Generate metadata per locale
export async function generateMetadata({
params,
@@ -55,19 +48,22 @@ export async function generateMetadata({
params: Promise<{ lang: string }>
}): Promise<Metadata> {
const { lang: rawLang } = await params
const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
const lang = (
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
// Default to English metadata
const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI 驱动的图表生成器",
ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
}
const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
}
@@ -96,7 +92,14 @@ export async function generateMetadata({
type: "website",
url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io",
locale: OG_LOCALE[lang],
locale:
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
images: [
{
url: "/architecture.png",
+4
View File
@@ -53,6 +53,10 @@ function validateValue(def: SettingDef, value: string): string | null {
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
+32 -19
View File
@@ -1,3 +1,4 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
@@ -5,7 +6,6 @@ import {
mergeSecrets,
} from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers"
import { testModelConnection } from "@/lib/test-model-connection"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
@@ -48,24 +48,37 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [],
)
// Without a URL of its own, chat sends the entry's key to the server's
// <P>_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.
// The admin's own request supplies the EdgeOne endpoint, cookies and
// access code, as a chat request would.
const serverUrl = globalBaseUrl(resolved.provider)
return testModelConnection(
req,
{
provider: resolved.provider,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl || serverUrl,
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
},
!resolved.baseUrl && !!serverUrl,
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: {
"Content-Type": "application/json",
// Checked again there, in place of an access code
"x-admin-password": req.headers.get("x-admin-password") || "",
// The EdgeOne function checks the access code and Pages
// cookies, and its URL is built from the page's origin
"x-access-code": req.headers.get("x-access-code") || "",
cookie: req.headers.get("cookie") || "",
...(req.headers.get("origin") && {
origin: req.headers.get("origin") as string,
}),
},
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
// Without a URL of its own, chat sends the entry's key to
// the server's <P>_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,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
)
}
+318 -76
View File
@@ -1,15 +1,15 @@
import {
APICallError,
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
isFileUIPart,
isTextUIPart,
pruneMessages,
safeValidateUIMessages,
stepCountIs,
streamText,
} from "ai"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import {
CACHE_POINT,
@@ -21,14 +21,14 @@ import {
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
dropInvalidToolCalls,
fixToolInputJson,
formatSelectionContext,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import type { ChatUIMessage } from "@/lib/chat-message"
import { chatTools } from "@/lib/chat-tools"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import {
checkAndIncrementRequest,
@@ -56,11 +56,50 @@ import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import {
STYLE_CLASS_EXAMPLE,
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,
// which is also where Node's response-body timeout on the upstream stream lands.
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.write({ type: "start" })
writer.write({
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
})
writer.write({
type: "tool-input-delta",
toolCallId,
inputTextDelta: xml,
})
writer.write({
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml },
})
writer.write({ type: "finish" })
},
})
return createUIMessageStreamResponse({ stream })
}
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>()
// A Request's signal follows the client's disconnect only while the Request
@@ -72,21 +111,6 @@ const requestOfResponse = new WeakMap<Response, Request>()
// Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
// Message structure for DEBUG_LLM_PAYLOAD logs: roles, part types, tool names
// and input keys, without the diagram XML or image data
const describeMessages = (msgs: any[]) =>
msgs.map((m) => ({
role: m.role,
parts: (m.parts ?? m.content ?? []).map(
(p: any) =>
p.type +
(p.toolName ? `:${p.toolName}` : "") +
(p.input && typeof p.input === "object"
? `(${Object.keys(p.input).join(",")})`
: ""),
),
}))
async function handleChatRequest(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
@@ -95,19 +119,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
if (accessDenied) return accessDenied
const body = await req.json()
// The messages in the SDK's UI shape. The route writes its own system
// messages, so none may come from the client.
const validated = await safeValidateUIMessages<ChatUIMessage>({
messages: body.messages,
})
if (!validated.success) {
return Response.json(
{ error: validated.error.message },
{ status: 400 },
)
}
const messages = validated.data.filter((m) => m.role !== "system")
const { xml, previousXml, sessionId } = body
const { messages, xml, previousXml, sessionId } = body
const customSystemMessage =
typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000)
@@ -126,8 +138,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [...messages]
.reverse()
.find((m) => m.role === "user")
const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
.find((m: any) => m.role === "user")
const userInputText =
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user
setTraceInput({
@@ -143,6 +156,23 @@ async function handleChatRequest(req: Request): Promise<Response> {
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || !hasCells(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || "", !!filePart)
if (cached) {
return createCachedStreamResponse(cached.xml)
}
}
// === CACHE CHECK END ===
// Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
@@ -318,14 +348,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
)
// System prompt, with the model name filled in and the style section chosen
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5)
const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
: systemMessage
// Extract file parts (images) from the last user message
const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? []
const fileParts =
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models
@@ -338,37 +370,98 @@ async function handleChatRequest(req: Request): Promise<Response> {
${userInputText}
"""`
// Convert UIMessages to ModelMessages. 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). Messages left without content (a notice
// the chat shows is a data part, which the model never sees) are
// dropped: Bedrock rejects empty content.
const modelMessages = pruneMessages({
messages: await convertToModelMessages(messages, {
ignoreIncompleteToolCalls: true,
}),
// 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",
JSON.stringify(describeMessages(messages)),
)
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 =
process.env.ENABLE_HISTORY_XML_REPLACE === "true"
const placeholderMessages = enableHistoryReplace
? replaceHistoricalToolInputs(modelMessages)
: modelMessages
// Filter out messages with empty content arrays (Bedrock API rejects these)
// This is a safety measure - ideally convertToModelMessages should handle all cases
let enhancedMessages = placeholderMessages.filter(
(msg: any) =>
msg.content && Array.isArray(msg.content) && msg.content.length > 0,
)
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// 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.
let enhancedMessages = dropInvalidToolCalls(modelMessages)
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) {
console.log(
"[route.ts] to model",
JSON.stringify(describeMessages(enhancedMessages)),
)
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)
@@ -473,10 +566,13 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
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
// (the client's own system messages were dropped above)
// 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
@@ -523,31 +619,166 @@ 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
// Langfuse telemetry (undefined when not configured)
experimental_telemetry: getTelemetryConfig({
sessionId: validSessionId,
userId,
// Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({
sessionId: validSessionId,
userId,
}),
}),
// The quota counts each step as it ends: a later step that fails or
// is stopped has still used these tokens (onFinish runs only for a
// completed answer). Awaited, so the write is done before the host
// may freeze the function after the answer ends.
onStepFinish: async ({ usage }) => {
if (countsQuota) {
await recordTokenUsage(userId, usage.totalTokens ?? 0)
}
},
onFinish: ({ text }) => {
onFinish: ({ text, totalUsage }) => {
// AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput(text)
// Record token usage for server-side quota tracking (if enabled)
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
// inputTokens already includes cache reads and writes in AI SDK 6
if (countsQuota && totalUsage && !stopped) {
const totalTokens =
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0)
recordTokenUsage(userId, totalTokens)
}
},
// onFinish is skipped when the stream fails or is aborted, so end the trace here
onError: ({ error }) => {
console.error(error) // what AI SDK does without an onError
endTrace()
},
onAbort: () => endTrace(),
tools: chatTools,
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: {
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2")
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
6. Escape special chars in values: &lt; &gt; &amp; &quot;
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
Example (generate ONLY this - no wrapper tags):
${SWIMLANE_EXAMPLE}
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;gt;.
${STYLE_CLASS_EXAMPLE}
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style.
`,
inputSchema: z.object({
xml: z
.string()
.describe("XML string to be displayed on draw.io"),
}),
},
edit_diagram: {
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({
operations: z
.array(
z.object({
operation: z
.enum(["update", "add", "delete"])
.describe(
"Operation to perform: add, update, or delete",
),
cell_id: z
.string()
.describe(
"The id of the mxCell. Must match the id attribute in new_xml.",
),
new_xml: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}),
)
.describe("Array of operations to apply"),
}),
},
append_diagram: {
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
inputSchema: z.object({
xml: z
.string()
.describe(
"Continuation XML fragment to append (NO wrapper tags)",
),
}),
},
get_shape_library: {
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
${SHAPE_LIBRARY_LIST}
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
library: z
.string()
.describe(
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
),
}),
execute: async ({ library }) => {
// Only known library names reach the file system
const result = await getShapeLibrary(
library,
path.join(process.cwd(), "docs/shape-libraries"),
)
return result.ok ? result.text : result.error
},
},
},
...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE),
}),
@@ -559,6 +790,17 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
// Keyless endpoints keep theirs: the desktop app's Ollama is the
// user's own, and EdgeOne's text is our function's explanation.
onError: (error) => streamErrorText(error, onServerCredentials),
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
// AI SDK 6 provides totalTokens directly
return {
totalTokens: usage?.totalTokens ?? 0,
finishReason: (part as any).finishReason,
}
}
return undefined
},
})
modelStreamResponses.add(response)
requestOfResponse.set(response, req)
@@ -598,7 +840,7 @@ async function safeHandler(req: Request): Promise<Response> {
} catch (error) {
response = handleError(error)
}
// Early returns and errors never reach streamText's callbacks,
// 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
+321
View File
@@ -0,0 +1,321 @@
# Draw.io XML Schema Guide
This guide explains the structure of draw.io (diagrams.net) XML files to help you understand and create diagrams programmatically.
## Basic Structure
A draw.io XML file has the following hierarchy:
```xml
<mxfile>
<diagram>
<mxGraphModel>
<root>
<mxCell /> <!-- Cells that make up the diagram -->
</root>
</mxGraphModel>
</diagram>
</mxfile>
```
## Root Element: `<mxfile>`
The root element of a draw.io file.
**Attributes:**
- `host`: The application that created the file (e.g., "app.diagrams.net")
- `modified`: Last modification timestamp
- `agent`: Browser / user agent information
- `version`: Version of the application
- `type`: File type (usually "device" or "google")
**Example:**
```xml
<mxfile host="app.diagrams.net" modified="2023-07-14T10:20:30.123Z" agent="Mozilla/5.0" version="21.5.2" type="device">
```
## Diagram Element: `<diagram>`
Each page in your draw.io document is represented by a `<diagram>` element.
**Attributes:**
- `id`: Unique identifier for the diagram
- `name`: The name of the diagram / page
**Example:**
```xml
<diagram id="pWHN0msd4Ud1ZK5cD-Hr" name="Page-1">
```
## Graph Model: `<mxGraphModel>`
Contains the actual diagram data.
**Attributes:**
- `dx`: Grid size in x-direction (usually 1)
- `dy`: Grid size in y-direction (usually 1)
- `grid`: Whether grid is enabled (0 or 1)
- `gridSize`: Grid cell size (usually 10)
- `guides`: Whether guides are enabled (0 or 1)
- `tooltips`: Whether tooltips are enabled (0 or 1)
- `connect`: Whether connections are enabled (0 or 1)
- `arrows`: Whether arrows are enabled (0 or 1)
- `fold`: Whether folding is enabled (0 or 1)
- `page`: Whether page view is enabled (0 or 1)
- `pageScale`: Scale of the page (usually 1)
- `pageWidth`: Width of the page (e.g., 850)
- `pageHeight`: Height of the page (e.g., 1100)
- `math`: Whether math typesetting is enabled (0 or 1)
- `shadow`: Whether shadows are enabled (0 or 1)
**Example:**
```xml
<mxGraphModel dx="1" dy="1" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
```
## Root Cell Container: `<root>`
Contains all the cells in the diagram. **Note:** When generating diagrams, you only need to provide the mxCell elements - the root container and root cells (id="0", id="1") are added automatically.
**Internal structure (auto-generated):**
```xml
<root>
<mxCell id="0"/> <!-- Auto-added -->
<mxCell id="1" parent="0"/> <!-- Auto-added -->
<!-- Your mxCell elements go here (start from id="2") -->
</root>
```
## Cell Elements: `<mxCell>`
The basic building block of diagrams. Cells represent shapes, connectors, text, etc.
**Attributes for all cells:**
- `id`: Unique identifier for the cell
- `parent`: ID of the parent cell (typically "1" for most cells)
- `value`: Text content of the cell
- `style`: Styling information (see Style section below)
**Attributes for shapes (vertices):**
- `vertex`: Set to "1" for shapes
- `connectable`: Whether the shape can be connected (0 or 1)
**Attributes for connectors (edges):**
- `edge`: Set to "1" for connectors
- `source`: ID of the source cell
- `target`: ID of the target cell
**Example (Rectangle shape):**
```xml
<mxCell id="2" value="Hello World" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
</mxCell>
```
**Example (Connector):**
```xml
<mxCell id="3" value="" style="endArrow=classic;html=1;rounded=0;" edge="1" parent="1" source="2" target="4">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="430" as="sourcePoint"/>
<mxPoint x="450" y="380" as="targetPoint"/>
</mxGeometry>
</mxCell>
```
## Geometry: `<mxGeometry>`
Defines the position and dimensions of cells.
**Attributes for shapes:**
- `x`: The x-coordinate of the **top-left** point of the shape.
- `y`: The y-coordinate of the **top-left** point of the shape.
- `width`: The width of the shape.
- `height`: The height of the shape.
- `as`: Specifies the role of this geometry within its parent cell. Typically set to `"geometry"` for the main shape definition.
**Attributes for connectors:**
- `relative`: Set to "1" for relative geometry
- `as`: Set to "geometry"
**Example for shapes:**
```xml
<mxGeometry x="350" y="190" width="120" height="60" as="geometry"/>
```
**Example for connectors:**
```xml
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="430" as="sourcePoint"/>
<mxPoint x="450" y="380" as="targetPoint"/>
</mxGeometry>
```
## Cell Style Reference
Styles are specified as semicolon-separated `key=value` pairs in the `style` attribute of `<mxCell>` elements.
### Shape-specific Styles
- Rectangle: `shape=rectangle`
- Ellipse: `shape=ellipse`
- Triangle: `shape=triangle`
- Rhombus: `shape=rhombus`
- Hexagon: `shape=hexagon`
- Cloud: `shape=cloud`
- Actor: `shape=actor`
- Cylinder: `shape=cylinder`
- Document: `shape=document`
- Note: `shape=note`
- Card: `shape=card`
- Parallelogram: `shape=parallelogram`
### Connector Styles
- `endArrow=classic`: Arrow type at the end (classic, open, oval, diamond, block)
- `startArrow=none`: Arrow type at the start (none, classic, open, oval, diamond)
- `curved=1`: Curved connector (0 or 1)
- `edgeStyle=orthogonalEdgeStyle`: Connector routing style
- `elbow=vertical`: Elbow direction (vertical, horizontal)
- `jumpStyle=arc`: Jump style for line crossing (arc, gap)
- `jumpSize=10`: Size of the jump
## Special Cells
Draw.io files contain two special cells that are always present:
1. **Root Cell** (id = "0"): The parent of all cells
2. **Default Parent Cell** (id = "1", parent = "0"): The default layer and parent for most cells
## Tips for Creating Draw.io XML
1. **Generate ONLY mxCell elements** - wrapper tags and root cells (id="0", id="1") are added automatically
2. Start IDs from "2" (id="0" and id="1" are reserved for root cells)
3. Assign unique and sequential IDs to all cells
4. Define parent relationships correctly (use parent="1" for top-level shapes)
5. Use `mxGeometry` elements to position shapes
6. For connectors, specify `source` and `target` attributes
7. **CRITICAL: All mxCell elements must be siblings. NEVER nest mxCell inside another mxCell.**
## Common Patterns
### Grouping Elements
To group elements, create a parent cell and set other cells' `parent` attribute to its ID:
```xml
<!-- Group container -->
<mxCell id="10" value="Group" style="group" vertex="1" connectable="0" parent="1">
<mxGeometry x="200" y="200" width="200" height="100" as="geometry" />
</mxCell>
<!-- Elements inside the group -->
<mxCell id="11" value="Element 1" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
<mxGeometry width="90" height="40" as="geometry" />
</mxCell>
<mxCell id="12" value="Element 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="10">
<mxGeometry x="110" width="90" height="40" as="geometry" />
</mxCell>
```
### Swimlanes
Swimlanes use the `swimlane` shape style. **IMPORTANT: All mxCell elements (swimlanes, steps, and edges) must be siblings under `<root>`. Edges are NOT nested inside swimlanes or steps.**
```xml
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<!-- Swimlane 1 -->
<mxCell id="lane1" value="Frontend" style="swimlane;startSize=30;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="300" as="geometry"/>
</mxCell>
<!-- Swimlane 2 -->
<mxCell id="lane2" value="Backend" style="swimlane;startSize=30;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="300" as="geometry"/>
</mxCell>
<!-- Step inside lane1 (parent="lane1") -->
<mxCell id="step1" value="Send Request" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<!-- Step inside lane2 (parent="lane2") -->
<mxCell id="step2" value="Process" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<!-- Edge connecting step1 to step2 (sibling element, NOT nested inside steps) -->
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
</root>
```
### Tables
Tables use multiple cells with parent-child relationships:
```xml
<mxCell id="30" value="Table" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
<mxGeometry x="200" y="200" width="180" height="120" as="geometry" />
</mxCell>
<mxCell id="31" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[0,0.5,1,0.5];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="30">
<mxGeometry y="30" width="180" height="30" as="geometry" />
</mxCell>
```
## Advanced Features
### Custom Attributes
Draw.io allows adding custom attributes to cells:
```xml
<mxCell id="40" value="Custom Element" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="200" y="200" width="120" height="60" as="geometry"/>
<Object label="Custom Label" customAttr="value" />
</mxCell>
```
These custom attributes can store additional metadata or be used by plugins and custom behaviors.
### User-defined Styles
You can define custom styles for cells by combining various style attributes:
```xml
<mxCell id="50" value="Custom Styled Cell"
style="shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;fixedSize=1;fillColor=#f8cecc;strokeColor=#b85450;strokeWidth=2;fontSize=14;fontStyle=1"
vertex="1" parent="1">
<mxGeometry x="300" y="200" width="120" height="80" as="geometry"/>
</mxCell>
```
### Layers
You can create multiple layers in a diagram to organize complex diagrams:
```xml
<!-- Default layer (always present) -->
<mxCell id="1" parent="0"/>
<!-- Additional custom layer -->
<mxCell id="60" value="Layer 2" style="locked=0;group=" parent="0"/>
<!-- Elements in Layer 2 -->
<mxCell id="61" value="Element in Layer 2" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="60">
<mxGeometry x="200" y="300" width="120" height="60" as="geometry"/>
</mxCell>
```
+3
View File
@@ -3,5 +3,8 @@ import { NextResponse } from "next/server"
export async function GET() {
return NextResponse.json({
accessCodeRequired: !!process.env.ACCESS_CODE_LIST,
dailyRequestLimit: Number(process.env.DAILY_REQUEST_LIMIT) || 0,
dailyTokenLimit: Number(process.env.DAILY_TOKEN_LIMIT) || 0,
tpmLimit: Number(process.env.TPM_LIMIT) || 0,
})
}
+68 -16
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
import { getLangfuseClient } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({
@@ -36,24 +37,75 @@ export async function POST(req: Request) {
const userId = getUserIdFromRequest(req)
try {
// Score the session's latest chat trace; without one, a standalone
// feedback trace carries the score
await scoreLatestTrace(langfuse, {
// Find the most recent chat trace for this session to attach the score to
const tracesResponse = await langfuse.api.trace.list({
sessionId,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
fallbackTrace: {
name: "user-feedback",
userId,
input: { messageId, feedback },
metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
},
limit: 1,
})
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (!latestTrace) {
// No trace found for this session - create a standalone feedback trace
const traceId = randomUUID()
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "trace-create",
id: randomUUID(),
timestamp,
body: {
id: traceId,
name: "user-feedback",
sessionId,
userId,
input: { messageId, feedback },
metadata: {
source: "feedback-button",
note: "standalone - no chat trace found",
},
timestamp,
},
},
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
} else {
// Attach score to the existing chat trace
const timestamp = new Date().toISOString()
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "user-feedback",
value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
},
},
],
})
}
return Response.json({ success: true, logged: true })
} catch (error) {
console.error("Langfuse feedback error:", error)
+32 -8
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "crypto"
import { z } from "zod"
import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
@@ -32,16 +33,39 @@ export async function POST(req: Request) {
}
try {
// Flag the session's latest chat trace as saved; a session without a
// trace (no chat yet) is skipped
const logged = await scoreLatestTrace(langfuse, {
const timestamp = new Date().toISOString()
// Find the most recent chat trace for this session to attach the save flag
const tracesResponse = await langfuse.api.trace.list({
sessionId,
name: "diagram-saved",
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
limit: 1,
})
return Response.json({ success: true, logged })
const traces = tracesResponse.data || []
const latestTrace = traces[0]
if (latestTrace) {
// Add a score to the existing trace to flag that user saved
await langfuse.api.ingestion.batch({
batch: [
{
type: "score-create",
id: randomUUID(),
timestamp,
body: {
id: randomUUID(),
traceId: latestTrace.id,
name: "diagram-saved",
value: 1,
comment: `User saved diagram as ${filename}.${format}`,
},
},
],
})
}
// If no trace found, skip logging (user hasn't chatted yet)
return Response.json({ success: true, logged: !!latestTrace })
} catch (error) {
console.error("Langfuse save error:", error)
return Response.json(
+4 -1
View File
@@ -7,5 +7,8 @@ export const dynamic = "force-dynamic"
export async function GET() {
const models = await loadFlattenedServerModels()
return NextResponse.json({ models })
return NextResponse.json({
models,
hasConfig: models.length > 0,
})
}
+31 -25
View File
@@ -1,9 +1,9 @@
/**
* API endpoint for VLM-based diagram validation.
* Accepts a PNG image and answers with the validation result as JSON.
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { generateText, Output } from "ai"
import { Output, streamText } from "ai"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers"
import {
@@ -11,7 +11,6 @@ import {
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import { getTelemetryConfig } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
@@ -36,6 +35,13 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
/** A fixed result in the text format useObject reads */
function createStreamingResponse(result: ValidationResult): Response {
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
@@ -47,7 +53,7 @@ export async function POST(req: Request): Promise<Response> {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) {
return Response.json(DEFAULT_VALID_RESULT)
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
@@ -117,7 +123,7 @@ export async function POST(req: Request): Promise<Response> {
error,
)
// Return valid if no vision model is configured
return Response.json(DEFAULT_VALID_RESULT)
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
@@ -127,7 +133,8 @@ export async function POST(req: Request): Promise<Response> {
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
const { output, totalUsage } = await generateText({
// Stream the VLM response for useObject consumption
const result = streamText({
model,
output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT,
@@ -147,25 +154,24 @@ export async function POST(req: Request): Promise<Response> {
},
],
maxOutputTokens: 1024,
// Ends with the request: the user pressed Stop, or left
abortSignal: req.signal,
timeout,
// Its usage and cost show in Langfuse next to the chat's
experimental_telemetry: getTelemetryConfig({
functionId: "validate-diagram",
sessionId,
userId,
}),
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ output, totalUsage }) => {
if (countsQuota && totalUsage) {
recordTokenUsage(
userId,
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0),
)
}
if (sessionId && output) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
)
}
},
})
if (countsQuota) {
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0)
}
if (sessionId) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
)
}
return Response.json(output)
return result.toTextStreamResponse()
} catch (error) {
// Log with session context if available
const errorMessage =
@@ -173,6 +179,6 @@ export async function POST(req: Request): Promise<Response> {
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
return Response.json(DEFAULT_VALID_RESULT)
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}
+233 -13
View File
@@ -1,29 +1,249 @@
import { streamText, tool } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
type TestModelRequest,
testModelConnection,
} from "@/lib/test-model-connection"
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 { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
export const runtime = "nodejs"
// The Settings dialog's Test button: a model on the user's own credentials
interface ValidateRequest {
provider: ProviderName
apiKey: string
baseUrl?: string
modelId: string
// AWS Bedrock specific
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 <P>_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
// 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) return accessError
if (accessError && checkAdminAuth(req)) return accessError
let body: TestModelRequest
try {
body = await req.json()
} catch {
const body: ValidateRequest = await req.json()
const {
provider,
apiKey,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
awsSessionToken,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
if (!provider || !modelId) {
return NextResponse.json(
{ valid: false, error: "Provider and model ID are required" },
{ 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 <P>_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 &&
!serverUrl &&
!allowPrivateUrls() &&
(await isPrivateUrl(baseUrl))
) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
)
}
// Validate credentials based on provider
if (provider === "bedrock") {
if (
!(apiKey || (awsAccessKeyId && awsSecretAccessKey)) ||
!awsRegion
) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (a Bedrock API key, or an Access Key ID and Secret Access Key, and a Region) are required",
},
{ status: 400 },
)
}
} else if (provider === "vertexai") {
if (!vertexApiKey) {
return NextResponse.json(
{
valid: false,
error: "Vertex AI API key is required for Express Mode",
},
{ status: 400 },
)
}
} else if (provider !== "ollama" && provider !== "edgeone" && !apiKey) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ 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 },
)
}
// 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)
const { code, message } = classifyLLMError(error)
return NextResponse.json(
{ valid: false, error: "Invalid JSON body" },
{ status: 400 },
{ valid: false, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
)
}
// A URL in the request is one the user typed, never the server's own
return testModelConnection(req, body, false)
}
+22 -9
View File
@@ -98,7 +98,9 @@
--shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
--duration-normal: 200ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/*
@@ -239,6 +241,13 @@
.shadow-pop {
box-shadow: var(--shadow-pop);
}
/* Older names kept for components that still use them */
.shadow-soft {
box-shadow: var(--shadow-float);
}
.shadow-dialog {
box-shadow: var(--shadow-pop);
}
}
@keyframes fadeIn {
@@ -275,13 +284,17 @@
}
}
/* A highlight sweeps across "Thinking..." text (Shimmer) */
@keyframes shimmer {
from {
background-position: 100% center;
@keyframes check-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
to {
background-position: 0% center;
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@@ -294,15 +307,15 @@
.animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both;
}
.animate-shimmer {
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in,
.animate-message-in,
.animate-panel-in,
.animate-shimmer {
.animate-check-pop {
animation: none;
}
}
+238
View File
@@ -0,0 +1,238 @@
import { Cloud } from "lucide-react"
import type { ComponentProps, ElementRef, ReactNode } from "react"
import { useEffect, useRef, useState } from "react"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
export type ModelSelectorProps = ComponentProps<typeof Dialog>
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
)
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
)
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode
}
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent className={cn("p-0", className)} {...props}>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
)
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
)
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
)
export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({
className,
...props
}: ModelSelectorListProps) => {
const listRef = useRef<ElementRef<typeof CommandList>>(null)
const [showShadow, setShowShadow] = useState(false)
useEffect(() => {
const listElement = listRef.current
if (!listElement) return
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listElement
// Show shadow if there is more content below
// Using a small threshold to handle fractional pixel rendering
setShowShadow(
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
)
}
// Initial check
checkScroll()
// Event listeners
listElement.addEventListener("scroll", checkScroll)
window.addEventListener("resize", checkScroll)
// Observe content changes (e.g. async loading of items)
const observer = new MutationObserver(checkScroll)
observer.observe(listElement, { childList: true, subtree: true })
return () => {
listElement.removeEventListener("scroll", checkScroll)
window.removeEventListener("resize", checkScroll)
observer.disconnect()
}
}, [])
return (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
}
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
)
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
)
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
)
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
)
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
)
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider: string
}
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => {
// Use Lucide icon for bedrock since models.dev doesn't have a good AWS icon
if (provider === "amazon-bedrock") {
return <Cloud className={cn("size-4", className)} />
}
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
{...props}
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${provider}.svg`}
width={16}
/>
)
}
export type ModelSelectorLogoGroupProps = ComponentProps<"div">
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className,
)}
{...props}
/>
)
export type ModelSelectorNameProps = ComponentProps<"span">
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
)
export type ModelSelectorSectionHeaderProps = {
icon: ReactNode
label: string
className?: string
}
export const ModelSelectorSectionHeader = ({
icon,
label,
className,
}: ModelSelectorSectionHeaderProps) => (
<div
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1",
className,
)}
>
<span className="[&>svg]:size-3.5" aria-hidden="true">
{icon}
</span>
<span>{label}</span>
</div>
)
+163 -60
View File
@@ -1,83 +1,186 @@
"use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { BrainIcon, ChevronDownIcon } from "lucide-react"
import type { ReactNode } from "react"
import { useEffect, useState } from "react"
import type { ComponentProps, ReactNode } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { Shimmer } from "./shimmer"
export type ReasoningProps = {
className?: string
type ReasoningContextValue = {
isStreaming: boolean
isOpen: boolean
setIsOpen: (open: boolean) => void
duration: number | undefined
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null)
export const useReasoning = () => {
const context = useContext(ReasoningContext)
if (!context) {
throw new Error("Reasoning components must be used within Reasoning")
}
return context
}
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean
/** Open while streaming, then folds once about a second after it ends */
open?: boolean
defaultOpen?: boolean
getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
children: string
onOpenChange?: (open: boolean) => void
duration?: number
}
const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000
export function Reasoning({
className,
isStreaming = false,
defaultOpen = true,
getThinkingMessage,
children,
}: ReasoningProps) {
const [isOpen, setIsOpen] = useState(defaultOpen)
const [duration, setDuration] = useState<number>()
const [hasAutoClosed, setHasAutoClosed] = useState(false)
const [startTime, setStartTime] = useState<number | null>(null)
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen = true,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const [isOpen, setIsOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
})
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now())
const [hasAutoClosed, setHasAutoClosed] = useState(false)
const [startTime, setStartTime] = useState<number | null>(null)
// Track duration when streaming starts and ends
useEffect(() => {
if (isStreaming) {
if (startTime === null) {
setStartTime(Date.now())
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null)
}
} else if (startTime !== null) {
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null)
}, [isStreaming, startTime, setDuration])
// Auto-open when streaming starts, auto-close when streaming ends (once only)
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => {
setIsOpen(false)
setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY)
return () => clearTimeout(timer)
}
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed])
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen)
}
}, [isStreaming, startTime])
// Auto-close once when streaming ends, after a short delay so the user
// sees the content
useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
const timer = setTimeout(() => {
setIsOpen(false)
setHasAutoClosed(true)
}, AUTO_CLOSE_DELAY)
return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
)
},
)
return () => clearTimeout(timer)
}
}, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
return (
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={setIsOpen}
open={isOpen}
>
<CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="mt-4 text-sm data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in">
<div className="whitespace-pre-wrap">{children}</div>
</CollapsibleContent>
</Collapsible>
)
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode
}
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>
}
return <p>Thought for {duration} seconds</p>
}
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning()
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0",
)}
/>
</>
)}
</CollapsibleTrigger>
)
},
)
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string
}
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}
>
<div className="whitespace-pre-wrap">{children}</div>
</CollapsibleContent>
),
)
Reasoning.displayName = "Reasoning"
ReasoningTrigger.displayName = "ReasoningTrigger"
ReasoningContent.displayName = "ReasoningContent"
+22 -5
View File
@@ -1,6 +1,13 @@
"use client"
import { type CSSProperties, type ElementType, memo, useMemo } from "react"
import { motion } from "motion/react"
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react"
import { cn } from "@/lib/utils"
export type TextShimmerProps = {
@@ -18,29 +25,39 @@ const ShimmerComponent = ({
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
)
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread],
)
return (
<Component
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"animate-shimmer relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className,
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
"--shimmer-duration": `${duration}s`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</Component>
</MotionComponent>
)
}
+36
View File
@@ -0,0 +1,36 @@
import type { VariantProps } from "class-variance-authority"
import type React from "react"
import { Button, type buttonVariants } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface ButtonWithTooltipProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
tooltipContent: string
children: React.ReactNode
asChild?: boolean
}
export function ButtonWithTooltip({
tooltipContent,
children,
...buttonProps
}: ButtonWithTooltipProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button {...buttonProps}>{children}</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-wrap">
{tooltipContent}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+2 -1
View File
@@ -155,7 +155,8 @@ export function CanvasStage({
key={src}
ref={drawioRef}
src={src}
css={DRAWIO_CSS}
configCss={DRAWIO_CSS}
editorCss={DRAWIO_CSS}
dark={isDark}
onLoad={onDrawioLoad}
onAutoSave={handleDiagramAutoSave}
+21 -11
View File
@@ -30,11 +30,10 @@ export interface DrawioFrameHandle {
interface DrawioFrameProps {
src: string
/**
* Theme CSS: sent with the configure message (works cross-origin too)
* and, same origin, injected as a <style> so it updates without a reload
*/
css: string
/** CSS sent with the configure message (works cross-origin too) */
configCss: string
/** CSS injected once the editor is reachable directly (same origin) */
editorCss: string
/** draw.io's dark mode, switched in place when the editor is reachable */
dark: boolean
onLoad?: () => void
@@ -53,14 +52,25 @@ const STYLE_ID = "next-ai-drawio-theme"
*/
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
function DrawioFrame(
{ src, css, dark, onLoad, onAutoSave, onExport, onEditorAccess },
{
src,
configCss,
editorCss,
dark,
onLoad,
onAutoSave,
onExport,
onEditorAccess,
},
ref,
) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const editorUiRef = useRef<any>(null)
const attachedRef = useRef(false)
const cssRef = useRef(css)
cssRef.current = css
const configCssRef = useRef(configCss)
configCssRef.current = configCss
const editorCssRef = useRef(editorCss)
editorCssRef.current = editorCss
const darkRef = useRef(dark)
darkRef.current = dark
// Handlers change on every render; the message listener reads refs
@@ -101,7 +111,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
style.id = STYLE_ID
doc.head.appendChild(style)
}
style.textContent = cssRef.current
style.textContent = editorCssRef.current
} catch {
// Cross-origin: the configure message already applied it
}
@@ -109,7 +119,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
useEffect(() => {
applyThemeCss()
}, [css])
}, [editorCss])
// Same-origin only: draw.io's own dark mode, without a reload
const applyDarkMode = () => {
@@ -199,7 +209,7 @@ export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
send("configure", {
config: {
...DRAWIO_CONFIG,
css: cssRef.current,
css: configCssRef.current,
},
})
break
+206
View File
@@ -0,0 +1,206 @@
"use client"
import { Bookmark, Plus } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import {
createTemplate,
type TemplateCreateInput,
} from "@/lib/template-storage"
interface TemplateCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
initialPrompt?: string
}
export function TemplateCreateDialog({
open,
onOpenChange,
onSuccess,
initialPrompt = "",
}: TemplateCreateDialogProps) {
const dict = useDictionary()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
const [pinned, setPinned] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset form when dialog opens with the latest initialPrompt
useEffect(() => {
if (open) {
setTitle("")
setDescription("")
setPrompt(initialPrompt)
setPinned(false)
setError(null)
}
}, [open, initialPrompt])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
e.stopPropagation()
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
return
}
setIsSubmitting(true)
setError(null)
try {
const input: TemplateCreateInput = {
prompt: trimmedPrompt,
title: title.trim() || undefined,
description: description.trim() || undefined,
pinned,
}
const template = await createTemplate(input)
if (template) {
onSuccess()
onOpenChange(false)
} else {
setError(dict.templates.createFailed)
}
} catch (err) {
console.error("Failed to create template:", err)
setError(dict.templates.createFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
{dict.templates.createTitle}
</DialogTitle>
<DialogDescription>
{dict.templates.createDescription}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Prompt field - required */}
<div className="space-y-2">
<Label htmlFor="prompt" className="text-foreground">
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
className="min-h-[100px] resize-none break-words"
required
/>
</div>
{/* Title field - optional */}
<div className="space-y-2">
<Label htmlFor="title" className="text-foreground">
{dict.templates.titleLabel}
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
/>
<p className="text-xs text-muted-foreground">
{dict.templates.titleHint}
</p>
</div>
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor="description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
dict.templates.descriptionPlaceholder
}
className="min-h-[60px] resize-none"
/>
</div>
{/* Pinned switch */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
{dict.templates.pinnedLabel}
</Label>
<p className="text-xs text-muted-foreground">
{dict.templates.pinnedHint}
</p>
</div>
<Switch
id="pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
</div>
{/* Error message */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{dict.common.cancel}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: dict.templates.createButton}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,7 +1,7 @@
"use client"
import { Bookmark, Edit2, Plus } from "lucide-react"
import { useEffect, useId, useState } from "react"
import { Bookmark, Edit2 } from "lucide-react"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -16,32 +16,22 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import {
createTemplate,
type Template,
updateTemplate,
} from "@/lib/template-storage"
import { type Template, updateTemplate } from "@/lib/template-storage"
interface TemplateFormDialogProps {
interface TemplateEditDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void
/** The template to edit; without one the dialog creates a new template */
template?: Template | null
initialPrompt?: string
}
// One form for creating and editing a template
export function TemplateFormDialog({
export function TemplateEditDialog({
open,
onOpenChange,
onSuccess,
template,
initialPrompt = "",
}: TemplateFormDialogProps) {
onSuccess,
}: TemplateEditDialogProps) {
const dict = useDictionary()
const id = useId()
const isEdit = !!template
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("")
@@ -49,22 +39,32 @@ export function TemplateFormDialog({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Fill the form each time the dialog opens, dropping cancelled edits
// Populate form each time the dialog opens, dropping any cancelled edits
useEffect(() => {
if (!open) return
setTitle(template?.title || "")
setDescription(template?.description || "")
setPrompt(template ? template.prompt || "" : initialPrompt)
setPinned(template?.pinned || false)
setError(null)
}, [open, template, initialPrompt])
if (open && template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
// any form this dialog may be rendered in
e.stopPropagation()
if (!template) return
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
setError(dict.templates.promptRequired)
@@ -74,59 +74,40 @@ export function TemplateFormDialog({
setIsSubmitting(true)
setError(null)
const failed = isEdit
? dict.templates.updateFailed
: dict.templates.createFailed
try {
const fields = {
const updates: Partial<Omit<Template, "id" | "createdAt">> = {
prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined,
pinned,
}
// An emptied title keeps the old one when editing; a new template
// gets a default title from its prompt
const result = template
? await updateTemplate(template.id, {
...fields,
title: title.trim() || template.title,
})
: await createTemplate({
...fields,
title: title.trim() || undefined,
})
if (result) {
const updated = await updateTemplate(template.id, updates)
if (updated) {
onSuccess()
onOpenChange(false)
} else {
setError(failed)
setError(dict.templates.updateFailed)
}
} catch (err) {
console.error("Failed to save template:", err)
setError(failed)
console.error("Failed to update template:", err)
setError(dict.templates.updateFailed)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isEdit ? (
<Edit2 className="w-5 h-5" />
) : (
<Plus className="w-5 h-5" />
)}
{isEdit
? dict.templates.editTitle
: dict.templates.createTitle}
<Edit2 className="w-5 h-5" />
{dict.templates.editTitle}
</DialogTitle>
<DialogDescription>
{isEdit
? dict.templates.editDescription
: dict.templates.createDescription}
{dict.templates.editDescription}
</DialogDescription>
</DialogHeader>
@@ -134,14 +115,14 @@ export function TemplateFormDialog({
{/* Prompt field - required */}
<div className="space-y-2">
<Label
htmlFor={`${id}-prompt`}
htmlFor="edit-prompt"
className="text-foreground"
>
{dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span>
</Label>
<Textarea
id={`${id}-prompt`}
id="edit-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder}
@@ -153,13 +134,13 @@ export function TemplateFormDialog({
{/* Title field - optional */}
<div className="space-y-2">
<Label
htmlFor={`${id}-title`}
htmlFor="edit-title"
className="text-foreground"
>
{dict.templates.titleLabel}
</Label>
<Input
id={`${id}-title`}
id="edit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder}
@@ -172,13 +153,13 @@ export function TemplateFormDialog({
{/* Description field - optional */}
<div className="space-y-2">
<Label
htmlFor={`${id}-description`}
htmlFor="edit-description"
className="text-foreground"
>
{dict.templates.descriptionLabel}
</Label>
<Textarea
id={`${id}-description`}
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
@@ -192,7 +173,7 @@ export function TemplateFormDialog({
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor={`${id}-pinned`}
htmlFor="edit-pinned"
className="flex items-center gap-2 text-foreground"
>
<Bookmark className="w-4 h-4" />
@@ -203,7 +184,7 @@ export function TemplateFormDialog({
</p>
</div>
<Switch
id={`${id}-pinned`}
id="edit-pinned"
checked={pinned}
onCheckedChange={setPinned}
/>
@@ -227,9 +208,7 @@ export function TemplateFormDialog({
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? dict.common.loading
: isEdit
? dict.common.save
: dict.templates.createButton}
: dict.common.save}
</Button>
</DialogFooter>
</form>
+81 -27
View File
@@ -11,7 +11,6 @@ import {
Trash2,
Upload,
} from "lucide-react"
import { useParams } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import {
AlertDialog,
@@ -24,33 +23,63 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import {
deleteTemplate,
duplicateTemplate,
exportTemplates,
getAllTemplates,
importTemplates,
incrementClickCount,
incrementRunCount,
searchTemplates,
type Template,
updateTemplate,
validateImportData,
} from "@/lib/template-storage"
import { TemplateFormDialog } from "./TemplateFormDialog"
import { TemplateCreateDialog } from "./TemplateCreateDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps {
setInput: (input: string) => void
/** Whether it was sent */
onSendTemplate: (template: Template) => Promise<boolean>
onSendTemplate?: (template: Template) => Promise<boolean>
currentInput?: string
}
function formatLastUsed(timestamp: number, neverUsedText: string): string {
if (!timestamp) return neverUsedText
const now = Date.now()
const diffMs = now - timestamp
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
try {
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })
if (diffMins < 1) return rtf.format(0, "minute")
if (diffMins < 60) return rtf.format(-diffMins, "minute")
if (diffHours < 24) return rtf.format(-diffHours, "hour")
if (diffDays < 7) return rtf.format(-diffDays, "day")
} catch {
// Fallback if Intl.RelativeTimeFormat is not available
if (diffMins < 1) return "<1m ago"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
}
return new Date(timestamp).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
export function TemplatePanel({
setInput,
onSendTemplate,
currentInput = "",
}: TemplatePanelProps) {
const dict = useDictionary()
const params = useParams<{ lang: string }>()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
@@ -82,6 +111,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 = () => {
@@ -100,7 +133,7 @@ export function TemplatePanel({
const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate(
template.id,
dict.templates.copySuffix,
dict.templates.copySuffix || "(copy)",
)
if (duplicated) {
loadTemplates()
@@ -146,13 +179,23 @@ export function TemplatePanel({
// Actually send the template
const sendTemplate = async (template: Template) => {
// A run only when it was sent (not while a message is still being
// sent, or attachments read)
if (await onSendTemplate(template)) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
if (onSendTemplate) {
// Sent first, while the chat is the one it was clicked in (the
// counts wait for storage). A run only when it was sent (not
// while a message is still being sent, or attachments read).
const sending = onSendTemplate(template)
// Increment click count only when actually sending
await incrementClickCount(template.id)
if (await sending) {
// Increment run count and update lastUsedAt
await incrementRunCount(template.id)
// Reload to show updated stats
loadTemplates()
}
} else {
await incrementClickCount(template.id)
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
}
setConfirmSendDialogOpen(false)
setTemplateToSend(null)
@@ -175,7 +218,7 @@ export function TemplatePanel({
if (templates.length === 0) {
setImportMessage({
type: "error",
text: dict.templates.exportEmpty,
text: dict.templates.exportEmpty || "No templates to export",
})
return
}
@@ -217,7 +260,8 @@ export function TemplatePanel({
if (!file) {
setImportMessage({
type: "error",
text: dict.templates.importNoFile,
text:
dict.templates.importNoFile || "Please select a JSON file",
})
return
}
@@ -326,7 +370,7 @@ export function TemplatePanel({
<div className="mt-3">{importMessageBox}</div>
)}
<TemplateFormDialog
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
@@ -456,7 +500,7 @@ export function TemplatePanel({
<div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}`
: dict.templates.neverUsed}
</div>
<div className="absolute inset-0 flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
@@ -473,8 +517,10 @@ export function TemplatePanel({
}`}
title={
template.pinned
? dict.templates.unpin
: dict.templates.pin
? dict.templates
.unpin || "Unpin"
: dict.templates.pin ||
"Pin"
}
>
<Bookmark
@@ -499,7 +545,10 @@ export function TemplatePanel({
handleDuplicate(template)
}}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={dict.templates.duplicate}
title={
dict.templates.duplicate ||
"Duplicate"
}
>
<Copy className="w-4 h-4" />
</button>
@@ -521,13 +570,13 @@ export function TemplatePanel({
</div>
</div>
<TemplateFormDialog
<TemplateCreateDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess}
/>
<TemplateFormDialog
<TemplateEditDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
template={templateToEdit}
@@ -542,10 +591,12 @@ export function TemplatePanel({
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.deleteTitle}
{dict.templates.deleteTitle ||
"Delete this template?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.deleteDescription}
{dict.templates.deleteDescription ||
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -570,10 +621,12 @@ export function TemplatePanel({
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.templates.confirmSendTitle}
{dict.templates.confirmSendTitle ||
"Replace current input?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.templates.confirmSendDescription}
{dict.templates.confirmSendDescription ||
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -581,7 +634,8 @@ export function TemplatePanel({
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton}
{dict.templates.confirmSendButton ||
"Send Template"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+4
View File
@@ -16,6 +16,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus =
| "idle"
| "capturing"
| "validating"
| "success"
@@ -158,10 +159,13 @@ export function ValidationCard({
color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
default:
return null
}
}
const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
+200 -122
View File
@@ -4,8 +4,8 @@ import { useChat } from "@ai-sdk/react"
import {
type ChatStatus,
DefaultChatTransport,
getToolName,
isToolUIPart,
type UIMessage,
} from "ai"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import type React from "react"
@@ -19,10 +19,14 @@ import {
useRef,
useState,
} from "react"
import { flushSync } from "react-dom"
import { toast } from "sonner"
import type { ValidationState } from "@/components/chat/ValidationCard"
import { useDiagram } from "@/contexts/diagram-context"
import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import {
keepFileVars,
useDiagramToolHandlers,
} from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary"
import {
getSelectedAIConfig,
@@ -30,18 +34,13 @@ import {
useModelConfig,
} from "@/hooks/use-model-config"
import {
getSessionVersions,
type SessionData,
useSessionManager,
} from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import {
type ChatToolName,
type ChatUIMessage,
type MessageSelection,
noticeMessage,
} from "@/lib/chat-message"
import { buildChatHeaders } from "@/lib/chat-request"
import { pageModelXml, placeOnPage } from "@/lib/diagram-pages"
import { EMPTY_DRAWIO_DOCUMENT } from "@/lib/drawio/drawio-config"
@@ -81,7 +80,7 @@ const SAVE_DEBOUNCE_MS = 1000
* Only checks the LAST tool part (most recent tool call), not all tool parts.
* A call the user stopped is not one to retry.
*/
function hasToolErrors(messages: ChatUIMessage[]): boolean {
function hasToolErrors(messages: UIMessage[]): boolean {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role !== "assistant") return false
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
@@ -108,23 +107,45 @@ function viewedPageId(): string | null {
return useCanvasStore.getState().currentPageId
}
function selectionOf(message: ChatUIMessage): MessageSelection {
const selected = message.metadata?.selectedCells
return Array.isArray(selected) ? selected : []
// Shapes sent with a user message (also kept in its metadata)
type MessageSelection = Pick<SelectedCell, "id" | "label">[]
function selectionOf(message: UIMessage): MessageSelection {
const meta = message.metadata as
| { selectedCells?: MessageSelection }
| undefined
return Array.isArray(meta?.selectedCells) ? meta.selectedCells : []
}
function newLangfuseSessionId() {
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
const XML_PROLOG =
/^(?:\s|<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>)*/i
/**
* Read the diagram document out of a .drawio, .xml or .drawio.svg file, with
* its pages inflated: previews and the model read it before draw.io saves
* it again. A broken or cut off file gives null.
*/
async function readDiagramFile(file: File): Promise<string | null> {
const loaded = parseDrawioFileContent(await file.text())
return loaded.ok ? withUniquePageIds(loaded.xml) : null
const raw = await file.text()
// What XML allows before the root element: the declaration, comments,
// a DOCTYPE (the parser below expects the root first)
let text = raw.replace(XML_PROLOG, "")
// Editable SVG: the diagram is stored in the root's `content` attribute.
// Parsed whole: its DOCTYPE may define entities the content uses.
if (/^<(?:[\w.-]+:)?svg[\s>]/i.test(text)) {
const doc = new DOMParser().parseFromString(raw, "image/svg+xml")
text = (doc.documentElement?.getAttribute("content") ?? "").replace(
XML_PROLOG,
"",
)
}
const loaded = parseDrawioFileContent(text)
if (!loaded.ok) return null
return withUniquePageIds(loaded.xml)
}
/**
@@ -169,13 +190,15 @@ function withUniquePageIds(xml: string): string {
class FileReadError extends Error {}
export interface ChatEngine {
messages: ChatUIMessage[]
messages: UIMessage[]
status: ChatStatus
error: Error | undefined
isBusy: boolean
/** The chat on screen is being left (saved, then another one shown) */
isLeaving: boolean
setMessages: (messages: ChatUIMessage[]) => void
/** A message is being sent again; the chat is not empty meanwhile */
isResending: boolean
setMessages: (messages: UIMessage[]) => void
stop: () => void
input: string
@@ -244,10 +267,11 @@ export function ChatEngineProvider({
}) {
const {
loadDiagram: onDisplayChart,
fetchDiagramXml,
handleExport: onExport,
exportResolversRef,
chartXML,
chartXMLRef,
latestSvgRef,
chartXMLRef: liveChartXMLRef,
latestSvg,
clearDiagram,
getThumbnailSvg,
captureValidationPng,
@@ -260,10 +284,26 @@ export function ChatEngineProvider({
const searchParams = useSearchParams()
const urlSessionId = searchParams.get("session")
const vlmValidationEnabled = useSettingsStore((s) => s.vlmValidationEnabled)
const settings = useSettingsStore()
const openSettings = useUiStore((s) => s.openSettings)
const focusComposer = useUiStore((s) => s.focusComposer)
const onFetchChart = () => {
// Waits for the reply to its own export, by its tag
const tag = onExport()
return Promise.race([
new Promise<string>((resolve) => {
if (tag) exportResolversRef.current[tag] = resolve
}),
new Promise<string>((_, reject) => {
setTimeout(() => {
delete exportResolversRef.current[tag]
reject(new Error("Chart export timed out after 10 seconds"))
}, 10000)
}),
])
}
const { files, pdfData, handleFileChange, setFiles } = useFileProcessor({
tooLong: dict.errors.fileTooLong,
readFailed: dict.errors.fileReadFailed,
@@ -278,6 +318,9 @@ export function ChatEngineProvider({
const sessionManager = useSessionManager({ initialSessionId: urlSessionId })
const [input, setInputState] = useState("")
const [dailyRequestLimit, setDailyRequestLimit] = useState(0)
const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0)
// Restore the unsent input from sessionStorage
useEffect(() => {
@@ -297,7 +340,21 @@ export function ChatEngineProvider({
else sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
}, [])
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
.then((res) => res.json())
.then((data) => {
setDailyRequestLimit(data.dailyRequestLimit || 0)
setDailyTokenLimit(data.dailyTokenLimit || 0)
setTpmLimit(data.tpmLimit || 0)
})
.catch(() => {})
}, [])
const quotaManager = useQuotaManager({
dailyRequestLimit,
dailyTokenLimit,
tpmLimit,
onConfigModel: () => openSettings("models"),
})
@@ -335,12 +392,20 @@ export function ChatEngineProvider({
const hasRestoredRef = useRef(false)
const [isRestored, setIsRestored] = useState(false)
// Latest chartXML for callbacks (avoids stale closures)
const chartXMLRef = useRef(chartXML)
// Session loaded without a diagram (prevents thumbnail contamination)
const justLoadedSessionIdRef = useRef<string | null>(null)
useEffect(() => {
chartXMLRef.current = chartXML
if (chartXML) justLoadedSessionIdRef.current = null
}, [chartXML])
const latestSvgRef = useRef(latestSvg)
useEffect(() => {
latestSvgRef.current = latestSvg
}, [latestSvg])
const autoRetryCountRef = useRef(0)
const continuationRetryCountRef = useRef(0)
// Partial XML when output was truncated; non-empty means continuation mode
@@ -359,6 +424,9 @@ export function ChatEngineProvider({
const [isLeaving, setIsLeaving] = useState(false)
// The user's provider the current turn was sent to, for its errors
const requestProviderIdRef = useRef<string | undefined>(undefined)
// A message is being sent again (retry, regenerate, edit), until the
// SDK has put it back
const [isResending, setIsResending] = useState(false)
// Presses of Stop: a check that began before one still knows of it after
// the next message clears stoppedRef
const stopCountRef = useRef(0)
@@ -390,13 +458,13 @@ export function ChatEngineProvider({
processedToolCallsRef,
validationRetryCountRef,
// A preview undone just before the tool call is in this one already
chartXMLRef,
chartXMLRef: liveChartXMLRef,
turnPageIdRef,
onDisplayChart,
onFetchChart: fetchDiagramXml,
onFetchChart,
captureValidationPng,
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
enableVlmValidation: settings.vlmValidationEnabled,
sessionId: langfuseSessionId,
watchStop: () => {
const stopsBefore = stopCountRef.current
@@ -424,40 +492,9 @@ export function ChatEngineProvider({
onDisplayChart(original, true, "revert")
}
// Requests made in the turn so far. The first carries the diagram the
// message was sent with (exported then, or the snapshot it is sent
// again from). Each later one, an automatic retry after a tool error,
// carries the canvas as it is then: a drawing is committed before its
// screenshot check can fail, and the model must go on from that
// drawing, not from the one before the turn.
const turnRequestsRef = useRef(0)
const transport = useMemo(
() =>
new DefaultChatTransport<ChatUIMessage>({
api: getApiEndpoint("/api/chat"),
prepareSendMessagesRequest: ({
id,
messages,
body,
trigger,
messageId,
}) => ({
body: {
...body,
...(turnRequestsRef.current++ > 0 && {
xml: getPageXml(
chartXMLRef.current,
turnPageIdRef.current,
),
}),
id,
messages,
trigger,
messageId,
},
}),
}),
[chartXMLRef],
() => new DefaultChatTransport({ api: getApiEndpoint("/api/chat") }),
[],
)
const {
@@ -468,19 +505,19 @@ export function ChatEngineProvider({
error,
setMessages,
stop,
} = useChat<ChatUIMessage>({
} = useChat({
transport,
onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutputSafely)
},
onFinish: ({ message, isAbort, isError, finishReason }) => {
onFinish: ({ message, isAbort, isError }) => {
// Stopped, failed, or the reply just ended (a closed connection):
// 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.
const cut = message.parts
.filter(isToolUIPart)
.filter((part) => part.state === "input-streaming")
const cut = (message.parts as any[]).filter(
(part) => part.state === "input-streaming" && part.toolCallId,
)
for (const part of cut) {
processedToolCallsRef.current.add(part.toolCallId)
}
@@ -489,16 +526,18 @@ export function ChatEngineProvider({
// or the page going, aborts a reply). Not once another chat is
// on screen: the call is not among its messages.
if (isAbort && getChatGeneration() === turnChatRef.current) {
for (const part of cut) stopToolCall(part)
for (const part of cut) {
addToolOutputSafely({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
}
if (isAbort || isError) return
// The output budget ran out before the model called a tool (its
// thinking took it all): nothing was drawn
const outOfTokens =
finishReason === "length" && !message.parts.some(isToolUIPart)
if (cut.length === 0 && !outOfTokens) return
// Ended with no error and no diagram: undo the preview, and say
// so, as an error does (with Retry)
if (isAbort || isError || cut.length === 0) return
// Ended with no error: undo the preview, and say so, as an
// error does (with Retry)
const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true, "revert")
editDiagramOriginalXmlRef.current.clear()
@@ -507,11 +546,16 @@ export function ChatEngineProvider({
if (getChatGeneration() !== turnChatRef.current) return
setMessages((currentMessages) => [
...currentMessages,
noticeMessage({
text: outOfTokens
? dict.errors.llm.output_truncated
: dict.errors.answerEndedEarly,
}),
{
id: `error-${Date.now()}`,
role: "system" as const,
parts: [
{
type: "text" as const,
text: dict.errors.answerEndedEarly,
},
],
},
])
},
onError: (error) => {
@@ -580,15 +624,19 @@ export function ChatEngineProvider({
// the model selected may have changed since, e.g. in another tab
const providerId = requestProviderIdRef.current
// In the chat, so it is cleared with the conversation; with a
// button that opens model settings, on the page of the provider
// that refused
// A system message, so it can be cleared with the conversation
setMessages((currentMessages) => [
...currentMessages,
noticeMessage({
text,
...(openModelConfig && { openModelConfig, providerId }),
}),
{
id: `error-${Date.now()}`,
role: "system" as const,
parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings,
// on the page of the provider that refused
...(openModelConfig && {
metadata: { openModelConfig: true, providerId },
}),
},
])
if (isAccessCodeError) openSettings("general")
@@ -618,19 +666,9 @@ export function ChatEngineProvider({
)
}
// A call the user stopped before it reached the tool handler. The SDK
// finds the call by its id; the tool name is for the types only.
const stopToolCall = (part: Parameters<typeof getToolName>[0]) =>
addToolOutputSafely({
tool: getToolName(part) as ChatToolName,
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
// After each request: send the next one (retry, continue a cut off
// drawing) or end the turn
const shouldSendAgain = (messages: ChatUIMessage[]) => {
const shouldSendAgain = (messages: UIMessage[]) => {
// 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
@@ -682,6 +720,9 @@ export function ChatEngineProvider({
}
const isBusy = status === "streaming" || status === "submitted"
useEffect(() => {
if (messages.length > 0) setIsResending(false)
}, [messages.length])
// isBusy of the latest render, for code that runs after an await
const busyRef = useRef(isBusy)
busyRef.current = isBusy
@@ -793,10 +834,16 @@ export function ChatEngineProvider({
const [shownChats, setShownChats] = useState(0)
// The messages shown when another chat was put on screen: useChat can
// show that chat's own up to 150 ms later than the rest of its state
const messagesBeforeShownRef = useRef<ChatUIMessage[] | null>(null)
const messagesBeforeShownRef = useRef<UIMessage[] | null>(null)
const syncUIWithSession = useCallback(
(data: SessionData | null) => {
(
data:
| (SessionData & {
diagramHistory?: { svg: string; xml: string }[]
})
| null,
) => {
// An answer still coming in belongs to the chat that was on
// screen (browser back or forward): it stops, before this chat's
// messages are put in its place, so none of it lands here
@@ -833,17 +880,26 @@ export function ChatEngineProvider({
loadedMessageIdsRef.current = new Set(
(data.messages as { id: string }[]).map((m) => m.id),
)
setMessages(data.messages as unknown as ChatUIMessage[])
setMessages(data.messages as unknown as UIMessage[])
xmlSnapshotsRef.current = new Map(data.xmlSnapshots)
if (data.diagramXml) onDisplayChart(data.diagramXml, true)
else clearDiagram()
if (data.diagramXml) {
onDisplayChart(data.diagramXml, true)
chartXMLRef.current = data.diagramXml
} else {
clearDiagram()
chartXMLRef.current = ""
}
if (!isRealDiagram(data.diagramXml)) latestSvgRef.current = ""
useVersionsStore.getState().setVersions(data.versions ?? [])
useVersionsStore
.getState()
.setVersions(getSessionVersions(data))
} else {
loadedMessageIdsRef.current = new Set()
setMessages([])
xmlSnapshotsRef.current.clear()
clearDiagram()
chartXMLRef.current = ""
latestSvgRef.current = ""
useVersionsStore.getState().clear()
}
},
@@ -876,6 +932,7 @@ export function ChatEngineProvider({
if (isRealDiagram(currentDiagramXml) && options.withThumbnail) {
const freshThumb = await getThumbnailSvg()
if (freshThumb) {
latestSvgRef.current = freshThumb
thumbnailDataUrl = freshThumb
} else if (latestSvgRef.current) {
thumbnailDataUrl = latestSvgRef.current
@@ -1140,15 +1197,12 @@ export function ChatEngineProvider({
: ""
}
// messageId: the user message sent again (regenerate, edit, retry); the
// SDK puts the new one in its place and drops the answers after it
const sendChatMessage = (
parts: any,
xml: string,
previousXml: string,
turnIndex: number,
selectedCells: MessageSelection,
messageId?: string,
) => {
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
@@ -1186,11 +1240,9 @@ export function ChatEngineProvider({
maxOutputTokens,
}),
}
turnRequestsRef.current = 0
// The selection stays with the message, for regenerate and retry
sendMessage(
{
...(messageId !== undefined && { messageId }),
parts,
...(selected.length > 0 && {
metadata: { selectedCells: selected },
@@ -1209,7 +1261,7 @@ export function ChatEngineProvider({
selectedCells: MessageSelection = [],
generation = getChatGeneration(),
): Promise<boolean> => {
const chartXml = formatXML(await fetchDiagramXml())
const chartXml = formatXML(await onFetchChart())
if (getChatGeneration() !== generation || leavingRef.current) {
return false
}
@@ -1292,8 +1344,12 @@ export function ChatEngineProvider({
})
if (prepared.ok) {
const pageId = viewedPageId()
const canvasXml = chartXMLRef.current || ""
onDisplayChart(
placeOnPage(prepared.xml, chartXMLRef.current, pageId),
keepFileVars(
placeOnPage(prepared.xml, canvasXml, pageId),
canvasXml,
),
true,
"commit",
{ toolCallId, pageId },
@@ -1381,8 +1437,8 @@ export function ChatEngineProvider({
}
// Templates fill the input and send it right away (attachments included)
// Whether it was sent (it can run after a wait, so busy is read from
// the ref)
// Whether it was sent (it can run after a wait: the template panel
// stores its click count first, so busy is read from the ref)
const sendTemplate = async (prompt: string): Promise<boolean> => {
// Like the send button, it waits for an answer or a message being
// prepared (that message may still come back to the input), and for
@@ -1422,6 +1478,11 @@ export function ChatEngineProvider({
}
}
const restoreDiagramFromSnapshot = (savedXml: string) => {
onDisplayChart(savedXml, true)
chartXMLRef.current = savedXml
}
// Snapshots of the messages after this one go with them
const dropLaterSnapshots = (messageIndex: number) => {
for (const key of xmlSnapshotsRef.current.keys()) {
@@ -1439,17 +1500,22 @@ export function ChatEngineProvider({
return false
}
const previousXml = getPreviousXml(index)
onDisplayChart(savedXml, true)
restoreDiagramFromSnapshot(savedXml)
dropLaterSnapshots(index)
useVersionsStore.getState().removeFromTurn(index)
// Remove the user message onwards (sendMessage re-adds it). For a
// moment there may be no message: that is no empty chat.
flushSync(() => {
setIsResending(true)
setMessages(messages.slice(0, index))
})
sendChatMessage(
parts,
getPageXml(savedXml, viewedPageId()),
previousXml,
index,
selectionOf(messages[index]),
messages[index].id,
)
return true
@@ -1502,7 +1568,7 @@ export function ChatEngineProvider({
// Like submit: the diagram export comes first
preparingSendRef.current = true
try {
const chartXml = formatXML(await fetchDiagramXml())
const chartXml = formatXML(await onFetchChart())
// Another chat came on screen meanwhile (browser back/forward),
// or this one is being left
if (getChatGeneration() !== generation || leavingRef.current) {
@@ -1515,13 +1581,16 @@ export function ChatEngineProvider({
chartXMLRef.current || chartXml,
)
}
flushSync(() => {
setIsResending(true)
setMessages(messagesRef.current.slice(0, index))
})
sendChatMessage(
message.parts,
chartXml,
getPreviousXml(index),
index,
selectionOf(message),
message.id,
)
} catch (error) {
console.error("Error fetching chart data:", error)
@@ -1540,17 +1609,25 @@ export function ChatEngineProvider({
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 streaming = (lastMessage?.parts ?? [])
.filter(isToolUIPart)
.filter(
(part) =>
part.state === "input-streaming" &&
!processedToolCallsRef.current.has(part.toolCallId),
)
for (const part of streaming) stopToolCall(part)
const toolParts = lastMessage?.parts?.filter(
(part: any) =>
part.type?.startsWith("tool-") &&
part.state === "input-streaming" &&
!processedToolCallsRef.current.has(part.toolCallId),
)
for (const part of (toolParts ?? []) as any[]) {
if (part.toolCallId) {
addToolOutputSafely({
tool: part.type.replace("tool-", ""),
toolCallId: part.toolCallId,
state: "output-error",
errorText: "Stopped by user",
})
}
}
stop()
abandonContinuation()
}, [messages, stopToolCall, stop, cancelValidation])
}, [messages, addToolOutputSafely, stop, cancelValidation])
// ---------------------------------------------------------------------
// Session actions
@@ -1860,7 +1937,8 @@ export function ChatEngineProvider({
error,
isBusy,
isLeaving,
setMessages: setMessages as (messages: ChatUIMessage[]) => void,
isResending,
setMessages: setMessages as (messages: UIMessage[]) => void,
stop: handleStop,
input,
setInput,
+19 -8
View File
@@ -18,8 +18,9 @@ import {
useState,
} from "react"
import { toast } from "sonner"
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import { ErrorToast } from "@/components/error-toast"
import { FilePreviewList } from "@/components/file-preview-list"
import { ModelSelector } from "@/components/model-selector"
import {
@@ -45,16 +46,24 @@ import { useUiStore } from "@/stores/ui-store"
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
import { useChatEngine } from "./chat-engine"
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
toast.error(
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
{ duration: 5000 },
)
return
}
toast.error(
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
@@ -74,7 +83,6 @@ function showValidationErrors(errors: string[], dict: any) {
)}
</ul>
</div>,
{ duration: 5000 },
)
}
@@ -269,11 +277,10 @@ export function Composer({
next.delete(url)
return next
})
toast.error(
showErrorToast(
<span className="text-muted-foreground">
{err instanceof Error ? err.message : dict.url.failed}
</span>,
{ duration: 5000 },
)
} finally {
setIsExtractingUrl(false)
@@ -512,7 +519,7 @@ export function Composer({
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
<TemplateFormDialog
<TemplateCreateDialog
open={saveTemplateOpen}
onOpenChange={setSaveTemplateOpen}
onSuccess={() => setSaveTemplateOpen(false)}
@@ -525,6 +532,10 @@ export function Composer({
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
<TemplatePanel
setInput={(value) => {
setInput(value)
setTemplatesOpen(false)
}}
currentInput={input}
onSendTemplate={(template) => {
setTemplatesOpen(false)
+22 -9
View File
@@ -1,4 +1,4 @@
import { FileUp, History, Pencil } from "lucide-react"
import { FileText, FileUp, History, Pencil } from "lucide-react"
import type React from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
@@ -12,7 +12,7 @@ interface Example {
title: string
description: string
/** Picture of the result (or of the input, for "replicate" examples) */
image: string
image?: string
prompt: string
/** Bundled file sent with the prompt; matches the cached example */
file?: { path: string; name: string; type: string }
@@ -34,6 +34,7 @@ function ExampleTile({
title,
description,
picture,
plain,
testId,
}: {
onClick: () => void
@@ -41,6 +42,8 @@ function ExampleTile({
/** Shown as the tooltip: the title alone names the example */
description: string
picture: React.ReactNode
/** An icon on a tinted tile instead of a diagram on paper */
plain?: boolean
testId?: string
}) {
return (
@@ -51,7 +54,12 @@ function ExampleTile({
className="group/example flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-card p-1.5 pb-2.5 text-left transition-colors hover:border-foreground/25"
data-testid={testId}
>
<span className="flex h-16 w-full items-center justify-center overflow-hidden rounded-lg sheet-light bg-white">
<span
className={cn(
"flex h-16 w-full items-center justify-center overflow-hidden rounded-lg",
plain ? "bg-muted" : "sheet-light bg-white",
)}
>
{picture}
</span>
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
@@ -212,13 +220,18 @@ export function LobbyHero({
description={example.description}
testId={`example-${example.key}`}
picture={
// biome-ignore lint/performance/noImgElement: static preview
<img
src={getAssetUrl(example.image)}
alt=""
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/>
example.image ? (
// biome-ignore lint/performance/noImgElement: static preview
<img
src={getAssetUrl(example.image)}
alt=""
className="max-h-14 max-w-[88%] object-contain transition-transform duration-300 group-hover/example:scale-[1.06]"
/>
) : (
<FileText className="size-5 text-muted-foreground" />
)
}
plain={!example.image}
/>
),
)}
+41 -28
View File
@@ -1,4 +1,4 @@
import { isToolUIPart, type UIMessage } from "ai"
import type { UIMessage } from "ai"
import {
AlertCircle,
BookmarkPlus,
@@ -16,15 +16,18 @@ import {
import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import { Reasoning } from "@/components/ai-elements/reasoning"
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import type { ToolPartLike } from "@/components/chat/types"
import { ValidationCard } from "@/components/chat/ValidationCard"
import Image from "@/components/image-with-basepath"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import { noticeOf } from "@/lib/chat-message"
import { useCopy } from "@/lib/clipboard"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
@@ -288,7 +291,7 @@ function UserMessage({
{text && <MessageText text={text} />}
</div>
{saveTemplate && (
<TemplateFormDialog
<TemplateCreateDialog
open
onOpenChange={(open) => !open && setSaveTemplate(false)}
onSuccess={() => setSaveTemplate(false)}
@@ -370,7 +373,7 @@ function AssistantMessage({
parts.forEach((part, partIndex) => {
if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
else if (isToolUIPart(part))
else if (part.type?.startsWith("tool-"))
blocks.push({ kind: "tool", index: partIndex, texts: [] })
else if (part.type === "text") {
const text = (part as { text: string }).text
@@ -395,9 +398,13 @@ function AssistantMessage({
className="w-full"
isStreaming={isStreamingReasoning}
defaultOpen={!isRestored}
getThinkingMessage={thinkingMessage}
>
{(part as { text: string }).text}
<ReasoningTrigger
getThinkingMessage={thinkingMessage}
/>
<ReasoningContent>
{(part as { text: string }).text}
</ReasoningContent>
</Reasoning>
)
}
@@ -569,10 +576,9 @@ export function MessageList() {
}
}, [messages])
// A notice (a failed request) is an assistant message without an answer
const lastAssistantIndex = messages.findLastIndex(
(m) => m.role === "assistant" && !noticeOf(m),
)
const lastAssistantIndex = messages
.map((m) => m.role)
.lastIndexOf("assistant")
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return (
@@ -581,7 +587,6 @@ export function MessageList() {
const isRestored = engine.loadedMessageIdsRef.current.has(
message.id,
)
const notice = noticeOf(message)
return (
<div
key={message.id}
@@ -593,18 +598,33 @@ export function MessageList() {
index={index}
isLast={index === lastUserIndex}
/>
) : notice ? (
) : message.role === "assistant" ? (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
) : (
<ErrorNotice
text={notice.text}
openModelConfig={!!notice.openModelConfig}
onOpenModelConfig={() =>
text={getMessageTextContent(message)}
openModelConfig={
!!(
message.metadata as
| { openModelConfig?: boolean }
| undefined
)?.openModelConfig
}
onOpenModelConfig={() => {
const { providerId } =
(message.metadata as
| { providerId?: string }
| undefined) ?? {}
openSettings(
"models",
notice.providerId
? { providerId: notice.providerId }
: "list",
providerId ? { providerId } : "list",
)
}
}}
onRetry={
index === messages.length - 1 &&
!engine.isBusy &&
@@ -613,13 +633,6 @@ export function MessageList() {
: undefined
}
/>
) : (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
)}
</div>
)
+2 -3
View File
@@ -1,4 +1,3 @@
import { getToolName } from "ai"
import {
AlertCircle,
Check,
@@ -165,7 +164,7 @@ function ToolRow({
tone === "error" && "border-destructive/25",
)}
data-testid="tool-row"
data-tool-name={getToolName(part)}
data-tool-name={part.type?.replace("tool-", "")}
data-tool-state={part.state}
>
<button
@@ -247,7 +246,7 @@ export function ToolActivity({
const t = dict.versions
const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const toolName = getToolName(part)
const toolName = part.type?.replace("tool-", "")
const { state, errorText, output } = part
const versionIndex = versions.findIndex(
+8 -12
View File
@@ -1,19 +1,15 @@
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation }
/**
* A tool part as the UI reads it, in any state: input, output and errorText
* are each there or not, instead of one shape per state.
*/
export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
input?:
| ({
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>)
| undefined
export interface ToolPartLike {
type: string
toolCallId: string
state?: string
input?: {
xml?: string
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
errorText?: string
}
+22 -21
View File
@@ -5,25 +5,25 @@ import type { RefObject } from "react"
import { useCallback, useEffect, useRef } from "react"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import { useDiagram } from "@/contexts/diagram-context"
import { pageSelectorFor, replacePageModel } from "@/lib/diagram-pages"
import { extractCompleteMxCells } from "@/lib/utils"
import { pageSelectorFor } from "@/lib/diagram-pages"
import {
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
} from "@/lib/utils"
import {
defaultLayerOf,
expandCompactCells,
} from "@/packages/mcp-server/src/compact-cells.ts"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { targetPageXml } from "@/packages/mcp-server/src/edit-diagram.ts"
import {
BLANK_MXFILE,
wrapCellsInModel,
} from "@/packages/mcp-server/src/pages.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import {
addDefaultStyles,
applyStyleClasses,
edgeIdsOf,
readStyleClasses,
} from "@/packages/mcp-server/src/style-classes.ts"
import { autoFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
@@ -91,31 +91,32 @@ export function useDiagramStreaming({
const { classes, xml: cellsXml } = readStyleClasses(xml || "")
const completeCells = extractCompleteMxCells(cellsXml)
if (!completeCells) return
// The same wrapping, fixes and expansion as the final diagram
// (prepareNewDiagram), so the preview matches what is committed
const model = addDefaultStyles(
const convertedXml = addDefaultStyles(
applyStyleClasses(
expandCompactCells(
autoFixXml(wrapCellsInModel(completeCells)).fixed,
),
expandCompactCells(convertToLegalXml(completeCells)),
classes,
),
)
if (model === previousXML.current) return
if (convertedXml === previousXML.current) return
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(model, "text/xml")
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
try {
// Replace the model's page so other pages stay intact; an
// empty canvas gets the blank mxfile to put the page in
const replacedXML = replacePageModel(
chartXML,
// Replace the model's page's cells so other pages stay
// intact. An empty canvas gets a default mxfile to put the
// cells in.
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(
baseXML,
convertedXml,
turnPageIdRef?.current,
model,
)
previousXML.current = model
previousXML.current = convertedXml
loadDiagram(replacedXML, true, "preview")
} catch (error) {
console.error("Error processing XML:", error)
+25 -12
View File
@@ -1,6 +1,7 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
// Dev XML presets for streaming simulator
@@ -134,12 +135,15 @@ const DEV_XML_PRESETS: Record<string, string> = {
interface DevXmlSimulatorProps {
setMessages: React.Dispatch<React.SetStateAction<any[]>>
onDisplayChart: (xml: string) => void
onShowQuotaToast?: () => void
}
export function DevXmlSimulator({
setMessages,
onDisplayChart,
onShowQuotaToast,
}: DevXmlSimulatorProps) {
const dict = useDictionary()
const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false)
const [devIntervalMs, setDevIntervalMs] = useState(1)
@@ -176,7 +180,7 @@ export function DevXmlSimulator({
parts: [
{
type: "text" as const,
text: "[Dev] Simulating XML streaming",
text: dict.dev.simulatingMessage,
},
],
}
@@ -226,7 +230,7 @@ export function DevXmlSimulator({
const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].state = "output-available"
lastMsg.parts[0].output = "Successfully displayed the diagram."
lastMsg.parts[0].output = dict.dev.successMessage
lastMsg.parts[0].input = { xml }
}
return updated
@@ -243,12 +247,12 @@ export function DevXmlSimulator({
<div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
<details>
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
Dev: XML Streaming Simulator
{dict.dev.title}
</summary>
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap">
Preset:
{dict.dev.preset}
</label>
<select
onChange={(e) => {
@@ -260,7 +264,7 @@ export function DevXmlSimulator({
defaultValue=""
>
<option value="" disabled>
Select a preset...
{dict.dev.selectPreset}
</option>
{Object.keys(DEV_XML_PRESETS).map((name) => (
<option key={name} value={name}>
@@ -273,19 +277,19 @@ export function DevXmlSimulator({
onClick={() => setDevXml("")}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
>
Clear
{dict.dev.clear}
</button>
</div>
<textarea
value={devXml}
onChange={(e) => setDevXml(e.target.value)}
placeholder="Paste mxCell XML here or select a preset..."
placeholder={dict.dev.placeholder}
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background"
/>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-1">
<label className="text-xs text-muted-foreground whitespace-nowrap">
Interval:
{dict.dev.interval}
</label>
<input
type="range"
@@ -304,7 +308,7 @@ export function DevXmlSimulator({
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap">
Chars:
{dict.dev.chars}
</label>
<input
type="number"
@@ -328,8 +332,8 @@ export function DevXmlSimulator({
className="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSimulating
? "Streaming..."
: `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
? dict.dev.streaming
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`}
</button>
{isSimulating && (
<button
@@ -339,7 +343,16 @@ export function DevXmlSimulator({
}}
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Stop
{dict.dev.stop}
</button>
)}
{onShowQuotaToast && (
<button
type="button"
onClick={onShowQuotaToast}
className="px-3 py-1 text-xs bg-purple-500 text-white rounded hover:bg-purple-600"
>
{dict.dev.testQuotaToast}
</button>
)}
</div>
+44
View File
@@ -0,0 +1,44 @@
"use client"
import type React from "react"
interface ErrorToastProps {
message: React.ReactNode
onDismiss: () => void
}
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault()
onDismiss()
}
}
return (
<div
role="alert"
aria-live="polite"
tabIndex={0}
onClick={onDismiss}
onKeyDown={handleKeyDown}
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
<svg
className="w-4 h-4 text-destructive"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<span className="text-sm text-foreground">{message}</span>
</div>
)
}
-13
View File
@@ -1,13 +0,0 @@
// GitHub mark as an inline SVG, so no icon package is needed for one icon
export function GitHubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}
+41 -2
View File
@@ -43,6 +43,7 @@ import {
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
@@ -163,6 +164,7 @@ export function ModelConfigDialog({
)
const [customModelInput, setCustomModelInput] = useState("")
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState("")
// Models whose test is running (they are all tested at once)
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>(
() => new Set(),
@@ -439,6 +441,7 @@ export function ModelConfigDialog({
setFetchModelsError("")
updates = {
...updates,
validated: false,
models: selectedProvider.models.map((m) => ({
...m,
validated: undefined,
@@ -656,6 +659,10 @@ export function ModelConfigDialog({
return
}
// A model whose id changed was not tested
if (allValid && !idChanged) {
updateProvider(selectedProviderId, { validated: true })
}
markRejected(selectedProviderId, askedWith, rejected)
// The status line is about the provider shown now
if (selectedProviderIdRef.current !== selectedProviderId) return
@@ -997,6 +1004,7 @@ export function ModelConfigDialog({
name={provider.name}
baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion}
bedrockApiKey
settingsLayout
bedrockFilled={{
apiKey: !!provider.apiKey,
@@ -1381,7 +1389,10 @@ export function ModelConfigDialog({
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
onOpenChange={(open) => {
setDeleteConfirmOpen(open)
if (!open) setDeleteConfirmText("")
}}
>
<AlertDialogContent className="border-destructive/30">
<AlertDialogHeader>
@@ -1399,11 +1410,39 @@ export function ModelConfigDialog({
})}
</AlertDialogDescription>
</AlertDialogHeader>
{selectedProvider &&
selectedProvider.models.length >= 3 && (
<div className="mt-2 space-y-2">
<Label
htmlFor="delete-confirm"
className="text-sm text-muted-foreground"
>
{formatMessage(t.typeToConfirm, {
name: providerName(selectedProvider),
})}
</Label>
<Input
id="delete-confirm"
value={deleteConfirmText}
onChange={(e) =>
setDeleteConfirmText(e.target.value)
}
placeholder={t.typeProviderName}
className="h-9"
/>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProvider}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={
selectedProvider &&
selectedProvider.models.length >= 3 &&
deleteConfirmText !==
providerName(selectedProvider)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
>
{t.delete}
</AlertDialogAction>
+333 -367
View File
@@ -10,38 +10,28 @@ import {
Settings2,
User,
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
type ComponentProps,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react"
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorName,
ModelSelector as ModelSelectorRoot,
ModelSelectorSectionHeader,
ModelSelectorSeparator,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ProviderLogo } from "@/components/provider-logo"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { useDictionary } from "@/hooks/use-dictionary"
import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
import {
type FlattenedModel,
PROVIDER_LOGO_MAP,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
interface ModelSelectorProps {
@@ -54,22 +44,25 @@ interface ModelSelectorProps {
disabled?: boolean
}
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
// Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider(
models: FlattenedModel[],
): Map<string, ProviderGroup> {
const groups = new Map<string, ProviderGroup>()
): Map<string, { provider: string; models: FlattenedModel[] }> {
const groups = new Map<
string,
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) {
const existing = groups.get(model.providerLabel)
// For server models, strip "Server · " prefix for cleaner grouping
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const existing = groups.get(key)
if (existing) {
existing.models.push(model)
} else {
groups.set(model.providerLabel, {
provider: model.provider,
models: [model],
})
groups.set(key, { provider: model.provider, models: [model] })
}
}
return groups
@@ -95,72 +88,6 @@ export function shortModelName(id: string): string {
return short
}
// The command list with a shadow at the bottom while more items are below
function ModelList({
className,
...props
}: ComponentProps<typeof CommandList>) {
const listRef = useRef<HTMLDivElement>(null)
const [showShadow, setShowShadow] = useState(false)
useEffect(() => {
const listElement = listRef.current
if (!listElement) return
const checkScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listElement
// A small threshold handles fractional pixel rendering
setShowShadow(
scrollHeight > Math.ceil(scrollTop + clientHeight) + 1,
)
}
checkScroll()
listElement.addEventListener("scroll", checkScroll)
window.addEventListener("resize", checkScroll)
// Content changes, e.g. the search filtering items
const observer = new MutationObserver(checkScroll)
observer.observe(listElement, { childList: true, subtree: true })
return () => {
listElement.removeEventListener("scroll", checkScroll)
window.removeEventListener("resize", checkScroll)
observer.disconnect()
}
}, [])
return (
<div className="relative">
<CommandList
ref={listRef}
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
<div
className={cn(
"pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent transition-opacity duration-200",
showShadow ? "opacity-100" : "opacity-0",
)}
/>
</div>
)
}
function SectionHeader({ icon, label }: { icon: ReactNode; label: string }) {
return (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40 rounded-sm mx-1 mt-1">
<span className="[&>svg]:size-3.5" aria-hidden="true">
{icon}
</span>
<span>{label}</span>
</div>
)
}
export function ModelSelector({
models,
selectedModelId,
@@ -219,52 +146,6 @@ export function ModelSelector({
setOpen(false)
}
// One group per provider; `trailing` is the small label at the end of a
// row ("Default" for server models, the test state for user models)
const renderGroups = (
groups: Map<string, ProviderGroup>,
keyPrefix: "server" | "user",
trailing: (model: FlattenedModel) => ReactNode,
) =>
Array.from(groups.entries()).map(
([providerLabel, { provider, models: providerModels }]) => (
<CommandGroup
key={`${keyPrefix}-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map((model) => (
<CommandItem
key={model.id}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[model.modelId, providerLabel]}
onSelect={() => handleSelect(model.id)}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId === model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ProviderLogo
provider={provider}
className="mr-2"
/>
<span className="flex-1 truncate text-left">
{model.modelId}
</span>
{trailing(model)}
</CommandItem>
))}
</CommandGroup>
),
)
const tooltipContent = selectedModel
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
@@ -305,244 +186,329 @@ export function ModelSelector({
return (
<div ref={wrapperRef} className="min-w-0 max-w-44">
<Dialog
<ModelSelectorRoot
open={open}
onOpenChange={(next) => {
if (next) toSettingsRef.current = false
setOpen(next)
}}
>
{/* The dialog trigger must wrap the button itself: Radix
Tooltip's root renders no element to pass its props to */}
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
aria-label={tooltipContent}
data-testid="model-selector"
>
{selectedModel ? (
<ProviderLogo
provider={selectedModel.provider}
className="size-3.5 flex-shrink-0 opacity-80"
/>
) : (
<Bot className="h-4 w-4 flex-shrink-0" />
)}
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="min-w-0 truncate text-xs">
{selectedModel
? shortModelName(
selectedModel.modelId,
)
: dict.modelConfig.serverDefault}
</span>
) : (
// Keep an sr-only label for screen readers when hidden
<span className="sr-only">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.serverDefault}
</span>
)}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-wrap">
{tooltipContent}
</TooltipContent>
</Tooltip>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
tooltipContent={tooltipContent}
variant="ghost"
size="sm"
disabled={disabled}
className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 font-normal text-muted-foreground transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent hover:text-foreground",
!showLabel && "px-1.5 justify-center",
)}
// accessibility: expose label to screen readers
aria-label={tooltipContent}
data-testid="model-selector"
>
{selectedModel ? (
<ProviderLogo
provider={selectedModel.provider}
className="size-3.5 flex-shrink-0 opacity-80"
/>
) : (
<Bot className="h-4 w-4 flex-shrink-0" />
)}
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="min-w-0 truncate text-xs">
{selectedModel
? shortModelName(selectedModel.modelId)
: dict.modelConfig.serverDefault}
</span>
) : (
// Keep an sr-only label for screen readers when hidden
<span className="sr-only">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.serverDefault}
</span>
)}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip>
</ModelSelectorTrigger>
<DialogContent
className="p-0"
<ModelSelectorContent
title={dict.modelConfig.selectModel}
onCloseAutoFocus={(e) => {
if (!toSettingsRef.current) return
toSettingsRef.current = false
e.preventDefault()
}}
>
<DialogTitle className="sr-only">
{dict.modelConfig.selectModel}
</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
<CommandInput
className="h-auto py-3.5"
placeholder={dict.modelConfig.searchModels}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ModelList className="overflow-y-auto scrollbar-thin">
<CommandEmpty>
{dict.modelConfig.noModelsFound}
</CommandEmpty>
<ModelSelectorInput
placeholder={dict.modelConfig.searchModels}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
<ModelSelectorEmpty>
{dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<CommandGroup
heading={dict.modelConfig.default}
{/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && (
<ModelSelectorGroup
heading={dict.modelConfig.default}
>
<ModelSelectorItem
value="__server_default__"
onSelect={handleSelect}
className={cn(
"cursor-pointer",
!selectedModelId && "bg-accent",
)}
>
<CommandItem
value="__server_default__"
onSelect={handleSelect}
<Check
className={cn(
"cursor-pointer",
!selectedModelId &&
"bg-accent",
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!selectedModelId
? "opacity-100"
: "opacity-0",
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName>
{dict.modelConfig.serverDefault}
</ModelSelectorName>
</ModelSelectorItem>
</ModelSelectorGroup>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<ModelSelectorSectionHeader
icon={<Monitor />}
label={
dict.modelConfig.serverModels
}
/>
{Array.from(
groupedServerModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`server-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.isDefault && (
<span
title={
dict
.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict
.modelConfig
.default
}
</span>
)}
</ModelSelectorItem>
),
)}
/>
<Server className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="flex-1 truncate text-left">
{
dict.modelConfig
.serverDefault
}
</span>
</CommandItem>
</CommandGroup>
)}
</ModelSelectorGroup>
),
)}
</>
)}
{/* Server Models Section */}
{serverModels.length > 0 && (
<>
<SectionHeader
icon={<Monitor />}
label={
dict.modelConfig
.serverModels
}
/>
{renderGroups(
groupedServerModels,
"server",
(model) =>
model.isDefault && (
<span
title={
dict.modelConfig
.serverDefaultModel
}
className="ml-auto text-xs text-muted-foreground"
>
{
dict.modelConfig
.default
}
</span>
),
)}
</>
)}
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<CommandSeparator />
)}
<SectionHeader
icon={<User />}
label={
dict.modelConfig.userModels
}
/>
{renderGroups(
groupedUserModels,
"user",
(model) =>
model.validated !==
true && (
<span
className={cn(
"ml-auto shrink-0 pl-2 text-xs",
model.validated ===
false
? "text-destructive"
: "text-muted-foreground",
)}
>
{model.validated ===
false
? dict
.modelConfig
.modelFailed
: dict
.modelConfig
.modelUntested}
</span>
),
)}
</>
)}
</ModelList>
</div>
{/* Pinned footer: add a provider, configure models
(z-10 above list shadow). Buttons, outside the
search: reachable with Tab, never filtered out */}
{(onAddProvider || onConfigure) && (
<div
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
// The search's Enter would pick the highlighted
// model instead of pressing the button
onKeyDown={(e) => e.stopPropagation()}
>
{onAddProvider && (
<button
type="button"
onClick={() =>
goToSettings(onAddProvider)
}
className={footerButton}
>
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{/* User Models Section */}
{userModels.length > 0 && (
<>
{serverModels.length > 0 && (
<ModelSelectorSeparator />
)}
<ModelSelectorSectionHeader
icon={<User />}
label={dict.modelConfig.userModels}
/>
{Array.from(
groupedUserModels.entries(),
).map(
([
providerLabel,
{
dict.modelConfig
.addProviderEntry
}
</span>
</button>
)}
{onConfigure && (
<button
type="button"
onClick={() =>
goToSettings(onConfigure)
}
className={footerButton}
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{
dict.modelConfig
.configureModels
}
</span>
</button>
)}
</div>
)}
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
}
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/>
<ModelSelectorLogo
provider={
PROVIDER_LOGO_MAP[
provider
] ||
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.validated !==
true && (
<span
className={cn(
"ml-auto shrink-0 pl-2 text-xs",
model.validated ===
false
? "text-destructive"
: "text-muted-foreground",
)}
>
{model.validated ===
false
? dict
.modelConfig
.modelFailed
: dict
.modelConfig
.modelUntested}
</span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
),
)}
</>
)}
</ModelSelectorList>
</div>
</Command>
</DialogContent>
</Dialog>
{/* Pinned footer: add a provider, configure models
(z-10 above list shadow). Buttons, outside the
search: reachable with Tab, never filtered out */}
{(onAddProvider || onConfigure) && (
<div
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
// The search's Enter would pick the highlighted
// model instead of pressing the button
onKeyDown={(e) => e.stopPropagation()}
>
{onAddProvider && (
<button
type="button"
onClick={() =>
goToSettings(onAddProvider)
}
className={footerButton}
>
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{dict.modelConfig.addProviderEntry}
</span>
</button>
)}
{onConfigure && (
<button
type="button"
onClick={() =>
goToSettings(onConfigure)
}
className={footerButton}
>
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">
{dict.modelConfig.configureModels}
</span>
</button>
)}
</div>
)}
</div>
</ModelSelectorContent>
</ModelSelectorRoot>
</div>
)
}
+35 -2
View File
@@ -58,6 +58,9 @@ interface ProviderCredentialsFieldsProps {
name?: string
baseUrl?: string
awsRegion?: string
// Bedrock: also offer an API key (the user dialog; the admin panel
// stores access keys only)
bedrockApiKey?: boolean
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
@@ -65,6 +68,9 @@ interface ProviderCredentialsFieldsProps {
// UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode
// The user's settings page: the secrets come first, the display name and
// base URL fold under "More options", and Bedrock shows only the way of
// signing in that is chosen. Switching it empties the other way's
@@ -85,9 +91,11 @@ export function ProviderCredentialsFields({
name,
baseUrl,
awsRegion,
bedrockApiKey,
disabled,
onChange,
renderSecret,
footer,
settingsLayout,
clearSecrets,
bedrockFilled,
@@ -261,8 +269,10 @@ export function ProviderCredentialsFields({
</div>
)
// EdgeOne needs no credentials
if (provider === "edgeone") return null
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
if (settingsLayout) {
return (
@@ -437,6 +447,7 @@ export function ProviderCredentialsFields({
</>
)}
</div>
{footer}
</div>
)
}
@@ -447,6 +458,26 @@ export function ProviderCredentialsFields({
{provider === "bedrock" ? (
<>
{/* Bedrock API key: used instead of the access keys */}
{bedrockApiKey && (
<div className="space-y-2">
<Label
htmlFor="bedrock-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.bedrockApiKey}
</Label>
{renderSecret({
field: "apiKey",
id: "bedrock-api-key",
})}
<p className="text-xs text-muted-foreground">
{dict.modelConfig.bedrockApiKeyHint}
</p>
</div>
)}
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
@@ -504,6 +535,8 @@ export function ProviderCredentialsFields({
{baseUrlField}
</>
)}
{footer}
</div>
)
}
+12 -6
View File
@@ -2,7 +2,7 @@
import { Coffee, Settings, X } from "lucide-react"
import type React from "react"
import { GitHubIcon } from "@/components/icons/github"
import { FaGithub } from "react-icons/fa"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
@@ -28,10 +28,16 @@ export function QuotaLimitToast({
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit
? dict.quota.messageToken
: dict.quota.messageApi
? isSelfHosted
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi
const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
const tipHtml = isSelfHosted
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
@@ -46,7 +52,7 @@ export function QuotaLimitToast({
aria-live="polite"
tabIndex={0}
onKeyDown={handleKeyDown}
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-float animate-message-in"
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in"
>
{/* Close button */}
<button
@@ -109,7 +115,7 @@ export function QuotaLimitToast({
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
>
<GitHubIcon className="w-3.5 h-3.5" />
<FaGithub className="w-3.5 h-3.5" />
{dict.quota.selfHost}
</a>
<a
+3 -3
View File
@@ -1,4 +1,5 @@
import {
Github,
Info,
Monitor,
Moon,
@@ -12,7 +13,6 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { useChatEngine } from "@/components/chat/chat-engine"
import { GitHubIcon } from "@/components/icons/github"
import { ModelConfigDialog } from "@/components/model-config-dialog"
import { SettingsHeader } from "@/components/settings/settings-header"
import { Button } from "@/components/ui/button"
@@ -510,7 +510,7 @@ function AboutTab() {
const lang = pathname.split("/").filter(Boolean)[0] || i18n.defaultLocale
const showAbout = process.env.NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE === "true"
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const aboutPath = `/${lang}/about`
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
return (
<div className="space-y-5">
@@ -553,7 +553,7 @@ function AboutTab() {
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border px-3 text-xs text-muted-foreground hover:text-foreground"
>
<GitHubIcon className="size-3.5" />
<Github className="size-3.5" />
GitHub
</a>
{showAbout && (
+11
View File
@@ -12,6 +12,14 @@ function AlertDialog({
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
@@ -136,6 +144,9 @@ function AlertDialogCancel({
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
+51
View File
@@ -5,6 +5,13 @@ import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
@@ -22,6 +29,32 @@ function Command({
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
@@ -131,12 +164,30 @@ function CommandItem({
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+10 -1
View File
@@ -24,6 +24,12 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
@@ -59,7 +65,7 @@ function DialogContent({
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
"grid gap-4 p-6",
// Refined visual treatment
"bg-surface-0 rounded-2xl border border-border-subtle shadow-pop",
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog",
// Entry/exit animations
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
@@ -145,10 +151,13 @@ function DialogDescription({
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+7 -1
View File
@@ -39,4 +39,10 @@ function PopoverContent({
)
}
export { Popover, PopoverTrigger, PopoverContent }
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+1 -1
View File
@@ -55,4 +55,4 @@ function ScrollBar({
)
}
export { ScrollArea }
export { ScrollArea, ScrollBar }
+37
View File
@@ -12,6 +12,12 @@ function Select({
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
@@ -81,6 +87,19 @@ function SelectContent({
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
@@ -105,6 +124,19 @@ function SelectItem({
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
@@ -144,7 +176,12 @@ function SelectScrollDownButton({
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+15 -1
View File
@@ -13,7 +13,9 @@ interface IconButtonProps extends React.ComponentProps<"button"> {
label: string
/** Shown next to the label in the tooltip, e.g. "⌘B" */
shortcut?: string
active?: boolean
tooltipSide?: "top" | "bottom" | "left" | "right"
size?: "sm" | "md"
}
/** Square icon button with a tooltip; the tooltip text is also its label */
@@ -22,7 +24,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{
label,
shortcut,
active = false,
tooltipSide = "bottom",
size = "md",
className,
children,
...props
@@ -36,12 +40,17 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
ref={ref}
type="button"
aria-label={label}
aria-pressed={active || undefined}
className={cn(
"inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
"inline-flex shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors",
"hover:bg-accent hover:text-foreground",
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
"disabled:pointer-events-none disabled:opacity-35",
"[&_svg]:size-4 [&_svg]:stroke-[1.75]",
size === "md" ? "size-8" : "size-7",
// A tinted tile: solid ink is for the main action
active &&
"bg-interactive-active text-foreground hover:bg-interactive-active [&_svg]:stroke-2",
className,
)}
{...props}
@@ -64,3 +73,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
)
},
)
/** Thin vertical divider between toolbar groups */
export function ToolbarDivider() {
return <span className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />
}
+1
View File
@@ -150,6 +150,7 @@ function WorkspaceLayout() {
const showHero =
engine.isRestored &&
engine.messages.length === 0 &&
!engine.isResending &&
!isRealDiagram(chartXML) &&
!heroDismissed
+146 -117
View File
@@ -78,16 +78,20 @@ interface DiagramContextType {
chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.MutableRefObject<string>
// The diagram's latest SVG export, a fallback thumbnail for saves
latestSvgRef: React.MutableRefObject<string>
latestSvg: string
loadDiagram: (
chart: string,
skipValidation?: boolean,
mode?: LoadMode,
meta?: { toolCallId?: string; pageId?: string | null },
) => string | null
/** The XML of the page on screen, exported from draw.io */
fetchDiagramXml: () => Promise<string>
// Returns the export's tag (empty when draw.io is not there yet)
handleExport: () => string
// Pending exports by tag; a plain export's resolver gets the XML of the
// page on screen
exportResolversRef: React.MutableRefObject<
Record<string, (data: string, xml?: string) => void>
>
drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
handleDiagramExport: (data: DrawioExportEvent) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
@@ -97,7 +101,7 @@ interface DiagramContextType {
format: ExportFormat,
sessionId?: string,
successMessage?: string,
) => Promise<void>
) => void
getThumbnailSvg: () => Promise<string | null>
getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
@@ -131,11 +135,11 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// 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" | "version" | "fetch" | "save"
type ExportTag = "thumbnail" | "validation" | "version"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const latestSvgRef = useRef("")
const [latestSvg, setLatestSvg] = useState<string>("")
const [isDrawioReady, setIsDrawioReady] = useState(false)
const hasCalledOnLoadRef = useRef(false)
const mountedRef = useRef(true)
@@ -148,7 +152,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioRef = useRef<DrawioFrameHandle | null>(null)
// Pending exports, keyed by their export tag
const exportResolversRef = useRef<
Record<string, (result: ExportResult) => void>
Record<string, (data: string, xml?: string) => void>
>({})
const exportSeqRef = useRef(0)
// Track latest chartXML for restoration after remount
@@ -213,15 +217,26 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setChartXML(xml)
}
const handleExport = () => {
if (!drawioRef.current) return ""
// Export of the current state (for the chat and edit_diagram)
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
// 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" | "svg" | "png", timeoutMs: number) =>
new Promise<ExportResult | null>((resolve) => {
new Promise<string | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: ExportResult | null) => {
const finish = (value: string | null) => {
clearTimeout(timer)
delete exportResolversRef.current[id]
resolve(value)
@@ -234,18 +249,20 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
)
// Get current diagram as SVG for thumbnail (used by session storage).
// One identity: the chat's auto-save effect depends on it
// 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<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL
const result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (result?.data?.startsWith("data:image/svg")) {
// Kept for later saves that take no fresh thumbnail
latestSvgRef.current = result.data
return result.data
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves
setLatestSvg(svgData)
return svgData
}
return null
}, [requestTaggedExport])
@@ -253,8 +270,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Plain SVG (no embedded diagram data) for version cards
const getVersionSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
const result = await requestTaggedExport("version", "svg", 4000)
return result?.data?.startsWith("data:image/svg") ? result.data : null
const svgData = await requestTaggedExport("version", "svg", 4000)
return svgData?.startsWith("data:image/svg") ? svgData : null
}
// Capture current diagram as PNG for VLM validation
@@ -263,27 +280,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
const result = await requestTaggedExport("validation", "png", 5000)
const pngData = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL
return result?.data?.startsWith("data:image/png") ? result.data : null
}
// The page the user is viewing (the first one with an external draw.io,
// which cannot tell us), for the chat and edit_diagram.
// Not written to chartXML: exports don't change the diagram, and the
// export's xml has compressed <diagram> payloads that would break
// edit_diagram/display_diagram. Autosave keeps chartXML up to date with
// the full uncompressed multi-page document (#879).
const fetchDiagramXml = async (): Promise<string> => {
const result = await requestTaggedExport("fetch", "xmlsvg", 10000)
if (!result?.data) {
throw new Error("Chart export timed out after 10 seconds")
}
latestSvgRef.current = result.data
return extractDiagramXML(
result.data,
useCanvasStore.getState().currentPageId,
)
return pngData?.startsWith("data:image/png") ? pngData : null
}
// Like requestTaggedExport, with the request's other parameters (a PNG's
@@ -302,7 +301,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
exportResolversRef.current[id] = finish
exportResolversRef.current[id] = (data, xml) =>
finish({ data, xml })
drawioRef.current.exportDiagram({ ...request, message: id })
}),
[],
@@ -406,11 +406,34 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resetPreview()
}
// Each export result goes to the caller that asked for it, by its tag
const handleDiagramExport = (data: DrawioExportEvent) => {
// Thumbnail, version, validation PNG, file save and requestExport
// exports go only to their own caller
const tag = data.message?.message
if (!tag) return
exportResolversRef.current[tag]?.({ data: data.data, xml: data.xml })
if (/^(thumbnail|validation|version|save|export)-/.test(tag ?? "")) {
exportResolversRef.current[tag as string]?.(data.data, data.xml)
return
}
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
// The chat gets the page the user is viewing (the first one with
// an external draw.io, which cannot tell us)
const extractedXML = extractDiagramXML(
data.data,
useCanvasStore.getState().currentPageId,
)
setLatestSvg(data.data)
// 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)
}
}
// The frame registers this callback once per iframe mount, so it must
@@ -436,10 +459,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const emptyDiagram = BLANK_MXFILE
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true)
latestSvgRef.current = ""
setLatestSvg("")
}
const saveDiagramToFile = async (
const saveDiagramToFile = (
filename: string,
format: ExportFormat,
sessionId?: string,
@@ -454,80 +477,85 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Each save has its own tag, so two at once never swap results. A
// large PNG takes a while to render.
const result = await requestTaggedExport("save", drawioFormat, 30000)
if (!result?.data) {
console.warn("Diagram export for saving timed out")
return
}
const exportData = result.data
const fullDiagramXML = result.xml
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)
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"
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)
// 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)
// Show success toast after download is initiated
if (successMessage) {
toast.success(successMessage, { duration: 2500 })
}
// Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100)
}
}
// 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)
}
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, { duration: 2500 })
}
// 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,
message: tag,
})
}
// Log save event to Langfuse (just flags the trace, doesn't send content)
@@ -558,9 +586,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
value={{
chartXML,
chartXMLRef,
latestSvgRef,
latestSvg,
loadDiagram,
fetchDiagramXml,
handleExport,
exportResolversRef,
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
+1 -1
View File
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
| `models` | 是 | 模型 ID 列表 |
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) |
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
### 说明
+38 -8
View File
@@ -68,15 +68,47 @@ cp env.example .env.local
## 第三步 — 选择部署类型
### 选项 A:不使用 R2 部署(默认,免费)
### 选项 A:不使用 R2 部署(简单,免费)
仓库自带的就是这套配置:`open-next.config.ts` 为 `defineCloudflareConfig({})`,`wrangler.jsonc` 中没有 `r2_buckets`。无需改动任何文件,直接跳至 **第四步**。
如果您不需要 ISR 缓存,可以选择不使用 R2 进行部署:
**1. 使用简单的 `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. 使用简单的 `wrangler.jsonc`(不包含 r2_buckets):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
直接跳至 **第四步**。
---
### 选项 B:使用 R2 部署(可选,用于 ISR 缓存)
### 选项 B:使用 R2 部署(完整的 ISR 支持)
R2 为 OpenNext 提供存放 **增量静态再生 (ISR)** 页面的空间。本应用目前没有 ISR 页面,只有在您自己添加了此类页面时才需要这一步。需要在您的 Cloudflare 账户中绑定支付方式。
R2 开启了 **增量静态再生 (ISR)** 缓存功能。需要在您的 Cloudflare 账户中绑定支付方式。
**1. 在 Cloudflare 控制台中创建 R2 存储桶:**
@@ -95,7 +127,7 @@ export default defineCloudflareConfig({
})
```
**3. 在 `wrangler.jsonc` 中加入 `r2_buckets`:**
**3. 配置 `wrangler.jsonc`(包含 R2):**
```jsonc
{
@@ -125,8 +157,6 @@ export default defineCloudflareConfig({
> **重要提示:** `bucket_name` 必须与您在 Cloudflare 控制台中创建的名称完全一致。
> **注意:** 定时重新验证(页面或 fetch 上的 `revalidate`)还需要在 `open-next.config.ts` 中配置 `queue`,请参阅 OpenNext 的 Cloudflare 缓存文档。
---
## 第四步 — 注册 workers.dev 子域名(仅首次需要)
@@ -234,4 +264,4 @@ npm run preview
| API 路由 | 支持 | 支持 |
| 配置复杂度 | 简单 | 中等 |
仓库默认 **不使用 R2**。只有当您添加了需要 ISR 缓存的页面时,才选择 **使用 R2**。
测试或简单应用请选择 **不使用 R2**。需要 ISR 缓存的生产环境应用请选择 **使用 R2**。
+1 -1
View File
@@ -407,7 +407,7 @@ This is shorthand for the equivalent `ai-models.json`. For multiple providers or
| `provider` | Yes | Provider type (`openai`, `anthropic`, `google`, `bedrock`, etc.) |
| `models` | Yes | List of model IDs |
| `default` | No | Set to `true` to auto-select this provider's first model as default |
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`). May also be an array of env var names: each request picks one that has a value at random, spreading load over several keys |
| `apiKeyEnv` | No | Custom API key env var name (defaults to provider's standard var like `OPENAI_API_KEY`) |
| `baseUrlEnv` | No | Custom base URL env var name |
### Notes
+38 -8
View File
@@ -68,15 +68,47 @@ Fill in the same values there.
## Step 3 — Choose your deployment type
### Option A: Deploy WITHOUT R2 (Default, Free)
### Option A: Deploy WITHOUT R2 (Simple, Free)
This is how the repository is configured: `open-next.config.ts` is `defineCloudflareConfig({})` and `wrangler.jsonc` has no `r2_buckets`. Nothing to change here. Skip to **Step 4**.
If you don't need ISR caching, you can deploy without R2:
**1. Use simple `open-next.config.ts`:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. Use simple `wrangler.jsonc` (without r2_buckets):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
Skip to **Step 4**.
---
### Option B: Deploy WITH R2 (Optional, ISR caching)
### Option B: Deploy WITH R2 (Full ISR Support)
R2 gives OpenNext a place to store **Incremental Static Regeneration (ISR)** pages. This app currently has no ISR pages, so you only need this step if you add some. Requires a payment method on your Cloudflare account.
R2 enables **Incremental Static Regeneration (ISR)** caching. Requires a payment method on your Cloudflare account.
**1. Create an R2 bucket** in the Cloudflare Dashboard:
@@ -95,7 +127,7 @@ export default defineCloudflareConfig({
})
```
**3. Add `r2_buckets` to `wrangler.jsonc`:**
**3. Configure `wrangler.jsonc` (with R2):**
```jsonc
{
@@ -125,8 +157,6 @@ export default defineCloudflareConfig({
> **Important:** The `bucket_name` must exactly match the name you created in the Cloudflare dashboard.
> **Note:** Time-based revalidation (`revalidate` on a page or fetch) also needs a `queue` in `open-next.config.ts`. See the OpenNext Cloudflare caching docs.
---
## Step 4 — Register a workers.dev subdomain (first-time only)
@@ -234,4 +264,4 @@ npm run preview
| API Routes | Yes | Yes |
| Setup Complexity | Simple | Moderate |
The repository defaults to **without R2**. Choose **with R2** only if you add pages that need ISR caching.
Choose **without R2** for testing or simple apps. Choose **with R2** for production apps that need ISR caching.
+1 -1
View File
@@ -392,7 +392,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
| `provider` | はい | プロバイダータイプ(`openai`, `anthropic`, `google`, `bedrock` など) |
| `models` | はい | モデルIDのリスト |
| `default` | いいえ | `true` に設定すると、そのプロバイダーの最初のモデルがデフォルトで選択されます |
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数)。環境変数名の配列も指定でき、リクエストごとに値のある変数から1つがランダムに選ばれます(複数キーへの負荷分散) |
| `apiKeyEnv` | いいえ | カスタムAPIキー環境変数名(デフォルトは `OPENAI_API_KEY` などの標準変数) |
| `baseUrlEnv` | いいえ | カスタムBase URL環境変数名 |
### 備考
+38 -8
View File
@@ -68,15 +68,47 @@ cp env.example .env.local
## ステップ 3 — デプロイタイプの選択
### オプション A: R2 なしでのデプロイ(デフォルト、無料)
### オプション A: R2 なしでのデプロイ(シンプル、無料)
リポジトリはこの構成のままです。`open-next.config.ts` は `defineCloudflareConfig({})` で、`wrangler.jsonc` に `r2_buckets` はありません。ファイルを変更する必要はないので、**ステップ 4** へ進んでください。
ISR キャッシュが不要な場合は、R2 なしでデプロイできます:
**1. シンプルな `open-next.config.ts` を使用:**
```ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare/config"
export default defineCloudflareConfig({})
```
**2. シンプルな `wrangler.jsonc` を使用(r2_buckets なし):**
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "next-ai-draw-io-worker",
"compatibility_date": "2025-12-08",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"services": [
{
"binding": "WORKER_SELF_REFERENCE",
"service": "next-ai-draw-io-worker"
}
]
}
```
**ステップ 4** へ進んでください。
---
### オプション B: R2 ありでのデプロイ(オプション、ISR キャッシュ用)
### オプション B: R2 ありでのデプロイ(完全な ISR サポート)
R2 は OpenNext が **Incremental Static Regeneration (ISR)** ページを保存する場所になります。このアプリには現在 ISR ページがないため、自分で ISR ページを追加した場合にだけ必要です。Cloudflare アカウントに支払い方法の登録が必要です。
R2 を使用すると **Incremental Static Regeneration (ISR)** キャッシュが有効になります。これには Cloudflare アカウントに支払い方法の登録が必要です。
**1. R2 バケットの作成**(Cloudflare ダッシュボードにて):
@@ -95,7 +127,7 @@ export default defineCloudflareConfig({
})
```
**3. `wrangler.jsonc` に `r2_buckets` を追加:**
**3. `wrangler.jsonc` の設定(R2 あり):**
```jsonc
{
@@ -125,8 +157,6 @@ export default defineCloudflareConfig({
> **重要:** `bucket_name` は Cloudflare ダッシュボードで作成した名前と完全に一致させる必要があります。
> **注意:** 時間ベースの再検証(ページや fetch の `revalidate`)には、`open-next.config.ts` で `queue` の設定も必要です。OpenNext の Cloudflare キャッシュドキュメントを参照してください。
---
## ステップ 4 — workers.dev サブドメインの登録(初回のみ)
@@ -234,4 +264,4 @@ npm run preview
| API ルート | あり | あり |
| 設定の複雑さ | シンプル | 普通 |
リポジトリのデフォルトは **R2 なし** です。ISR キャッシュが必要なページを追加した場合にだけ **R2 あり** を選んでください。
テストやシンプルなアプリには **R2 なし** を選んでください。ISR キャッシュが必要な本番アプリには **R2 あり** を選んでください。
+56 -14
View File
@@ -29,7 +29,6 @@ const messageItemSchema = z
})
.passthrough()
// The fields read here; every other field is passed on to AI.chatCompletions
const messageSchema = z
.object({
messages: z.array(messageItemSchema),
@@ -37,6 +36,21 @@ const messageSchema = z
stream: z.boolean().optional(),
tools: z.any().optional(),
tool_choice: z.any().optional(),
functions: z.any().optional(),
function_call: z.any().optional(),
temperature: z.number().optional(),
top_p: z.number().optional(),
max_tokens: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
stop: z.union([z.string(), z.array(z.string())]).optional(),
response_format: z.any().optional(),
seed: z.number().optional(),
user: z.string().optional(),
n: z.number().int().optional(),
logit_bias: z.record(z.string(), z.number()).optional(),
parallel_tool_calls: z.boolean().optional(),
stream_options: z.any().optional(),
})
.passthrough()
@@ -47,6 +61,12 @@ const ALLOWED_MODELS = [
"@tx/deepseek-ai/deepseek-v3-0324",
]
const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3.2": "@tx/deepseek-ai/deepseek-v32",
"deepseek-r1-0528": "@tx/deepseek-ai/deepseek-r1-0528",
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
/**
* Create standardized JSON response
*/
@@ -125,16 +145,8 @@ export async function onRequest({ request, env }: any) {
)
}
// stream is taken out so it is not passed on: AI.chatCompletions
// only streams, and every caller reads a stream
const {
messages,
model,
stream: _,
tools,
tool_choice,
...extraParams
} = parseResult.data
const { messages, model, stream, tools, tool_choice, ...extraParams } =
parseResult.data
// Validate messages
const userMessages = messages.filter(
@@ -153,13 +165,14 @@ export async function onRequest({ request, env }: any) {
}
// Resolve model
const selectedModel = model || ALLOWED_MODELS[0]
const requestedModel = model || ALLOWED_MODELS[0]
const selectedModel = MODEL_ALIASES[requestedModel] || requestedModel
if (!ALLOWED_MODELS.includes(selectedModel)) {
return createResponse(
{
error: {
message: `Invalid model: ${selectedModel}.`,
message: `Invalid model: ${requestedModel}.`,
type: "invalid_request_error",
},
},
@@ -168,10 +181,39 @@ export async function onRequest({ request, env }: any) {
}
console.log(
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}`,
`[EdgeOne] Model: ${selectedModel}, Tools: ${tools?.length || 0}, Stream: ${stream ?? true}`,
)
try {
const isStream = !!stream
// Non-streaming: return mock response for validation
// AI.chatCompletions doesn't support non-streaming mode
if (!isStream) {
const mockResponse = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: selectedModel,
choices: [
{
index: 0,
message: {
role: "assistant",
content: "OK",
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 1,
total_tokens: 11,
},
}
return createResponse(mockResponse)
}
// Build AI.chatCompletions options for streaming
const aiOptions: any = {
...extraParams,
+69 -3
View File
@@ -2,6 +2,29 @@
* Type declarations for Electron API exposed via preload script
*/
/** Configuration preset interface */
interface ConfigPreset {
id: string
name: string
createdAt: number
updatedAt: number
config: {
AI_PROVIDER?: string
AI_MODEL?: string
AI_API_KEY?: string
AI_BASE_URL?: string
TEMPERATURE?: string
[key: string]: string | undefined
}
}
/** Result of applying a preset */
interface ApplyPresetResult {
success: boolean
error?: string
env?: Record<string, string>
}
/** Proxy configuration interface */
interface ProxyConfig {
httpProxy?: string
@@ -12,6 +35,7 @@ interface ProxyConfig {
interface SetProxyResult {
success: boolean
error?: string
devMode?: boolean
}
/** Result of setting user locale */
@@ -24,17 +48,31 @@ declare global {
interface Window {
/** Main window Electron API */
electronAPI?: {
/** Current platform (darwin, win32, linux) */
platform: NodeJS.Platform
/** Whether running in Electron environment */
isElectron: boolean
/** Get application version */
getVersion: () => Promise<string>
/** Minimize the window */
minimize: () => void
/** Maximize/restore the window */
maximize: () => void
/** Close the window */
close: () => void
/** Get proxy configuration */
getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */
getUserLocale: () => Promise<
"en" | "zh" | "ja" | "zh-Hant" | undefined
>
/** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
/**
* Call back after the server restarted on the same port (proxy
* change); returns a function that stops the calls
* 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 */
@@ -42,7 +80,35 @@ declare global {
/** The page loaded with this many chats */
chatsLoaded?: (count: number) => Promise<void>
}
/** Settings window Electron API */
settingsAPI?: {
/** Get all configuration presets */
getPresets: () => Promise<ConfigPreset[]>
/** Get current preset ID */
getCurrentPresetId: () => Promise<string | null>
/** Get current preset */
getCurrentPreset: () => Promise<ConfigPreset | null>
/** Save (create or update) a preset */
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => Promise<ConfigPreset>
/** Delete a preset */
deletePreset: (id: string) => Promise<boolean>
/** Apply a preset (sets environment variables and restarts server) */
applyPreset: (id: string) => Promise<ApplyPresetResult>
/** Close settings window */
close: () => void
}
}
}
export type { ProxyConfig }
export type {
ApplyPresetResult,
ConfigPreset,
ProxyConfig,
SetProxyResult,
SetUserLocaleResult,
}
+161 -2
View File
@@ -1,9 +1,23 @@
import { app, Menu, type MenuItemConstructorOptions, shell } from "electron"
import {
app,
BrowserWindow,
dialog,
Menu,
type MenuItemConstructorOptions,
shell,
} from "electron"
import {
applyPresetToEnv,
getAllPresets,
getCurrentPresetId,
setCurrentPreset,
} from "./config-manager"
import { getMenuTranslations, getPreferredLocale } from "./menu-i18n"
import { restartNextServer } from "./next-server"
import { showSettingsWindow } from "./settings-window"
/**
* Build and set the application menu with i18n support
* (call again when the language changes)
*/
export function buildAppMenu(): void {
const template = getMenuTemplate()
@@ -11,6 +25,75 @@ export function buildAppMenu(): void {
Menu.setApplicationMenu(menu)
}
/**
* Rebuild the menu (call this when presets change or language changes)
*/
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<Record<string, string>> {
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
*/
@@ -30,6 +113,15 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
submenu: [
{ role: "about" }, // System-translated
{ type: "separator" },
{
label: t.settings,
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" },
{ role: "services" }, // System-translated
{ type: "separator" },
{ role: "hide" }, // System-translated
@@ -45,6 +137,19 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
template.push({
label: t.file,
submenu: [
...(isMac
? []
: [
{
label: t.settings,
accelerator: "CmdOrCtrl+,",
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
{ type: "separator" } as MenuItemConstructorOptions,
]),
isMac ? { role: "close" } : { role: "quit" }, // System-translated
],
})
@@ -91,6 +196,9 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
],
})
// Configuration menu with presets
template.push(buildConfigMenu(t))
// Window menu
template.push({
label: t.window,
@@ -131,3 +239,54 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
return template
}
/**
* Build the Configuration menu with presets
*/
function buildConfigMenu(
t: ReturnType<typeof getMenuTranslations>,
): MenuItemConstructorOptions {
const presets = getAllPresets()
const currentPresetId = getCurrentPresetId()
const presetItems: MenuItemConstructorOptions[] = presets.map((preset) => ({
label: preset.name,
type: "radio",
checked: preset.id === currentPresetId,
click: async () => {
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)}`,
)
}
},
}))
return {
label: t.configuration,
submenu: [
...(presetItems.length > 0
? [
{ label: t.switchPreset, enabled: false },
{ type: "separator" } as MenuItemConstructorOptions,
...presetItems,
{ type: "separator" } as MenuItemConstructorOptions,
]
: []),
{
label:
presetItems.length > 0
? t.managePresets
: t.addConfigurationPreset,
click: () => {
const win = BrowserWindow.getFocusedWindow()
showSettingsWindow(win || undefined)
},
},
],
}
}
+551 -38
View File
@@ -1,57 +1,570 @@
import { app } from "electron"
import * as fs from "fs"
import * as path from "path"
import { randomUUID } from "node:crypto"
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs"
import path from "node:path"
import { app, safeStorage } from "electron"
export const USER_LOCALES = ["en", "zh", "ja", "zh-Hant"] as const
export type UserLocale = (typeof USER_LOCALES)[number]
/**
* Fields that contain sensitive data and should be encrypted
*/
const SENSITIVE_FIELDS = ["AI_API_KEY"] as const
// Earlier versions kept their configuration presets in this file too; the
// name stays so the saved language survives the upgrade
const CONFIG_FILE = "config-presets.json"
/**
* Prefix to identify encrypted values
*/
const ENCRYPTED_PREFIX = "encrypted:"
function getConfigPath(): string {
return path.join(app.getPath("userData"), CONFIG_FILE)
/**
* Check if safeStorage encryption is available
*/
function isEncryptionAvailable(): boolean {
return safeStorage.isEncryptionAvailable()
}
/**
* Load the config file as an object ({} if it is missing or invalid)
* Track if we've already warned about plaintext storage
*/
function loadConfig(): Record<string, unknown> {
let hasWarnedAboutPlaintext = false
/**
* Encrypt a sensitive value using safeStorage
* Warns if encryption is not available (API key stored in plaintext)
*/
function encryptValue(value: string): string {
// 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
}
if (!isEncryptionAvailable()) {
if (!hasWarnedAboutPlaintext) {
console.warn(
"⚠️ SECURITY WARNING: safeStorage not available. " +
"API keys will be stored in PLAINTEXT. " +
"On Linux, install gnome-keyring or similar for secure storage.",
)
hasWarnedAboutPlaintext = true
}
return value
}
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
if (data && typeof data === "object" && !Array.isArray(data)) {
return data
}
console.error("Ignoring invalid config file:", data)
}
const encrypted = safeStorage.encryptString(value)
return ENCRYPTED_PREFIX + encrypted.toString("base64")
} catch (error) {
console.error("Failed to load config file:", error)
console.error("Encryption failed:", error)
// Fail secure: don't store if encryption fails
throw new Error(
"Failed to encrypt API key. Cannot securely store credentials.",
)
}
return {}
}
/**
* Get the menu locale the user chose, if any
* 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)
*/
export function getUserLocale(): UserLocale | undefined {
const { userLocale } = loadConfig()
return (USER_LOCALES as readonly unknown[]).includes(userLocale)
? (userLocale as UserLocale)
: undefined
function decryptValue(value: string): string {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
return value
}
if (!isEncryptionAvailable()) {
console.warn(
"Cannot decrypt value: safeStorage encryption is not available",
)
return value
}
try {
const base64Data = value.slice(ENCRYPTED_PREFIX.length)
const buffer = Buffer.from(base64Data, "base64")
return safeStorage.decryptString(buffer)
} catch (error) {
console.error("Failed to decrypt value:", error)
return value
}
}
/**
* Save the menu locale the user chose
* Encrypt sensitive fields in a config object
*/
export function setUserLocale(locale: UserLocale): void {
const config = { ...loadConfig(), userLocale: locale }
const configPath = getConfigPath()
fs.mkdirSync(path.dirname(configPath), { recursive: true })
// 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)
function encryptConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const encrypted = { ...config }
for (const field of SENSITIVE_FIELDS) {
if (encrypted[field]) {
encrypted[field] = encryptValue(encrypted[field] as string)
}
}
return encrypted
}
/**
* Decrypt sensitive fields in a config object
*/
function decryptConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const decrypted = { ...config }
for (const field of SENSITIVE_FIELDS) {
if (decrypted[field]) {
decrypted[field] = decryptValue(decrypted[field] as string)
}
}
return decrypted
}
/**
* Configuration preset interface
*/
export interface ConfigPreset {
id: string
name: string
createdAt: number
updatedAt: number
config: {
AI_PROVIDER?: string
AI_MODEL?: string
AI_API_KEY?: string
AI_BASE_URL?: string
TEMPERATURE?: string
[key: string]: string | undefined
}
}
/**
* Configuration file structure
*/
interface ConfigPresetsFile {
version: 1
currentPresetId: string | null
presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja" | "zh-Hant"
}
const CONFIG_FILE_NAME = "config-presets.json"
/**
* Get the path to the config file
*/
function getConfigFilePath(): string {
const userDataPath = app.getPath("userData")
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
*/
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,
presets: [],
userLocale: undefined,
}
}
try {
const data = JSON.parse(content) as ConfigPresetsFile
// Decrypt sensitive fields in each preset
data.presets = data.presets.map((preset) => ({
...preset,
config: decryptConfig(preset.config) as ConfigPreset["config"],
}))
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,
presets: [],
userLocale: undefined,
}
}
}
/**
* Save presets to the config file
* 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")
// Ensure the directory exists
if (!existsSync(userDataPath)) {
mkdirSync(userDataPath, { recursive: true })
}
// Encrypt sensitive fields before saving
const dataToSave: ConfigPresetsFile = {
...data,
presets: data.presets.map((preset) => ({
...preset,
config: encryptConfig(preset.config) as ConfigPreset["config"],
})),
}
try {
// 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
}
}
/**
* Get all presets
*/
export function getAllPresets(): ConfigPreset[] {
const data = loadPresets()
return data.presets
}
/**
* Get current preset ID
*/
export function getCurrentPresetId(): string | null {
const data = loadPresets()
return data.currentPresetId
}
/**
* Get current preset
*/
export function getCurrentPreset(): ConfigPreset | null {
const data = loadPresets()
if (!data.currentPresetId) {
return null
}
return data.presets.find((p) => p.id === data.currentPresetId) || null
}
/**
* Create a new preset
*/
export function createPreset(
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt">,
): ConfigPreset {
const data = loadPresets()
const now = Date.now()
const newPreset: ConfigPreset = {
id: randomUUID(),
name: preset.name,
config: preset.config,
createdAt: now,
updatedAt: now,
}
data.presets.push(newPreset)
savePresets(data)
return newPreset
}
/**
* Update an existing preset
*/
export function updatePreset(
id: string,
updates: Partial<Omit<ConfigPreset, "id" | "createdAt">>,
): ConfigPreset | null {
const data = loadPresets()
const index = data.presets.findIndex((p) => p.id === id)
if (index === -1) {
return null
}
const updatedPreset: ConfigPreset = {
...data.presets[index],
...updates,
updatedAt: Date.now(),
}
data.presets[index] = updatedPreset
savePresets(data)
return updatedPreset
}
/**
* Delete a preset
*/
export function deletePreset(id: string): boolean {
const data = loadPresets()
const index = data.presets.findIndex((p) => p.id === id)
if (index === -1) {
return false
}
data.presets.splice(index, 1)
// Clear current preset (and its env vars) if it was deleted
if (data.currentPresetId === id) {
data.currentPresetId = null
setPresetEnv(null)
}
savePresets(data)
return true
}
/**
* Set the current preset
*/
export function setCurrentPreset(id: string | null): boolean {
const data = loadPresets()
let preset: ConfigPreset | null = null
if (id !== null) {
preset = data.presets.find((p) => p.id === id) || null
if (!preset) {
return false
}
}
setPresetEnv(preset)
data.currentPresetId = id
savePresets(data)
return true
}
/**
* Map generic AI_API_KEY and AI_BASE_URL to provider-specific environment variables
*/
const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" },
google: {
apiKey: "GOOGLE_GENERATIVE_AI_API_KEY",
baseUrl: "GOOGLE_BASE_URL",
},
azure: { apiKey: "AZURE_API_KEY", baseUrl: "AZURE_BASE_URL" },
openrouter: {
apiKey: "OPENROUTER_API_KEY",
baseUrl: "OPENROUTER_BASE_URL",
},
deepseek: { apiKey: "DEEPSEEK_API_KEY", baseUrl: "DEEPSEEK_BASE_URL" },
siliconflow: {
apiKey: "SILICONFLOW_API_KEY",
baseUrl: "SILICONFLOW_BASE_URL",
},
modelscope: {
apiKey: "MODELSCOPE_API_KEY",
baseUrl: "MODELSCOPE_BASE_URL",
},
gateway: { apiKey: "AI_GATEWAY_API_KEY", baseUrl: "AI_GATEWAY_BASE_URL" },
// bedrock doesn't use API keys in the same way
bedrock: { apiKey: "", baseUrl: "" },
ollama: { apiKey: "OLLAMA_API_KEY", baseUrl: "OLLAMA_BASE_URL" },
}
/**
* Map a preset's config to environment variables
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
*/
function presetToEnv(preset: ConfigPreset): Record<string, string> {
const env: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// A key that could not be decrypted is useless to the server
if (value.startsWith(ENCRYPTED_PREFIX)) {
console.warn(
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
)
}
// Map generic AI_API_KEY to provider-specific key
else if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
env[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) {
env[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
env[key] = value
}
}
}
return env
}
/**
* Values that env vars had before a preset first set them
* (from the system or .env files), and the keys the active preset set
*/
const originalEnv: Record<string, string | undefined> = {}
let presetEnvKeys: string[] = []
/**
* Replace the env vars of the previous preset with those of the given preset
* (null leaves no preset applied). Restoring first means switching presets
* never leaves the previous preset's base URL, model or key behind.
*/
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
for (const key of presetEnvKeys) {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
}
const env = preset ? presetToEnv(preset) : {}
for (const [key, value] of Object.entries(env)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key]
}
process.env[key] = value
}
presetEnvKeys = Object.keys(env)
writeDevPresetEnv(env)
return env
}
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
/**
* Development only: write the active preset's env vars (decrypted and mapped)
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
* this file changes. The dev server can't decrypt the config file itself.
*/
function writeDevPresetEnv(env: Record<string, string>): void {
if (app.isPackaged) {
return
}
try {
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
writeFileSync(filePath, JSON.stringify(env, null, 2), {
encoding: "utf-8",
mode: 0o600,
})
} catch (error) {
console.error("Failed to write dev preset env:", error)
}
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv = setPresetEnv(preset)
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Apply the saved current preset's environment variables (used at startup)
*/
export function applyCurrentPresetToEnv(): void {
setPresetEnv(getCurrentPreset())
}
/**
* Get user's preferred locale from config
* Returns undefined if not set
*/
export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
const data = loadPresets()
return data.userLocale
}
/**
* Set user's preferred locale in config
*/
export function setUserLocale(
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
): void {
const data = loadPresets()
data.userLocale = locale === null ? undefined : locale
savePresets(data)
}
+58 -3
View File
@@ -1,6 +1,5 @@
import fs from "node:fs"
import path from "node:path"
import { parse } from "dotenv"
import { app } from "electron"
/**
@@ -29,13 +28,69 @@ 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
*/
function loadEnvFromFile(filePath: string): void {
try {
const parsed = parse(fs.readFileSync(filePath, "utf-8"))
for (const [key, value] of Object.entries(parsed)) {
const content = fs.readFileSync(filePath, "utf-8")
const lines = content.split("\n")
for (const line of lines) {
const trimmed = line.trim()
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith("#")) continue
const equalIndex = trimmed.indexOf("=")
if (equalIndex === -1) continue
const key = trimmed.slice(0, equalIndex).trim()
let value = trimmed.slice(equalIndex + 1).trim()
const quote = value[0]
const closingQuote =
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
if (
closingQuote > 0 &&
/^\s*(#.*)?$/.test(value.slice(closingQuote + 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
if (!(key in process.env)) {
process.env[key] = value
+8
View File
@@ -1,9 +1,11 @@
import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu"
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,
getAppUrl,
@@ -34,8 +36,14 @@ if (!gotTheLock) {
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()
// Build application menu
buildAppMenu()
+171 -11
View File
@@ -1,6 +1,17 @@
import { app, type IpcMainInvokeEvent, ipcMain } from "electron"
import { buildAppMenu } from "./app-menu"
import { setUserLocale, USER_LOCALES, type UserLocale } from "./config-manager"
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
import { rebuildAppMenu, switchPreset } from "./app-menu"
import {
type ConfigPreset,
createPreset,
deletePreset,
getAllPresets,
getCurrentPreset,
getCurrentPresetId,
getUserLocale,
setCurrentPreset,
setUserLocale,
updatePreset,
} from "./config-manager"
import { restartNextServer } from "./next-server"
import { noteNoChats, rememberChatPort } from "./port-manager"
import {
@@ -12,9 +23,37 @@ import {
import { isAppUrl } from "./window-manager"
/**
* Register an IPC handler that only answers the app's own pages (on the app
* server). A main window that somehow ends up on an external site still
* gets the preload API, so its calls must be rejected here.
* Allowed configuration keys for presets
* This whitelist prevents arbitrary environment variable injection
*/
const ALLOWED_CONFIG_KEYS = new Set([
"AI_PROVIDER",
"AI_MODEL",
"AI_API_KEY",
"AI_BASE_URL",
"TEMPERATURE",
])
/**
* Sanitize preset config to only include allowed keys
*/
function sanitizePresetConfig(
config: Record<string, string | undefined>,
): Record<string, string | undefined> {
const sanitized: Record<string, string | undefined> = {}
for (const key of ALLOWED_CONFIG_KEYS) {
if (key in config && typeof config[key] === "string") {
sanitized[key] = config[key]
}
}
return sanitized
}
/**
* Register an IPC handler that only answers the app's own pages
* (the main window on the app server, or the local settings page).
* A main window that somehow ends up on an external site still gets the
* preload API, so its calls must be rejected here.
*/
function handle<Args extends unknown[]>(
channel: string,
@@ -22,7 +61,7 @@ function handle<Args extends unknown[]>(
): void {
ipcMain.handle(channel, (event, ...args) => {
const url = event.senderFrame?.url
if (!isAppUrl(url)) {
if (!isAppUrl(url) && !url?.startsWith("file://")) {
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
}
return listener(event, ...(args as Args))
@@ -33,6 +72,12 @@ function handle<Args extends unknown[]>(
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
// ==================== App Info ====================
handle("get-version", () => {
return app.getVersion()
})
// ==================== Where the chats are ====================
// The page saved a chat, or loaded without any: decides which port
@@ -42,6 +87,116 @@ export function registerIpcHandlers(): void {
if (count === 0) noteNoChats()
})
// ==================== Window Controls ====================
ipcMain.on("window-minimize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.minimize()
})
ipcMain.on("window-maximize", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win?.isMaximized()) {
win.unmaximize()
} else {
win?.maximize()
}
})
ipcMain.on("window-close", (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.close()
})
// ==================== Config Presets ====================
handle("config-presets:get-all", () => {
return getAllPresets()
})
handle("config-presets:get-current", () => {
return getCurrentPreset()
})
handle("config-presets:get-current-id", () => {
return getCurrentPresetId()
})
handle(
"config-presets:save",
async (
_event,
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
id?: string
},
) => {
// Validate preset name
if (typeof preset?.name !== "string" || !preset.name.trim()) {
throw new Error("Invalid preset name")
}
// Sanitize config to only allow whitelisted keys
const sanitizedConfig = sanitizePresetConfig(preset.config ?? {})
if (preset.id) {
// Update existing preset
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
const created = createPreset({
name: preset.name.trim(),
config: sanitizedConfig,
})
rebuildAppMenu()
return created
},
)
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
})
handle("config-presets:apply", async (_event, id: string) => {
try {
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,
error:
error instanceof Error
? error.message
: "Failed to restart server",
}
}
})
handle("config-presets:set-current", (_event, id: string | null) => {
return setCurrentPreset(id)
})
// ==================== Proxy Settings ====================
handle("get-proxy", () => {
@@ -73,7 +228,7 @@ export function registerIpcHandlers(): void {
if (!app.isPackaged) {
// In development, env vars are already applied
// Next.js dev server may need manual restart
return { success: true }
return { success: true, devMode: true }
}
// Production: restart Next.js server to pick up new env vars
@@ -92,15 +247,20 @@ export function registerIpcHandlers(): void {
// ==================== User Locale ====================
handle("get-user-locale", () => {
return getUserLocale()
})
handle("set-user-locale", (_event, locale: string) => {
if (!(USER_LOCALES as readonly string[]).includes(locale)) {
// Validate locale is one of the supported values
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" }
}
try {
setUserLocale(locale as UserLocale)
setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
// Rebuild the menu to reflect the new locale
buildAppMenu()
rebuildAppMenu()
return { success: true }
} catch (error) {
return {
+113 -27
View File
@@ -3,35 +3,71 @@
* Translations for menu labels that don't use Electron's built-in roles
*/
import { getUserLocale, type UserLocale } from "./config-manager"
import { getUserLocale } from "./config-manager"
export type MenuLocale = UserLocale
export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
export interface MenuTranslations {
// App menu (macOS only)
settings: string
const en = {
// File menu
file: "File",
file: string
// Edit menu
edit: "Edit",
edit: string
// View menu
view: "View",
view: string
// Configuration menu
configuration: string
switchPreset: string
managePresets: string
addConfigurationPreset: string
// Window menu
window: "Window",
window: string
// Help menu
help: "Help",
documentation: "Documentation",
reportIssue: "Report Issue",
help: string
documentation: string
reportIssue: string
}
type MenuTranslations = typeof en
const translations: Record<MenuLocale, MenuTranslations> = {
en,
en: {
// App menu
settings: "Settings...",
// File menu
file: "File",
// Edit menu
edit: "Edit",
// View menu
view: "View",
// Configuration menu
configuration: "Configuration",
switchPreset: "Switch Preset",
managePresets: "Manage Presets...",
addConfigurationPreset: "Add Configuration Preset...",
// Window menu
window: "Window",
// Help menu
help: "Help",
documentation: "Documentation",
reportIssue: "Report Issue",
},
zh: {
// App menu
settings: "设置...",
// File menu
file: "文件",
@@ -41,6 +77,12 @@ const translations: Record<MenuLocale, MenuTranslations> = {
// View menu
view: "查看",
// Configuration menu
configuration: "配置",
switchPreset: "切换预设",
managePresets: "管理预设...",
addConfigurationPreset: "添加配置预设...",
// Window menu
window: "窗口",
@@ -51,6 +93,9 @@ const translations: Record<MenuLocale, MenuTranslations> = {
},
ja: {
// App menu
settings: "設定...",
// File menu
file: "ファイル",
@@ -60,6 +105,12 @@ const translations: Record<MenuLocale, MenuTranslations> = {
// View menu
view: "表示",
// Configuration menu
configuration: "設定",
switchPreset: "プリセット切り替え",
managePresets: "プリセット管理...",
addConfigurationPreset: "設定プリセットを追加...",
// Window menu
window: "ウインドウ",
@@ -70,6 +121,9 @@ const translations: Record<MenuLocale, MenuTranslations> = {
},
"zh-Hant": {
// App menu
settings: "設定...",
// File menu
file: "檔案",
@@ -79,6 +133,12 @@ const translations: Record<MenuLocale, MenuTranslations> = {
// View menu
view: "檢視",
// Configuration menu
configuration: "配置",
switchPreset: "切換預設",
managePresets: "管理預設...",
addConfigurationPreset: "新增配置預設...",
// Window menu
window: "視窗",
@@ -90,22 +150,33 @@ const translations: Record<MenuLocale, MenuTranslations> = {
}
/**
* Get menu translations for a locale (English for an unknown one)
* Get menu translations for a given locale
* Falls back to English if locale is not supported
*/
export function getMenuTranslations(locale: MenuLocale): MenuTranslations {
return translations[locale] ?? translations.en
export function getMenuTranslations(locale: string): MenuTranslations {
// Check for zh-Hant before normalizing
if (
locale === "zh-Hant" ||
locale.toLowerCase().startsWith("zh-hant") ||
locale.toLowerCase().startsWith("zh-tw") ||
locale.toLowerCase().startsWith("zh-hk")
) {
return translations["zh-Hant"]
}
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0]
if (normalized === "zh") return translations.zh
if (normalized === "ja") return translations.ja
return translations.en
}
/**
* Get locale from stored preference or system default
* Checks config file for user's language preference first
* Detect system locale from Electron app
* Returns one of: "en", "zh", "ja", "zh-Hant"
*/
export function getPreferredLocale(appLocale: string): MenuLocale {
const savedLocale = getUserLocale()
if (savedLocale) {
return savedLocale
}
export function detectSystemLocale(appLocale: string): MenuLocale {
const lower = appLocale.toLowerCase()
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
@@ -117,9 +188,24 @@ export function getPreferredLocale(appLocale: string): MenuLocale {
return "zh-Hant"
}
const language = lower.split("-")[0]
const normalized = lower.split("-")[0]
if (language === "zh") return "zh"
if (language === "ja") return "ja"
if (normalized === "zh") return "zh"
if (normalized === "ja") return "ja"
return "en"
}
/**
* Get locale from stored preference or system default
* Checks config file for user's language preference first
*/
export function getPreferredLocale(appLocale: string): MenuLocale {
// Try to get from saved preference first
const savedLocale = getUserLocale()
if (savedLocale) {
return savedLocale
}
// Fall back to system locale
return detectSystemLocale(appLocale)
}
+20 -6
View File
@@ -12,7 +12,7 @@ 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
// proxy changes) can't leave two servers running
// preset switches) can't leave two servers running
let serverQueue: Promise<unknown> = Promise.resolve()
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
@@ -21,6 +21,18 @@ function runExclusive<T>(task: () => Promise<T>): Promise<T> {
return result
}
/**
* Get the path to the standalone server resources
* In packaged app: resources/standalone
* In development: .next/standalone
*/
function getResourcePath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "standalone")
}
return path.join(app.getAppPath(), ".next", "standalone")
}
/**
* Wait for the server to be ready by polling the health endpoint
*/
@@ -49,8 +61,7 @@ export function startNextServer(): Promise<string> {
}
async function startServer(): Promise<string> {
// The standalone server shipped in the packaged app's resources
const resourcePath = path.join(process.resourcesPath, "standalone")
const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js")
console.log(`Starting Next.js server from: ${resourcePath}`)
@@ -64,7 +75,7 @@ async function startServer(): Promise<string> {
)
}
// Find an available port (the fixed ports 61337 / 13370, then fallbacks)
// Find an available port (random in production, fixed in development)
const port = await findAvailablePort()
console.log(`Using port: ${port}`)
@@ -75,7 +86,7 @@ async function startServer(): Promise<string> {
HOSTNAME: "127.0.0.1",
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1",
// The local server's env keys are the user's own, not a shared server's
// The preset keys are the user's own, not a server's
NEXT_AI_DRAWIO_DESKTOP: "1",
}
@@ -86,7 +97,10 @@ async function startServer(): Promise<string> {
// Set cache directory to a writable location (user's app data folder)
// This is necessary because the packaged app might be on a read-only volume
env.NEXT_CACHE_DIR = path.join(app.getPath("userData"), "cache")
if (app.isPackaged) {
const cacheDir = path.join(app.getPath("userData"), "cache")
env.NEXT_CACHE_DIR = cacheDir
}
// Copy existing environment variables
for (const [key, value] of Object.entries(process.env)) {
+22 -15
View File
@@ -9,6 +9,8 @@ import { app } from "electron"
* (localStorage is origin-specific, so changing ports loses all saved data)
*/
const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility
development: 6002,
// Legacy production port — tried first to preserve localStorage for existing users
legacyProduction: 61337,
// New production port below the ephemeral range (49152-65535)
@@ -106,8 +108,9 @@ export function isPortAvailable(port: number): Promise<boolean> {
/**
* Find an available port
* - Uses the legacy port (61337), then 13370, to preserve localStorage;
* 13370 first when only it has saved chats
* - In development: uses fixed port (6002)
* - 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)
*
@@ -115,7 +118,10 @@ export function isPortAvailable(port: number): Promise<boolean> {
* @returns Promise<number> The available port
*/
export async function findAvailablePort(reuseExisting = true): Promise<number> {
const preferredPort = PORT_CONFIG.production
const isDev = !app.isPackaged
const preferredPort = isDev
? PORT_CONFIG.development
: PORT_CONFIG.production
// Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) {
@@ -129,18 +135,19 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// 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 = readChatPort()
const candidates =
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]
// 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
+78
View File
@@ -0,0 +1,78 @@
import path from "node:path"
import { app, BrowserWindow, ipcMain } from "electron"
let settingsWindow: BrowserWindow | null = null
/**
* Create and show the settings window
*/
export function showSettingsWindow(parentWindow?: BrowserWindow): void {
// If settings window already exists, focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus()
return
}
// Determine path to settings preload script
// In compiled output: dist-electron/preload/settings.js
const preloadPath = path.join(__dirname, "..", "preload", "settings.js")
// Determine path to settings HTML
// In packaged app: app.asar/dist-electron/settings/index.html
// In development: electron/settings/index.html
const settingsHtmlPath = app.isPackaged
? path.join(__dirname, "..", "settings", "index.html")
: path.join(__dirname, "..", "..", "electron", "settings", "index.html")
settingsWindow = new BrowserWindow({
width: 600,
height: 700,
minWidth: 500,
minHeight: 500,
parent: parentWindow,
modal: false,
show: false,
title: "Settings - Next AI Draw.io",
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
settingsWindow.loadFile(settingsHtmlPath)
settingsWindow.once("ready-to-show", () => {
settingsWindow?.show()
})
settingsWindow.on("closed", () => {
settingsWindow = null
})
}
/**
* Close the settings window if it exists
*/
export function closeSettingsWindow(): void {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.close()
settingsWindow = null
}
}
/**
* Check if settings window is open
*/
export function isSettingsWindowOpen(): boolean {
return settingsWindow !== null && !settingsWindow.isDestroyed()
}
/**
* Register settings window IPC handlers
*/
export function registerSettingsWindowHandlers(): void {
ipcMain.on("settings:close", () => {
closeSettingsWindow()
})
}
+3 -3
View File
@@ -107,9 +107,9 @@ export function getAppUrl(): string | null {
/**
* Point the main window at the restarted app server (it can come up on a
* different port). On the same port the page is told so it can fetch the
* server's models again; it is not reloaded, which would drop unsent
* attachments.
* 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) {
+13 -1
View File
@@ -4,15 +4,27 @@ import { contextBridge, ipcRenderer } from "electron"
* Expose safe APIs to the renderer process
*/
contextBridge.exposeInMainWorld("electronAPI", {
// Platform information
platform: process.platform,
// Check if running in Electron
isElectron: true,
// Application version
getVersion: () => ipcRenderer.invoke("get-version"),
// Window controls (optional, for custom title bar)
minimize: () => ipcRenderer.send("window-minimize"),
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
ipcRenderer.invoke("set-proxy", config),
// User locale settings
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
setUserLocale: (locale: string) =>
ipcRenderer.invoke("set-user-locale", locale),
@@ -21,7 +33,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
chatSaved: () => ipcRenderer.invoke("chat-saved"),
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
// The server restarted on the same port (proxy change)
// The server restarted on the same port (another preset)
onServerRestarted: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on("server-restarted", listener)
+35
View File
@@ -0,0 +1,35 @@
/**
* Preload script for settings window
* Exposes APIs for managing configuration presets
*/
import { contextBridge, ipcRenderer } from "electron"
// Expose settings API to the renderer process
contextBridge.exposeInMainWorld("settingsAPI", {
// Get all presets
getPresets: () => ipcRenderer.invoke("config-presets:get-all"),
// Get current preset ID
getCurrentPresetId: () =>
ipcRenderer.invoke("config-presets:get-current-id"),
// Get current preset
getCurrentPreset: () => ipcRenderer.invoke("config-presets:get-current"),
// Save (create or update) a preset
savePreset: (preset: {
id?: string
name: string
config: Record<string, string | undefined>
}) => ipcRenderer.invoke("config-presets:save", preset),
// Delete a preset
deletePreset: (id: string) =>
ipcRenderer.invoke("config-presets:delete", id),
// Apply a preset (sets environment variables and restarts server)
applyPreset: (id: string) => ipcRenderer.invoke("config-presets:apply", id),
// Close settings window
close: () => ipcRenderer.send("settings:close"),
})
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self';">
<title>Settings - Next AI Draw.io</title>
<link rel="stylesheet" href="./settings.css">
</head>
<body>
<div class="container">
<div class="deprecation-notice">
<strong>⚠️ Deprecation Notice</strong>
<p>This settings panel will be removed in a future update.</p>
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
</div>
<h1>Configuration Presets</h1>
<div class="section">
<h2>Presets</h2>
<div id="preset-list" class="preset-list">
<!-- Presets will be loaded here -->
</div>
<button id="add-preset-btn" class="btn btn-primary">
+ Add New Preset
</button>
</div>
</div>
<!-- Add/Edit Preset Modal -->
<div id="preset-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Add Preset</h3>
</div>
<div class="modal-body">
<form id="preset-form">
<input type="hidden" id="preset-id">
<div class="form-group">
<label for="preset-name">Preset Name *</label>
<input type="text" id="preset-name" required placeholder="e.g., Work, Personal, Testing">
</div>
<div class="form-group">
<label for="ai-provider">AI Provider</label>
<select id="ai-provider">
<option value="">-- Select Provider --</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="google">Google AI (Gemini)</option>
<option value="azure">Azure OpenAI</option>
<option value="bedrock">AWS Bedrock</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
<option value="siliconflow">SiliconFlow</option>
<option value="modelscope">ModelScope</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>
<div class="form-group">
<label for="ai-model">Model ID</label>
<input type="text" id="ai-model" placeholder="e.g., gpt-4o, claude-sonnet-4-5">
<div class="hint">The model identifier to use with the selected provider</div>
</div>
<div class="form-group">
<label for="ai-api-key">API Key</label>
<input type="password" id="ai-api-key" placeholder="Your API key">
<div class="hint">This will be stored locally on your device</div>
</div>
<div class="form-group">
<label for="ai-base-url">Base URL (Optional)</label>
<input type="text" id="ai-base-url" placeholder="https://api.example.com/v1">
<div class="hint">Custom API endpoint URL</div>
</div>
<div class="form-group">
<label for="temperature">Temperature (Optional)</label>
<input type="text" id="temperature" placeholder="0.7">
<div class="hint">Controls randomness (0.0 - 2.0)</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" id="cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="save-btn" class="btn btn-primary">Save</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>Delete Preset</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete "<span id="delete-preset-name"></span>"?</p>
<p class="delete-warning">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" id="delete-cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="delete-confirm-btn" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="toast"></div>
<script src="./settings.js"></script>
</body>
</html>
+344
View File
@@ -0,0 +1,344 @@
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #e8e8e8;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--border-color: #e0e0e0;
--accent-color: #0066cc;
--accent-hover: #0052a3;
--danger-color: #dc3545;
--success-color: #28a745;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-hover: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #404040;
--accent-color: #4da6ff;
--accent-hover: #66b3ff;
}
}
.deprecation-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.deprecation-notice strong {
color: #856404;
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.deprecation-notice p {
color: #856404;
font-size: 13px;
margin: 4px 0;
}
@media (prefers-color-scheme: dark) {
.deprecation-notice {
background-color: #332701;
border-color: #665200;
}
.deprecation-notice strong,
.deprecation-notice p {
color: #ffc107;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
}
.container {
max-width: 560px;
margin: 0 auto;
padding: 24px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.section {
margin-bottom: 32px;
}
.preset-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.preset-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-card:hover {
background: var(--bg-hover);
}
.preset-card.active {
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.preset-name {
font-weight: 600;
font-size: 15px;
}
.preset-badge {
background: var(--accent-color);
color: white;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
.preset-info {
font-size: 13px;
color: var(--text-secondary);
}
.preset-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin-bottom: 16px;
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--bg-primary);
border-radius: 12px;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
}
.form-group .hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: var(--text-primary);
color: var(--bg-primary);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 200;
opacity: 0;
transition: opacity 0.3s ease;
}
.toast.show {
opacity: 1;
}
.toast.success {
background: var(--success-color);
color: white;
}
.toast.error {
background: var(--danger-color);
color: white;
}
/* Inline style replacements */
.delete-warning {
color: var(--text-secondary);
margin-top: 8px;
font-size: 14px;
}
+313
View File
@@ -0,0 +1,313 @@
// Settings page JavaScript
// This file handles the UI interactions for the settings window
let presets = []
let currentPresetId = null
let editingPresetId = null
let deletingPresetId = null
// DOM Elements
const presetList = document.getElementById("preset-list")
const addPresetBtn = document.getElementById("add-preset-btn")
const presetModal = document.getElementById("preset-modal")
const deleteModal = document.getElementById("delete-modal")
const presetForm = document.getElementById("preset-form")
const modalTitle = document.getElementById("modal-title")
const toast = document.getElementById("toast")
// Form fields
const presetIdField = document.getElementById("preset-id")
const presetNameField = document.getElementById("preset-name")
const aiProviderField = document.getElementById("ai-provider")
const aiModelField = document.getElementById("ai-model")
const aiApiKeyField = document.getElementById("ai-api-key")
const aiBaseUrlField = document.getElementById("ai-base-url")
const temperatureField = document.getElementById("temperature")
// Buttons
const cancelBtn = document.getElementById("cancel-btn")
const saveBtn = document.getElementById("save-btn")
const deleteCancelBtn = document.getElementById("delete-cancel-btn")
const deleteConfirmBtn = document.getElementById("delete-confirm-btn")
// Initialize
document.addEventListener("DOMContentLoaded", async () => {
await loadPresets()
setupEventListeners()
})
// Load presets from main process
async function loadPresets() {
try {
presets = await window.settingsAPI.getPresets()
currentPresetId = await window.settingsAPI.getCurrentPresetId()
renderPresets()
} catch (error) {
console.error("Failed to load presets:", error)
showToast("Failed to load presets", "error")
}
}
// Render presets list
function renderPresets() {
if (presets.length === 0) {
presetList.innerHTML = `
<div class="empty-state">
<p>No presets configured yet.</p>
<p>Add a preset to quickly switch between different AI configurations.</p>
</div>
`
return
}
presetList.innerHTML = presets
.map((preset) => {
const isActive = preset.id === currentPresetId
const providerLabel = getProviderLabel(preset.config.AI_PROVIDER)
return `
<div class="preset-card ${isActive ? "active" : ""}" data-id="${preset.id}">
<div class="preset-header">
<span class="preset-name">${escapeHtml(preset.name)}</span>
${isActive ? '<span class="preset-badge">Active</span>' : ""}
</div>
<div class="preset-info">
${providerLabel ? `Provider: ${providerLabel}` : "No provider configured"}
${preset.config.AI_MODEL ? ` • Model: ${escapeHtml(preset.config.AI_MODEL)}` : ""}
</div>
<div class="preset-actions">
${!isActive ? `<button class="btn btn-primary btn-sm apply-btn" data-id="${preset.id}">Apply</button>` : ""}
<button class="btn btn-secondary btn-sm edit-btn" data-id="${preset.id}">Edit</button>
<button class="btn btn-secondary btn-sm delete-btn" data-id="${preset.id}">Delete</button>
</div>
</div>
`
})
.join("")
// Add event listeners to buttons
presetList.querySelectorAll(".apply-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
applyPreset(btn.dataset.id)
})
})
presetList.querySelectorAll(".edit-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openEditModal(btn.dataset.id)
})
})
presetList.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation()
openDeleteModal(btn.dataset.id)
})
})
}
// Setup event listeners
function setupEventListeners() {
addPresetBtn.addEventListener("click", () => openAddModal())
cancelBtn.addEventListener("click", () => closeModal())
saveBtn.addEventListener("click", () => savePreset())
deleteCancelBtn.addEventListener("click", () => closeDeleteModal())
deleteConfirmBtn.addEventListener("click", () => confirmDelete())
// Close modal on overlay click
presetModal.addEventListener("click", (e) => {
if (e.target === presetModal) closeModal()
})
deleteModal.addEventListener("click", (e) => {
if (e.target === deleteModal) closeDeleteModal()
})
// Handle Enter key in form
presetForm.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault()
savePreset()
}
})
}
// Open add modal
function openAddModal() {
editingPresetId = null
modalTitle.textContent = "Add Preset"
presetForm.reset()
presetIdField.value = ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Open edit modal
function openEditModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
editingPresetId = id
modalTitle.textContent = "Edit Preset"
presetIdField.value = preset.id
presetNameField.value = preset.name
aiProviderField.value = preset.config.AI_PROVIDER || ""
aiModelField.value = preset.config.AI_MODEL || ""
aiApiKeyField.value = preset.config.AI_API_KEY || ""
aiBaseUrlField.value = preset.config.AI_BASE_URL || ""
temperatureField.value = preset.config.TEMPERATURE || ""
presetModal.classList.add("show")
presetNameField.focus()
}
// Close modal
function closeModal() {
presetModal.classList.remove("show")
editingPresetId = null
}
// Open delete modal
function openDeleteModal(id) {
const preset = presets.find((p) => p.id === id)
if (!preset) return
deletingPresetId = id
document.getElementById("delete-preset-name").textContent = preset.name
deleteModal.classList.add("show")
}
// Close delete modal
function closeDeleteModal() {
deleteModal.classList.remove("show")
deletingPresetId = null
}
// Save preset
async function savePreset() {
const name = presetNameField.value.trim()
if (!name) {
showToast("Please enter a preset name", "error")
presetNameField.focus()
return
}
const preset = {
id: editingPresetId || undefined,
name: name,
config: {
AI_PROVIDER: aiProviderField.value || undefined,
AI_MODEL: aiModelField.value.trim() || undefined,
AI_API_KEY: aiApiKeyField.value.trim() || undefined,
AI_BASE_URL: aiBaseUrlField.value.trim() || undefined,
TEMPERATURE: temperatureField.value.trim() || undefined,
},
}
// Remove undefined values
Object.keys(preset.config).forEach((key) => {
if (preset.config[key] === undefined) {
delete preset.config[key]
}
})
// closeModal() clears editingPresetId, so remember it for the toast
const isEdit = Boolean(editingPresetId)
try {
saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.savePreset(preset)
await loadPresets()
closeModal()
showToast(isEdit ? "Preset updated" : "Preset created", "success")
} catch (error) {
console.error("Failed to save preset:", error)
showToast("Failed to save preset", "error")
} finally {
saveBtn.disabled = false
saveBtn.textContent = "Save"
}
}
// Confirm delete
async function confirmDelete() {
if (!deletingPresetId) return
try {
deleteConfirmBtn.disabled = true
deleteConfirmBtn.innerHTML = '<span class="loading"></span>'
await window.settingsAPI.deletePreset(deletingPresetId)
await loadPresets()
closeDeleteModal()
showToast("Preset deleted", "success")
} catch (error) {
console.error("Failed to delete preset:", error)
showToast("Failed to delete preset", "error")
} finally {
deleteConfirmBtn.disabled = false
deleteConfirmBtn.textContent = "Delete"
}
}
// Apply preset
async function applyPreset(id) {
try {
const btn = presetList.querySelector(`.apply-btn[data-id="${id}"]`)
if (btn) {
btn.disabled = true
btn.innerHTML = '<span class="loading"></span>'
}
const result = await window.settingsAPI.applyPreset(id)
if (result.success) {
showToast("Preset applied, server restarting...", "success")
} else {
showToast(result.error || "Failed to apply preset", "error")
}
} 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()
}
}
// Get provider display label
function getProviderLabel(provider) {
const labels = {
openai: "OpenAI",
anthropic: "Anthropic",
google: "Google AI",
azure: "Azure OpenAI",
bedrock: "AWS Bedrock",
openrouter: "OpenRouter",
deepseek: "DeepSeek",
siliconflow: "SiliconFlow",
modelscope: "ModelScope",
ollama: "Ollama",
}
return labels[provider] || provider
}
// Show toast notification
function showToast(message, type = "") {
toast.textContent = message
toast.className = "toast show" + (type ? ` ${type}` : "")
setTimeout(() => {
toast.classList.remove("show")
}, 3000)
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
+5 -18
View File
@@ -8,7 +8,6 @@ AI_PROVIDER=bedrock
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
# The first one becomes the default and the rest appear in the model picker.
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
# There, apiKeyEnv may also be an array of env var names: each request picks one that has a value at random.
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
@@ -29,6 +28,8 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OpenAI Configuration
# OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: Custom OpenAI-compatible endpoint
# OPENAI_ORGANIZATION=org-... # Optional
# OPENAI_PROJECT=proj_... # Optional
# Note: o1/o3/gpt-5 models automatically enable reasoning summary (default: detailed)
# OPENAI_REASONING_EFFORT=low # Optional: Reasoning effort (minimal/low/medium/high) - for o1/o3/gpt-5
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
@@ -141,18 +142,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Quota & Validation (Optional, also editable in the admin panel)
# Quota enforcement is disabled unless DYNAMODB_QUOTA_TABLE is set.
# DYNAMODB_QUOTA_TABLE=next-ai-draw-io-quota # DynamoDB table that stores the per-IP counters
# DYNAMODB_REGION=ap-northeast-1 # Region of the table
# QUOTA_TIMEZONE=UTC # Timezone for the daily reset boundary
# DAILY_REQUEST_LIMIT=10 # Requests per IP per day
# DAILY_TOKEN_LIMIT=200000 # Tokens per IP per day
# TPM_LIMIT=20000 # Tokens per minute
# ENABLE_VLM_VALIDATION=true # Visually validate generated diagrams with a vision model
# VALIDATION_MODEL= # Falls back to the default AI model when empty
# VALIDATION_TIMEOUT=30000 # Validation timeout in ms
# Draw.io Configuration (Optional)
# By default the app serves its own copy of draw.io from /drawio (downloaded into
# public/drawio by `npm run dev` / `npm run build`). Same origin is required for
@@ -167,7 +156,9 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# NEXT_PUBLIC_BASE_PATH=/nextaidrawio
# PDF Input Feature (Optional)
# PDF file upload to extract text and generate diagrams
# Enable PDF file upload to extract text and generate diagrams
# Enabled by default. Set to "false" to disable.
# ENABLE_PDF_INPUT=true
# NEXT_PUBLIC_MAX_EXTRACTED_CHARS=150000 # Max characters for PDF/text extraction (default: 150000)
# Security Settings (Optional)
@@ -193,10 +184,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# It also turns off the MCP server advertisement in Settings > About.
# NEXT_PUBLIC_SELFHOSTED=true
# Analytics (Optional)
# Google Analytics measurement ID; nothing is loaded when unset
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# Minimax Configuration (Optional)
# Get your API key from: https://platform.minimaxi.com/docs/guides/models-intro
# MINIMAX_API_KEY=your_minimax_api_key
+108 -6
View File
@@ -1,4 +1,3 @@
import type { ChatAddToolOutputFunction } from "ai"
import type { RefObject } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import type {
@@ -6,7 +5,6 @@ import type {
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { LoadMode } from "@/contexts/diagram-context"
import type { ChatUIMessage } from "@/lib/chat-message"
import { pageModelXml, pageSelectorFor, placeOnPage } from "@/lib/diagram-pages"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
@@ -16,18 +14,57 @@ import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
const DEBUG = process.env.NODE_ENV === "development"
// The page a drawing is wrapped in; on the canvas it replaces the page the
// model works on, so the canvas page keeps its own id and name
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
/**
* A new diagram written without file variables (%name% placeholders) keeps
* the canvas file's, as replacing the page in draw.io does
*/
export function keepFileVars(xml: string, canvasXml: string): string {
const varsOf = (doc: Document) =>
doc.documentElement?.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
const parser = new DOMParser()
const canvasVars = varsOf(parser.parseFromString(canvasXml, "text/xml"))
if (!canvasVars) return xml
const doc = parser.parseFromString(xml, "text/xml")
if (doc.documentElement?.nodeName !== "mxfile" || varsOf(doc) !== null) {
return xml
}
doc.documentElement.setAttribute("vars", canvasVars)
return new XMLSerializer().serializeToString(doc)
}
interface ToolCall {
toolCallId: string
toolName: string
input: unknown
}
// useChat's addToolOutput, typed with the chat's tools
type AddToolOutputFn = ChatAddToolOutputFunction<ChatUIMessage>
type AddToolOutputSuccess = {
tool: string
toolCallId: string
state?: "output-available"
output: string
errorText?: undefined
}
type AddToolOutputError = {
tool: string
toolCallId: string
state: "output-error"
output?: undefined
errorText: string
}
type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3
@@ -120,6 +157,12 @@ export function useDiagramToolHandlers({
{ toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn,
) => {
if (DEBUG) {
console.log(
`[onToolCall] Tool: ${toolCall.toolName}, CallId: ${toolCall.toolCallId}`,
)
}
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,
@@ -161,9 +204,12 @@ export function useDiagramToolHandlers({
// A drawn page goes onto the model's page of the canvas file, which
// keeps its other pages; a drawing that brings several pages replaces
// the file. Either way it keeps the file's variables unless it sets its own.
// the file, and gets its variables
const onAiPage = (drawnXml: string) =>
placeOnPage(drawnXml, chartXMLRef.current, aiPageId())
keepFileVars(
placeOnPage(drawnXml, chartXMLRef.current, aiPageId()),
chartXMLRef.current,
)
const takeOriginals = (): string | undefined => {
const [originalXml] = editDiagramOriginalXmlRef.current.values()
@@ -182,8 +228,20 @@ export function useDiagramToolHandlers({
) => {
const { xml } = toolCall.input as { xml: string }
// DEBUG: Log raw input to diagnose false truncation detection
if (DEBUG) {
console.log(
"[display_diagram] XML ending (last 100 chars):",
xml.slice(-100),
)
console.log("[display_diagram] XML length:", xml.length)
}
// Check if XML is truncated (incomplete mxCell indicates truncated output)
const isTruncated = !isMxCellXmlComplete(xml)
if (DEBUG) {
console.log("[display_diagram] isTruncated:", isTruncated)
}
if (isTruncated) {
// Store the partial XML for continuation via append_diagram
@@ -240,6 +298,11 @@ NEXT STEP: Call append_diagram with the continuation XML.
// keeps the diagram from before this failed call
if (originalXml) onDisplayChart(originalXml, true, "revert")
// Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-error",
)
}
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
@@ -255,6 +318,12 @@ ${finalXml}
})
} else {
// Success - diagram will be rendered by chat-message-display
if (DEBUG) {
console.log(
"[display_diagram] Success! Checking if VLM validation is enabled...",
)
}
// VLM validation after successful display
if (
enableVlmValidation &&
@@ -279,6 +348,12 @@ ${finalXml}
if (stopped?.()) {
updateValidationState(toolCall.toolCallId, "skipped")
} else if (capturedPngData) {
if (DEBUG) {
console.log(
"[display_diagram] Captured PNG for validation",
)
}
// Each retry is a new tool call, so count attempts
// per user turn (the chat resets it when the user sends)
const attempt = validationRetryCountRef.current + 1
@@ -304,6 +379,12 @@ ${finalXml}
if (attempt < MAX_VALIDATION_RETRIES) {
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
// Notify UI of validation failure (include the image)
updateValidationState(
@@ -326,6 +407,11 @@ ${finalXml}
return
} else {
// Last attempt - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
// Notify UI that we're accepting with issues (include the image)
updateValidationState(
toolCall.toolCallId,
@@ -341,6 +427,12 @@ ${finalXml}
return
}
} else {
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
)
}
// Notify UI of success (include the image)
// Use "success_with_warnings" if valid but has issues
const hasWarnings = result.issues.length > 0
@@ -382,11 +474,21 @@ ${finalXml}
}
}
if (DEBUG) {
console.log(
"[display_diagram] Adding tool output with state: output-available",
)
}
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
if (DEBUG) {
console.log(
"[display_diagram] Tool output added. Diagram should be visible now.",
)
}
}
}
+2
View File
@@ -25,3 +25,5 @@ export function useDictionary() {
}
return dict
}
export default useDictionary
+95 -2
View File
@@ -18,6 +18,56 @@ import {
type ProviderName,
} from "@/lib/types/model-config"
// Old storage keys for migration
const OLD_KEYS = {
aiProvider: "next-ai-draw-io-ai-provider",
aiBaseUrl: "next-ai-draw-io-ai-base-url",
aiApiKey: "next-ai-draw-io-ai-api-key",
aiModel: "next-ai-draw-io-ai-model",
}
/**
* Migrate from old single-provider format to new multi-model format
*/
function migrateOldConfig(): MultiModelConfig | null {
if (typeof window === "undefined") return null
const oldProvider = localStorage.getItem(OLD_KEYS.aiProvider)
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
// No old config to migrate. A local Ollama needs no key.
if (!oldProvider || !oldModel) return null
if (!oldApiKey && oldProvider !== "ollama") return null
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
// Create new config from old format
const provider = createProviderConfig(oldProvider as ProviderName)
provider.apiKey = oldApiKey ?? ""
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
// Without one, the old version used the server's Ollama (OLLAMA_BASE_URL
// or this machine), not the cloud address new providers get
else if (oldProvider === "ollama") provider.baseUrl = ""
const model = createModelConfig(oldModel)
provider.models.push(model)
const config: MultiModelConfig = {
version: 1,
providers: [provider],
selectedModelId: model.id,
}
// Clear old keys after migration
localStorage.removeItem(OLD_KEYS.aiProvider)
localStorage.removeItem(OLD_KEYS.aiBaseUrl)
localStorage.removeItem(OLD_KEYS.aiApiKey)
localStorage.removeItem(OLD_KEYS.aiModel)
return config
}
const isKnownProvider = (p: { provider: string }) =>
Object.hasOwn(PROVIDER_INFO, p.provider)
@@ -46,6 +96,7 @@ function parseStoredConfig(stored: string): MultiModelConfig {
function loadConfig(): MultiModelConfig {
if (typeof window === "undefined") return createEmptyConfig()
// First, check if new format exists
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) {
try {
@@ -55,6 +106,17 @@ function loadConfig(): MultiModelConfig {
}
}
// Try migration from old format
const migrated = migrateOldConfig()
if (migrated) {
// Save migrated config
localStorage.setItem(
STORAGE_KEYS.modelConfigs,
JSON.stringify(migrated),
)
return migrated
}
return createEmptyConfig()
}
@@ -101,6 +163,7 @@ export interface UseModelConfigReturn {
// Getters
models: FlattenedModel[]
selectedModel: FlattenedModel | undefined
selectedModelId: string | undefined
// Actions
@@ -118,6 +181,7 @@ export interface UseModelConfigReturn {
updates: Partial<ModelConfig>,
) => void
deleteModel: (providerId: string, modelConfigId: string) => void
resetConfig: () => void
}
export function useModelConfig(): UseModelConfigReturn {
@@ -217,7 +281,7 @@ export function useModelConfig(): UseModelConfigReturn {
id: m.id,
modelId: m.modelId,
provider: m.provider,
providerLabel: m.providerLabel,
providerLabel: `Server · ${m.providerLabel}`,
apiKey: "",
baseUrl: undefined,
awsAccessKeyId: undefined,
@@ -227,11 +291,17 @@ export function useModelConfig(): UseModelConfigReturn {
validated: true,
source: "server" as const,
isDefault: m.isDefault,
apiKeyEnv: m.apiKeyEnv,
baseUrlEnv: m.baseUrlEnv,
})),
// User models from local configuration
...userModels,
]
const selectedModel = config.selectedModelId
? models.find((m) => m.id === config.selectedModelId)
: undefined
// Actions
const setSelectedModelId = useCallback((modelId: string | undefined) => {
setConfig((prev) => ({
@@ -356,10 +426,15 @@ export function useModelConfig(): UseModelConfigReturn {
[serverModels],
)
const resetConfig = useCallback(() => {
setConfig(createEmptyConfig())
}, [])
return {
config,
isLoaded: isLoaded && serverLoaded,
models,
selectedModel,
selectedModelId: config.selectedModelId,
setSelectedModelId,
addProvider,
@@ -368,6 +443,7 @@ export function useModelConfig(): UseModelConfigReturn {
addModel,
updateModel,
deleteModel,
resetConfig,
}
}
@@ -415,7 +491,24 @@ export function getSelectedAIConfig(): {
// Load multi-model config
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (!stored) return { ...empty, accessCode }
if (!stored) {
// Fallback to old format for backward compatibility
return {
accessCode,
aiProvider: localStorage.getItem(OLD_KEYS.aiProvider) || "",
aiBaseUrl: localStorage.getItem(OLD_KEYS.aiBaseUrl) || "",
aiApiKey: localStorage.getItem(OLD_KEYS.aiApiKey) || "",
aiModel: localStorage.getItem(OLD_KEYS.aiModel) || "",
// Old format didn't support AWS
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsRegion: "",
awsSessionToken: "",
selectedModelId: "",
vertexApiKey: "",
providerId: "",
}
}
let config: MultiModelConfig
try {
+19 -3
View File
@@ -12,6 +12,7 @@ import {
getAllSessionMetadata,
getSession,
isIndexedDBAvailable,
migrateFromLocalStorage,
readSessionCount,
renameSession as renameSessionInDB,
type SessionMetadata,
@@ -19,7 +20,10 @@ import {
saveSession,
} from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import type { DiagramVersion } from "@/stores/versions-store"
import {
type DiagramVersion,
versionsFromLegacyHistory,
} from "@/stores/versions-store"
export interface SessionData {
messages: StoredMessage[]
@@ -31,6 +35,13 @@ export interface SessionData {
title?: string
}
/** Versions of a stored session, converting the old history format */
export function getSessionVersions(
session: Pick<ChatSession, "versions" | "diagramHistory">,
): DiagramVersion[] {
return session.versions ?? versionsFromLegacyHistory(session.diagramHistory)
}
// Taken right before a save's data is read: the chat on screen then, and
// the order of the reads
export interface SaveTicket {
@@ -181,6 +192,9 @@ export function useSessionManager(
setIsAvailable(true)
try {
// Run migration first (one-time conversion from localStorage)
await migrateFromLocalStorage()
// Load sessions list
const metadata = await getAllSessionMetadata()
setSessions(metadata)
@@ -320,7 +334,7 @@ export function useSessionManager(
xmlSnapshots: session.xmlSnapshots,
diagramXml: session.diagramXml,
thumbnailDataUrl: session.thumbnailDataUrl,
versions: session.versions ?? [],
versions: getSessionVersions(session),
}
},
[currentSessionId, changeChat],
@@ -403,7 +417,9 @@ export function useSessionManager(
thumbnailDataUrl:
data.thumbnailDataUrl ??
currentSession.thumbnailDataUrl,
versions: data.versions ?? currentSession.versions ?? [],
versions:
data.versions ?? getSessionVersions(currentSession),
diagramHistory: undefined,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
+115 -45
View File
@@ -1,9 +1,10 @@
"use client"
/**
* Hook for VLM-based diagram validation: one request, one JSON result.
* Hook for VLM-based diagram validation using AI SDK's useObject.
*/
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react"
import { getSelectedAIConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
@@ -12,6 +13,8 @@ import {
ValidationResultSchema,
} from "@/lib/validation-schema"
export type { ValidationResult }
// Default valid result for fallback cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
@@ -19,56 +22,85 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
export function useValidateDiagram() {
// The running check's controller; a new check or cancel() aborts it
const controllerRef = useRef<AbortController | null>(null)
interface UseValidateDiagramOptions {
onSuccess?: (result: ValidationResult) => void
onError?: (error: Error) => void
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
reject: (error: Error) => void
}
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { onSuccess, onError } = options
const pendingValidationRef = useRef<PendingValidation | null>(null)
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
// Resolved per request so a changed access code is picked up
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
onFinish: ({
object,
error: finishError,
}: {
object: ValidationResult | undefined
error: Error | undefined
}) => {
if (finishError) {
console.error(
"[useValidateDiagram] Validation error:",
finishError,
)
onError?.(finishError)
pendingValidationRef.current?.reject(finishError)
pendingValidationRef.current = null
return
}
if (object) {
const result = object as ValidationResult
onSuccess?.(result)
pendingValidationRef.current?.resolve(result)
pendingValidationRef.current = null
}
},
onError: (err: Error) => {
console.error("[useValidateDiagram] Stream error:", err)
onError?.(err)
pendingValidationRef.current?.reject(err)
pendingValidationRef.current = null
},
})
/**
* Validate a diagram image, with fallback: a failed check gives the
* default valid result so it never blocks the user. A cancelled check
* is passed on as its AbortError.
* Validate a diagram image.
* Returns a promise that resolves with the validation result.
*/
const validateWithFallback = useCallback(
const validate = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const res = await fetch(
getApiEndpoint("/api/validate-diagram"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
// Read per request so a changed access code is
// picked up
"x-access-code": getSelectedAIConfig().accessCode,
},
body: JSON.stringify({ imageData, sessionId }),
signal: controller.signal,
},
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
if (!res.ok) {
throw new Error(`Validation request failed: ${res.status}`)
}
return ValidationResultSchema.parse(await res.json())
} catch (error) {
if ((error as Error)?.name === "AbortError") throw error
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
)
return DEFAULT_VALID_RESULT
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null
}
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers
pendingValidationRef.current = { resolve, reject }
// Submit the validation request
submit({ imageData, sessionId })
})
},
[],
[submit],
)
/**
@@ -76,9 +108,47 @@ export function useValidateDiagram() {
* an AbortError, so the tool handler can finish at once.
*/
const cancel = useCallback(() => {
controllerRef.current?.abort()
controllerRef.current = null
}, [])
const pending = pendingValidationRef.current
if (!pending) return
pendingValidationRef.current = null
stop()
pending.reject(new DOMException("Validation cancelled", "AbortError"))
}, [stop])
return { validateWithFallback, cancel }
/**
* 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 (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
try {
return await validate(imageData, sessionId)
} catch (error) {
if ((error as Error)?.name === "AbortError") throw error
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
)
return DEFAULT_VALID_RESULT
}
},
[validate],
)
return {
// Validation functions
validate,
validateWithFallback,
stop,
cancel,
// State
isValidating: isLoading,
partialResult: object as ValidationResult | undefined,
error,
}
}
+9 -3
View File
@@ -115,7 +115,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
}
}
type MaskedAdminProvider = Omit<
export type MaskedAdminProvider = Omit<
StoredAdminProvider,
(typeof SECRET_FIELDS)[number]
> & {
@@ -278,9 +278,15 @@ export function deriveEnvUpdates(
return updates
}
// Every settings key the panel may have written for a given list
// Every settings key the panel may have written for a given list.
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>(["AI_PROVIDER", "AI_MODEL"])
const keys = new Set<string>([
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
+9 -1
View File
@@ -10,7 +10,7 @@
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
type SettingType = "string" | "secret" | "number" | "boolean"
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export interface SettingDef {
key: string
@@ -18,6 +18,7 @@ export interface SettingDef {
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
@@ -122,6 +123,13 @@ export const SETTINGS_REGISTRY: SettingDef[] = [
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
+1 -1
View File
@@ -32,7 +32,7 @@ globalState.__adminSettingsState ??= {
}
const state = globalState.__adminSettingsState
function getSettingsPath(): string {
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
+118 -68
View File
@@ -30,7 +30,9 @@ import {
type ProviderName,
} from "@/lib/types/model-config"
const AIHUBMIX_APP_CODE = "MSBS9675"
export type { ProviderName }
export const AIHUBMIX_APP_CODE = "MSBS9675"
interface ModelConfig {
model: any
@@ -54,7 +56,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
* Normalize MiniMax base URL for AI SDK compatibility.
* MiniMax supports Anthropic-compatible and OpenAI-compatible endpoints.
*/
function normalizeMiniMaxBaseURL(rawUrl: string): {
export function normalizeMiniMaxBaseURL(rawUrl: string): {
baseURL: string
isAnthropicCompatible: boolean
} {
@@ -127,19 +129,21 @@ const BEDROCK_ANTHROPIC_BETA = {
* @param userApiKey - User-provided API key (if any)
* @param userBaseUrl - User-provided base URL (if any)
* @param serverBaseUrl - Server's base URL from environment variable
* @param defaultBaseUrl - Provider's official/default base URL (optional)
* @returns The resolved base URL to use
*/
export function resolveBaseURL(
userApiKey: string | null | undefined,
userBaseUrl: string | null | undefined,
serverBaseUrl: string | undefined,
defaultBaseUrl?: string,
): string | undefined {
if (userApiKey) {
// User provides their own API key - only use user's baseUrl
return userBaseUrl || undefined
// User provides their own API key - only use user's baseUrl or default
return userBaseUrl || defaultBaseUrl || undefined
}
// No user API key - fall back to server config
return userBaseUrl || serverBaseUrl || undefined
return userBaseUrl || serverBaseUrl || defaultBaseUrl || undefined
}
/**
@@ -240,39 +244,6 @@ function googleSamplingSettings(): { topK?: number; topP?: number } {
return settings
}
/**
* Gemini 2.5/3 models think by default, but need includeThoughts: true to
* return the reasoning. The budget applies to Gemini 2.5, the level to
* Gemini 3. Shared by Google AI and Vertex AI, which read different variables.
*/
function geminiThinkingOptions(
modelId: string | undefined,
budgetEnvName: string,
levelEnvName: string,
): { thinkingConfig: Record<string, any> } | undefined {
if (!modelId || !/gemini-?[23]/.test(modelId)) return undefined
const thinkingConfig: Record<string, any> = { includeThoughts: true }
const budget = parseIntSafe(
process.env[budgetEnvName],
budgetEnvName,
1024,
100000,
)
const level = process.env[levelEnvName] as
| "minimal"
| "low"
| "medium"
| "high"
| undefined
if (budget && (modelId.includes("2.5") || modelId.includes("2-5"))) {
thinkingConfig.thinkingBudget = budget
} else if (level && /gemini-?3/.test(modelId)) {
thinkingConfig.thinkingLevel = level
}
return { thinkingConfig }
}
/**
* Build provider-specific options from environment variables
* Supports various AI SDK providers with their unique configuration options
@@ -300,27 +271,41 @@ function buildProviderOptions(
switch (provider) {
case "openai": {
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT as
| "minimal"
| "low"
| "medium"
| "high"
| undefined
// Reasoning models (the o-series, gpt-5 and later) need
// reasoningSummary to return thoughts; 'auto' as the default
// since not all models support 'detailed'
const isReasoning =
!!modelId && /^(o\d|gpt-([5-9]|[1-9]\d))/.test(modelId)
const reasoningSummary =
(process.env.OPENAI_REASONING_SUMMARY as
| "auto"
| "detailed"
| undefined) || (isReasoning ? "auto" : undefined)
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
if (reasoningSummary || reasoningEffort) {
// 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 = {
...(reasoningSummary && { reasoningSummary }),
...(reasoningEffort && { reasoningEffort }),
// Auto-enable reasoning summary for reasoning models
// Use 'auto' as default since not all models support 'detailed'
reasoningSummary:
(reasoningSummary as "auto" | "detailed") || "auto",
}
// Optionally configure reasoning effort
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
} else if (reasoningEffort || reasoningSummary) {
// Non-reasoning models: only apply if explicitly configured
options.openai = {}
if (reasoningEffort) {
options.openai.reasoningEffort = reasoningEffort as
| "minimal"
| "low"
| "medium"
| "high"
}
if (reasoningSummary) {
options.openai.reasoningSummary = reasoningSummary as
| "auto"
| "detailed"
}
}
break
@@ -348,21 +333,85 @@ function buildProviderOptions(
}
case "google": {
const google = geminiThinkingOptions(
modelId,
const thinkingBudgetVal = parseIntSafe(
process.env.GOOGLE_THINKING_BUDGET,
"GOOGLE_THINKING_BUDGET",
"GOOGLE_THINKING_LEVEL",
1024,
100000,
)
if (google) options.google = google
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
// Google Gemini 2.5/3 models think by default, but need includeThoughts: true
// to return the reasoning in the response
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
// Optionally configure thinking budget or level
if (
thinkingBudgetVal &&
(modelId.includes("2.5") || modelId.includes("2-5"))
) {
thinkingConfig.thinkingBudget = thinkingBudgetVal
} else if (
thinkingLevel &&
(modelId.includes("gemini-3") ||
modelId.includes("gemini3"))
) {
thinkingConfig.thinkingLevel = thinkingLevel as
| "low"
| "high"
}
options.google = { thinkingConfig }
}
break
}
case "vertexai": {
const google = geminiThinkingOptions(
modelId,
const thinkingBudget = parseIntSafe(
process.env.GOOGLE_VERTEX_THINKING_BUDGET,
"GOOGLE_VERTEX_THINKING_BUDGET",
"GOOGLE_VERTEX_THINKING_LEVEL",
1024,
100000,
)
if (google) options.google = google
const thinkingLevel = process.env.GOOGLE_VERTEX_THINKING_LEVEL
if (
modelId &&
(modelId.includes("gemini-2") ||
modelId.includes("gemini-3") ||
modelId.includes("gemini2") ||
modelId.includes("gemini3"))
) {
const thinkingConfig: Record<string, any> = {
includeThoughts: true,
}
const isGemini3 =
modelId?.includes("gemini-3") ||
modelId?.includes("gemini3")
const isGemini25 =
modelId?.includes("2.5") || modelId?.includes("2-5")
if (isGemini3 && thinkingLevel) {
// Vertex AI provider in AI SDK supports more granular levels (minimal/low/medium/high)
thinkingConfig.thinkingLevel = thinkingLevel as
| "minimal"
| "low"
| "medium"
| "high"
} else if (isGemini25 && thinkingBudget) {
thinkingConfig.thinkingBudget = thinkingBudget
}
options.google = { thinkingConfig }
}
break
}
case "azure": {
@@ -449,7 +498,7 @@ function buildProviderOptions(
}
// Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY",
@@ -984,7 +1033,8 @@ export function getAIModel(clientOverrides?: ClientOverrides): ModelConfig {
// 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.
// 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
@@ -1153,7 +1203,7 @@ export function usesServerCredentials(
provider: ProviderName,
overrides?: ClientOverrides,
): boolean {
// The desktop app's local server only has the user's own keys (its .env)
// 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 ?? "")
+3 -2
View File
@@ -892,9 +892,9 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
},
]
// The chat panel answers a built-in example from here, without a request.
// 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,
@@ -904,6 +904,7 @@ export function findCachedResponse(
(c) =>
c.promptText === promptText &&
c.hasImage === hasImage &&
(!c.fileName || c.fileName === fileName),
(!c.fileName || c.fileName === fileName) &&
c.xml !== "",
)
}
+29
View File
@@ -58,6 +58,35 @@ 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)
// 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" &&
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
})
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).
-44
View File
@@ -1,44 +0,0 @@
import type { InferUITools, UIMessage } from "ai"
import type { chatTools } from "@/lib/chat-tools"
import type { SelectedCell } from "@/stores/canvas-store"
/** Shapes sent with a user message (also kept in its metadata) */
export type MessageSelection = Pick<SelectedCell, "id" | "label">[]
export interface ChatMessageMetadata {
selectedCells?: MessageSelection
}
/**
* A failed request or an answer that drew nothing, shown in the chat with
* what to do: an assistant message with this as its only part. The server
* ignores data parts, so it never reaches the model.
*/
export interface ChatNotice {
text: string
/** Show a button that opens model settings, on the provider that refused */
openModelConfig?: boolean
providerId?: string
}
export type ChatToolName = keyof InferUITools<typeof chatTools>
export type ChatUIMessage = UIMessage<
ChatMessageMetadata,
{ notice: ChatNotice },
InferUITools<typeof chatTools>
>
export function noticeMessage(notice: ChatNotice): ChatUIMessage {
return {
id: `notice-${Date.now()}`,
role: "assistant",
parts: [{ type: "data-notice", data: notice }],
}
}
/** The notice a message carries, if it is one */
export function noticeOf(message: ChatUIMessage): ChatNotice | undefined {
const part = message.parts.find((p) => p.type === "data-notice")
return part?.data
}
-138
View File
@@ -1,138 +0,0 @@
import { type InferUITools, tool } from "ai"
import path from "path"
import { z } from "zod"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import {
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
/**
* The chat's tools. display_diagram, edit_diagram and append_diagram run in
* the browser: the chat engine's tool handler puts their result on the
* canvas and reports it back as text, so they have an output schema and no
* execute. get_shape_library runs on the server.
*/
export const chatTools = {
display_diagram: tool({
description: `Display a diagram on draw.io. Pass ONLY the mxStyle definitions and the mxCell elements - wrapper tags and root cells are added automatically.
VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY mxStyle definitions and mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
3. All mxCell elements must be siblings - never nested
4. Every mxCell needs a unique id (start from "2")
5. parent defaults to "1"; write parent="<container-id>" only for shapes inside a container
6. Escape special chars in values: &lt; &gt; &amp; &quot;
A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target (a cell with source or target is always an edge). vertex="1", edge="1", parent="1" and the mxGeometry element are added automatically. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
Example (generate ONLY this - no wrapper tags):
${SWIMLANE_EXAMPLE}
Styles: define a style used by several cells ONCE with <mxStyle name="..." value="..."/> before the cells and use the name in style like a CSS class; overrides after the name win. Name only styles that two or more cells share; names must not be draw.io's own style names (text, ellipse, rhombus, swimlane, label, image, blue, green, red, gray, yellow, orange, purple, pink). A definition applies to the call it is in. html=1 and whiteSpace=wrap are added automatically, never write them. Labels are HTML: use &lt;br&gt; for a line break, never \\n; a literal < or > is written &amp;lt; or &amp;gt;.
${STYLE_CLASS_EXAMPLE}
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
- For animated connectors, add "flowAnimation=1" to edge style.
`,
inputSchema: z.object({
xml: z.string().describe("XML string to be displayed on draw.io"),
}),
outputSchema: z.string(),
}),
edit_diagram: tool({
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
Operations:
- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.
- add: Add a new cell. Provide cell_id (new unique id) and new_xml.
- delete: Remove a cell. Cascade is automatic: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
For update/add, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target), with its complete style (named styles are not available in edit_diagram).
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
Example - Delete container (children & edges auto-deleted):
{"operations": [{"operation": "delete", "cell_id": "2"}]}`,
inputSchema: z.object({
operations: z
.array(
z.object({
operation: z
.enum(["update", "add", "delete"])
.describe(
"Operation to perform: add, update, or delete",
),
cell_id: z
.string()
.describe(
"The id of the mxCell. Must match the id attribute in new_xml.",
),
new_xml: z
.string()
.optional()
.describe(
"Complete mxCell XML element (required for update/add)",
),
}),
)
.describe("Array of operations to apply"),
}),
outputSchema: z.string(),
}),
append_diagram: tool({
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
CRITICAL INSTRUCTIONS:
1. Do NOT include any wrapper tags - just continue the mxCell elements
2. Continue from EXACTLY where your previous output stopped
3. Complete the remaining mxCell elements
4. If still truncated, call append_diagram again with the next fragment
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" x="40" y="40" w="120" h="60"/>' and complete the remaining elements.`,
inputSchema: z.object({
xml: z
.string()
.describe(
"Continuation XML fragment to append (NO wrapper tags)",
),
}),
outputSchema: z.string(),
}),
get_shape_library: tool({
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
${SHAPE_LIBRARY_LIST}
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
library: z
.string()
.describe(
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
),
}),
execute: async ({ library }) => {
// Only known library names reach the file system
const result = await getShapeLibrary(
library,
path.join(process.cwd(), "docs/shape-libraries"),
)
return result.ok ? result.text : result.error
},
}),
}
/** The tool names and their input and output types, for the UI messages */
export type ChatTools = InferUITools<typeof chatTools>
+15 -10
View File
@@ -1,9 +1,4 @@
import {
fileVars,
modelOfPage,
pageElement,
parseXml,
} from "@/lib/diagram-pages"
import { modelOfPage, pageElement } from "@/lib/diagram-pages"
/** What changed on one page between two versions of a diagram */
export interface ChangeSummary {
@@ -37,12 +32,18 @@ interface CellInfo {
signature: string
}
function parse(xml: string): Document | null {
if (!xml?.trim()) return null
const doc = new DOMParser().parseFromString(xml, "text/xml")
return doc.querySelector("parsererror") ? null : doc
}
/**
* A page's mxGraphModel element, whatever wrapper the XML has: the page
* with this id, else the first
*/
function pageModel(xml: string, pageId?: string | null): Element | null {
const doc = parseXml(xml)
const doc = parse(xml)
if (!doc) return null
const diagram = pageElement(doc, pageId)
if (diagram) return modelOfPage(diagram)
@@ -303,10 +304,14 @@ export function isSameDocument(a: string, b: string): boolean {
model: modelOfPage(d),
}))
}
const docA = parseXml(a)
const docB = parseXml(b)
const docA = parse(a)
const docB = parse(b)
if (!docA || !docB) return a === b
if (!sameFileVars(fileVars(docA), fileVars(docB))) return false
const varsOf = (doc: Document) =>
doc.documentElement.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
if (!sameFileVars(varsOf(docA), varsOf(docB))) return false
const pagesA = pagesOf(docA)
const pagesB = pagesOf(docB)
if (pagesA.length !== pagesB.length) return false
+17 -35
View File
@@ -12,20 +12,12 @@ import {
type PageSelector,
} from "@/packages/mcp-server/src/pages.ts"
/** The parsed XML, or null when it is empty or does not parse */
export function parseXml(xml: string): Document | null {
function parse(xml: string): Document | null {
if (!xml?.trim()) return null
const doc = new DOMParser().parseFromString(xml, "text/xml")
return doc.querySelector("parsererror") ? null : doc
}
/** The file variables (%name% placeholders) an <mxfile> sets, else null */
export function fileVars(doc: Document): string | null {
return doc.documentElement.nodeName === "mxfile"
? doc.documentElement.getAttribute("vars")
: null
}
/** The <diagram> the AI works on: the one with this id, else the first */
export function pageElement(
doc: Document,
@@ -48,7 +40,7 @@ export function modelOfPage(diagram: Element): Element | null {
if (model) return model
const inflated = decompressPageContent(diagram.textContent || "")
if (!inflated) return null
return parseXml(inflated)?.querySelector("mxGraphModel") ?? null
return parse(inflated)?.querySelector("mxGraphModel") ?? null
}
/**
@@ -60,7 +52,7 @@ export function pageSelectorFor(
pageId: string | null | undefined,
): PageSelector {
if (!pageId) return {}
const doc = parseXml(xml)
const doc = parse(xml)
const found = doc
? Array.from(doc.getElementsByTagName("diagram")).some(
(diagram) => diagram.getAttribute("id") === pageId,
@@ -77,7 +69,7 @@ export function pageModelXml(
xml: string,
pageId: string | null | undefined,
): string | null {
const doc = parseXml(xml)
const doc = parse(xml)
if (!doc) return null
const root = doc.documentElement
if (root.nodeName === "mxGraphModel") return xml
@@ -98,15 +90,15 @@ export function replacePageModel(
pageId: string | null | undefined,
modelXml: string,
): string {
const model = parseXml(modelXml)
const model = parse(modelXml)
if (model?.documentElement.nodeName !== "mxGraphModel") {
throw new Error("replacePageModel needs an <mxGraphModel>")
}
let doc = parseXml(fileXml)
if (doc?.documentElement.nodeName !== "mxfile") doc = parseXml(BLANK_MXFILE)
let doc = parse(fileXml)
if (doc?.documentElement.nodeName !== "mxfile") doc = parse(BLANK_MXFILE)
let diagram = pageElement(doc as Document, pageId)
if (!diagram) {
doc = parseXml(BLANK_MXFILE)
doc = parse(BLANK_MXFILE)
diagram = pageElement(doc as Document, null)
}
const target = diagram as Element
@@ -120,37 +112,27 @@ export function replacePageModel(
/**
* Where a drawn diagram goes. One page's worth (bare cells wrapped by
* prepareNewDiagram, or a one-page file) replaces the AI's page of the
* canvas file. A file with several pages replaces the whole document: the
* model wrote the pages on purpose. Either way, file variables the drawn
* file sets replace the canvas file's, and a drawn file without any keeps
* the canvas file's (as replacing the page in draw.io does).
* canvas file; file variables the drawn file sets replace the canvas
* file's. A file with several pages replaces the whole document: the model
* wrote the pages on purpose.
*/
export function placeOnPage(
drawnXml: string,
canvasXml: string,
pageId: string | null | undefined,
): string {
const doc = parseXml(drawnXml)
const doc = parse(drawnXml)
const drawnFile =
doc?.documentElement.nodeName === "mxfile" ? doc.documentElement : null
const severalPages =
(drawnFile?.getElementsByTagName("diagram").length ?? 0) > 1
const model = severalPages ? null : pageModelXml(drawnXml, null)
if (!model) {
// The drawn document as it is, with the canvas file's variables
// when it sets none itself
const canvas = parseXml(canvasXml)
const canvasVars = canvas ? fileVars(canvas) : null
if (!doc || !drawnFile || fileVars(doc) !== null || !canvasVars) {
return drawnXml
}
drawnFile.setAttribute("vars", canvasVars)
return new XMLSerializer().serializeToString(doc)
if (drawnFile && drawnFile.getElementsByTagName("diagram").length > 1) {
return drawnXml
}
const model = pageModelXml(drawnXml, null)
if (!model) return drawnXml
const placed = replacePageModel(canvasXml, pageId, model)
const vars = drawnFile?.getAttribute("vars")
if (vars === null || vars === undefined) return placed
const result = parseXml(placed) as Document
const result = parse(placed) as Document
result.documentElement.setAttribute("vars", vars)
return new XMLSerializer().serializeToString(result)
}
+2 -2
View File
@@ -1,10 +1,10 @@
/**
* Types and utilities for VLM-based diagram validation.
* The validation itself runs in the useValidateDiagram hook (a JSON request to /api/validate-diagram).
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationResult } from "./validation-schema"
export type { ValidationIssue, ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
+13
View File
@@ -666,6 +666,18 @@ function readSelectionRect() {
}
}
// ---------------------------------------------------------------------------
// Pages
// ---------------------------------------------------------------------------
function readPages() {
const pages = (ui?.pages ?? []) as any[]
return pages.map((page, index) => ({
id: String(page.getId?.() ?? index),
name: String(page.getName?.() ?? `Page-${index + 1}`),
}))
}
// ---------------------------------------------------------------------------
// Keeping the store in sync
// ---------------------------------------------------------------------------
@@ -678,6 +690,7 @@ function syncAll() {
if (!ui) return
const g = graph()
useCanvasStore.getState().set({
pages: readPages(),
currentPageId: ui.currentPage?.getId
? String(ui.currentPage.getId())
: null,
+62 -46
View File
@@ -1,6 +1,7 @@
import {
ConditionalCheckFailedException,
DynamoDBClient,
GetItemCommand,
UpdateItemCommand,
} from "@aws-sdk/client-dynamodb"
@@ -107,61 +108,76 @@ export async function checkAndIncrementRequest(
":tokenLimit": { N: String(limits.tokens || 999999) },
":tpmLimit": { N: String(limits.tpm || 999999) },
},
// On a failed condition the exception carries the item as it
// was, so no second read is needed to tell which limit was hit
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
}),
)
return { allowed: true }
} catch (e: any) {
// Condition failed - the exception's Item holds the current counts
// Condition failed - need to determine which limit was exceeded
if (e instanceof ConditionalCheckFailedException) {
const item = e.Item
const storedMinute = item?.lastMinute?.S
// Get current counts to determine which limit was hit
try {
const getResult = await client.send(
new GetItemCommand({
TableName: TABLE,
Key: {
PK: { S: pk },
SK: { S: sk },
},
}),
)
const reqCount = Number(item?.reqCount?.N || 0)
const tokenCount = Number(item?.tokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
const item = getResult.Item
const storedMinute = item?.lastMinute?.S
// Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
limit: limits.requests,
}
}
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
const reqCount = Number(item?.reqCount?.N || 0)
const tokenCount = Number(item?.tokenCount?.N || 0)
const tpmCount =
storedMinute !== currentMinute
? 0
: Number(item?.tpmCount?.N || 0)
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
// Determine which limit was exceeded
if (limits.requests > 0 && reqCount >= limits.requests) {
return {
allowed: false,
type: "request",
error: "Daily request limit exceeded",
used: reqCount,
limit: limits.requests,
}
}
if (limits.tokens > 0 && tokenCount >= limits.tokens) {
return {
allowed: false,
type: "token",
error: "Daily token limit exceeded",
used: tokenCount,
limit: limits.tokens,
}
}
if (limits.tpm > 0 && tpmCount >= limits.tpm) {
return {
allowed: false,
type: "tpm",
error: "Rate limit exceeded (tokens per minute)",
used: tpmCount,
limit: limits.tpm,
}
}
// Condition failed but no limit clearly exceeded - race condition edge case
// Fail safe by allowing (could be a TPM reset race)
console.warn(
`[quota] Condition failed but no limit exceeded for IP prefix: ${ip.slice(0, 8)}...`,
)
return { allowed: true }
} catch (getError: any) {
console.error(
`[quota] Failed to get quota details after condition failure, IP prefix: ${ip.slice(0, 8)}..., error: ${getError.message}`,
)
return { allowed: true } // Fail open
}
}
// Other DynamoDB errors - fail open
+90 -1
View File
@@ -3,25 +3,48 @@
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"clear": "Clear",
"edit": "Edit",
"delete": "Delete",
"loading": "Loading.."
},
"nav": {
"editor": "Editor",
"newChat": "New diagram",
"github": "GitHub",
"settings": "Settings",
"hidePanel": "Hide chat panel",
"showPanel": "Show chat panel",
"aiChat": "AI chat"
},
"providers": {
"useServerDefault": "Use Server Default",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
"inputLabel": "Chat input",
"send": "Send",
"stopGeneration": "Stop generation",
"sendMessage": "Send message",
"clearConversation": "Clear conversation",
"uploadFile": "Upload file (image, PDF, text)",
"minimalStyle": "Minimal",
"styledMode": "Styled",
"minimalTooltip": "Use minimal for faster generation (no colors)",
"regenerate": "Regenerate response",
"copyResponse": "Copy response",
"copied": "Copied!",
@@ -59,6 +82,17 @@
"accessCode": "Access code",
"accessCodePlaceholder": "Enter access code",
"accessCodeDescription": "Required to use this application.",
"aiProvider": "AI Provider Settings",
"aiProviderDescription": "Use your own API key to bypass usage limits. Your key is stored locally in your browser and is never stored on the server.",
"provider": "Provider",
"modelId": "Model ID",
"apiKey": "API Key",
"apiKeyPlaceholder": "Your API key",
"baseUrl": "Base URL (optional)",
"customEndpoint": "Custom endpoint URL",
"overrides": "Overrides",
"clearSettings": "Clear Settings",
"useServerDefault": "Use Server Default",
"language": "Language",
"languageDescription": "Choose your interface language.",
"theme": "Theme",
@@ -69,6 +103,10 @@
"sendShortcutDescription": "Choose how to send messages.",
"enterToSend": "Enter to send",
"ctrlEnterToSend": "Cmd/Ctrl+Enter to send",
"diagramActions": "Diagram Actions",
"diagramActionsDescription": "Manage diagram history and exports",
"history": "History",
"download": "Download",
"proxy": "Proxy settings",
"proxyDescription": "Configure HTTP/HTTPS proxy for API requests (Desktop only)",
"httpProxy": "HTTP proxy",
@@ -132,9 +170,13 @@
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
"failedToPersist": "Failed to persist state before unload",
"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",
"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",
@@ -166,6 +208,8 @@
"quota": {
"dailyLimit": "Daily Quota Reached",
"tokenLimit": "Daily Token Limit Reached",
"tpmLimit": "Rate Limit",
"tpmMessage": "Too many requests. Please wait a moment.",
"tpmMessageDetailed": "Rate limit reached ({limit} tokens/min). Please wait {seconds} seconds before sending another request.",
"messageApi": "Looks like you've reached today's demo limit. We're glad you're enjoying it; the limit keeps the demo fair for everyone.",
"messageApiSelfHosted": null,
@@ -177,6 +221,7 @@
"configModel": "Use Your API Key",
"selfHost": "Self-host",
"sponsor": "Sponsor",
"learnMore": "Learn more →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -203,8 +248,37 @@
"thoughtForOne": "Thought for 1 second",
"thoughtBrief": "Thought for a few seconds"
},
"dev": {
"title": "Dev: XML Streaming Simulator",
"preset": "Preset:",
"selectPreset": "Select a preset...",
"clear": "Clear",
"placeholder": "Paste mxCell XML here or select a preset...",
"interval": "Interval:",
"chars": "Chars:",
"streaming": "Streaming...",
"simulate": "Simulate",
"stop": "Stop",
"testQuotaToast": "Test Quota Toast",
"simulatingMessage": "[Dev] Simulating XML streaming",
"successMessage": "Successfully displayed the diagram."
},
"about": {
"modelChange": "Model Change & Usage Limits",
"walletCrying": "(Or: Why My Wallet is Crying)",
"seekingSponsorship": "Call for Sponsorship",
"contactMe": "Contact Me",
"usageNotice": "Due to high usage, I have changed the model from Claude to minimax-m2 and added some usage limits. See About page for details."
},
"sessionHistory": {
"tooltip": "Chat History",
"newChat": "New Chat",
"empty": "No diagrams yet",
"emptyHint": "Start a conversation to begin",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"earlier": "Earlier",
"deleteTitle": "Delete this diagram?",
"deleteDescription": "The diagram and its conversation are deleted for good.",
"justNow": "Just now",
@@ -277,13 +351,20 @@
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": {
"configure": "Configure",
"addProvider": "Add provider",
"addModel": "Add Model",
"modelId": "Model ID",
"modelLabel": "Display Label",
"streaming": "Enable Streaming",
"deleteProvider": "Delete provider",
"deleteModel": "Delete Model",
"noModels": "No models configured. Add a model to get started.",
"validationError": "The test failed",
"addModelFirst": "Add a model first.",
"displayName": "Display Name",
"bedrockApiKey": "Bedrock API key",
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
"awsAccessKeyId": "AWS Access Key ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS Region",
@@ -291,6 +372,7 @@
"apiKey": "API key",
"enterApiKey": "Enter your API key",
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"getApiKey": "Get API key",
"noTools": "no tool calls",
@@ -306,6 +388,8 @@
"modelIdEmpty": "Model ID cannot be empty",
"modelIdExists": "This model ID already exists",
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
"typeToConfirm": "Type \"{name}\" to confirm",
"typeProviderName": "Type the provider name...",
"requestFailed": "Request failed ({status})",
"cancel": "Cancel",
"delete": "Delete",
@@ -499,6 +583,10 @@
"VALIDATION_TIMEOUT": {
"label": "Check Timeout (ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "Trim Old Diagrams",
"description": "Old diagrams in the chat history are kept as a short marker"
},
"ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs",
"description": "When off, custom model URLs can't point to private networks; safer"
@@ -609,7 +697,8 @@
"lede": "Describe a diagram or upload a screenshot or PDF. You get a draw.io diagram you can keep editing and export.",
"placeholder": "For example: a login sequence diagram with SMS codes and retries",
"recent": "Recent diagrams",
"drawYourself": "Start with a blank canvas"
"drawYourself": "Start with a blank canvas",
"openFileHint": "Keep working on a diagram you have"
},
"shell": {
"waiting": "Waiting for the MCP server…",
+90 -1
View File
@@ -3,25 +3,48 @@
"save": "保存",
"cancel": "キャンセル",
"close": "閉じる",
"clear": "クリア",
"edit": "編集",
"delete": "削除",
"loading": "読み込み中.."
},
"nav": {
"editor": "エディタ",
"newChat": "新しい図",
"github": "GitHub",
"settings": "設定",
"hidePanel": "チャットパネルを非表示",
"showPanel": "チャットパネルを表示",
"aiChat": "AI チャット"
},
"providers": {
"useServerDefault": "サーバーデフォルトを使用",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
"inputLabel": "チャット入力",
"send": "送信",
"stopGeneration": "生成を停止",
"sendMessage": "メッセージを送信",
"clearConversation": "会話をクリア",
"uploadFile": "ファイルをアップロード(画像、PDF、テキスト)",
"minimalStyle": "ミニマル",
"styledMode": "スタイル付き",
"minimalTooltip": "高速生成のためミニマルを使用(色なし)",
"regenerate": "応答を再生成",
"copyResponse": "応答をコピー",
"copied": "コピーしました!",
@@ -59,6 +82,17 @@
"accessCode": "アクセスコード",
"accessCodePlaceholder": "アクセスコードを入力",
"accessCodeDescription": "このアプリケーションを使用するために必要です。",
"aiProvider": "AI プロバイダー設定",
"aiProviderDescription": "独自の API キーを使用して使用制限を回避できます。キーはブラウザのローカルに保存され、サーバーには保存されません。",
"provider": "プロバイダー",
"modelId": "モデル ID",
"apiKey": "API キー",
"apiKeyPlaceholder": "あなたの API キー",
"baseUrl": "ベース URL(オプション)",
"customEndpoint": "カスタムエンドポイント URL",
"overrides": "上書き",
"clearSettings": "設定をクリア",
"useServerDefault": "サーバーデフォルトを使用",
"language": "言語",
"languageDescription": "インターフェース言語を選択します。",
"theme": "テーマ",
@@ -69,6 +103,10 @@
"sendShortcutDescription": "メッセージの送信方法を選択します。",
"enterToSend": "Enterで送信",
"ctrlEnterToSend": "Cmd/Ctrl+Enterで送信",
"diagramActions": "ダイアグラム操作",
"diagramActionsDescription": "ダイアグラムの履歴とエクスポートを管理",
"history": "履歴",
"download": "ダウンロード",
"proxy": "プロキシ設定",
"proxyDescription": "API リクエスト用の HTTP/HTTPS プロキシを設定(デスクトップ版のみ)",
"httpProxy": "HTTP プロキシ",
@@ -132,9 +170,13 @@
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
"failedToPersist": "アンロード前の状態の永続化に失敗しました",
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
@@ -166,6 +208,8 @@
"quota": {
"dailyLimit": "1日の割当量に達しました",
"tokenLimit": "1日のトークン制限に達しました",
"tpmLimit": "レート制限",
"tpmMessage": "リクエストが多すぎます。しばらくお待ちください。",
"tpmMessageDetailed": "レート制限に達しました({limit}トークン/分)。{seconds}秒待ってからもう一度リクエストしてください。",
"messageApi": "今日のデモ利用上限に達したようです。楽しんでいただけて嬉しいです。皆様に公平にご利用いただくため、利用量に上限を設けています。",
"messageApiSelfHosted": null,
@@ -177,6 +221,7 @@
"configModel": "APIキーを使用",
"selfHost": "セルフホスト",
"sponsor": "スポンサー",
"learnMore": "詳細 →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -203,8 +248,37 @@
"thoughtForOne": "1 秒考えました",
"thoughtBrief": "数秒考えました"
},
"dev": {
"title": "開発:XMLストリーミングシミュレーター",
"preset": "プリセット:",
"selectPreset": "プリセットを選択...",
"clear": "クリア",
"placeholder": "ここに mxCell XML を貼り付けるか、プリセットを選択...",
"interval": "間隔:",
"chars": "文字:",
"streaming": "ストリーミング中...",
"simulate": "シミュレート",
"stop": "停止",
"testQuotaToast": "クォータトーストをテスト",
"simulatingMessage": "[開発] XMLストリーミングをシミュレート中",
"successMessage": "ダイアグラムの表示に成功しました。"
},
"about": {
"modelChange": "モデル変更と利用制限について",
"walletCrying": "(別名:お財布が悲鳴を上げています)",
"seekingSponsorship": "スポンサー募集",
"contactMe": "お問い合わせ",
"usageNotice": "利用量の増加に伴い、コスト削減のためモデルを Claude から minimax-m2 に変更し、いくつかの利用制限を設けました。詳細は概要ページをご覧ください。"
},
"sessionHistory": {
"tooltip": "チャット履歴",
"newChat": "新しいチャット",
"empty": "まだ図はありません",
"emptyHint": "会話を始めてください",
"today": "今日",
"yesterday": "昨日",
"thisWeek": "今週",
"earlier": "それ以前",
"deleteTitle": "この図を削除しますか?",
"deleteDescription": "図とその会話は完全に削除されます。",
"justNow": "たった今",
@@ -232,13 +306,20 @@
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": {
"configure": "設定",
"addProvider": "プロバイダーを追加",
"addModel": "モデルを追加",
"modelId": "モデルID",
"modelLabel": "表示名",
"streaming": "ストリーミングを有効",
"deleteProvider": "プロバイダーを削除",
"deleteModel": "モデルを削除",
"noModels": "モデルが設定されていません。モデルを追加してください。",
"validationError": "テストに失敗しました",
"addModelFirst": "先にモデルを追加してください。",
"displayName": "表示名",
"bedrockApiKey": "Bedrock API キー",
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
"awsAccessKeyId": "AWS アクセスキー ID",
"awsSecretAccessKey": "AWS シークレットアクセスキー",
"awsRegion": "AWS リージョン",
@@ -246,6 +327,7 @@
"apiKey": "API キー",
"enterApiKey": "API キーを入力",
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"getApiKey": "API キーを取得",
"noTools": "ツール呼び出し非対応",
@@ -261,6 +343,8 @@
"modelIdEmpty": "モデル ID は空にできません",
"modelIdExists": "このモデル ID は既に存在します",
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
"typeToConfirm": "確認のため「{name}」と入力",
"typeProviderName": "プロバイダー名を入力...",
"requestFailed": "リクエストに失敗しました({status})",
"cancel": "キャンセル",
"delete": "削除",
@@ -499,6 +583,10 @@
"VALIDATION_TIMEOUT": {
"label": "チェックのタイムアウト(ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "古い図を省略",
"description": "会話履歴の古い図は短い印だけ残します"
},
"ALLOW_PRIVATE_URLS": {
"label": "内部ネットワークへのアクセス",
"description": "オフにすると、ユーザーが指定するモデルの URL に内部アドレスを使えなくなり、安全です"
@@ -609,7 +697,8 @@
"lede": "図を説明するか、スクリーンショットや PDF をアップロードしてください。できあがるのは編集もエクスポートもできる draw.io の図です。",
"placeholder": "例:SMS 認証とリトライを含むログインのシーケンス図",
"recent": "最近の図",
"drawYourself": "白紙のキャンバスから始める"
"drawYourself": "白紙のキャンバスから始める",
"openFileHint": "手元の図の続きを編集"
},
"shell": {
"waiting": "MCP サーバーを待っています…",
+90 -1
View File
@@ -3,25 +3,48 @@
"save": "儲存",
"cancel": "取消",
"close": "關閉",
"clear": "清除",
"edit": "編輯",
"delete": "刪除",
"loading": "載入中..."
},
"nav": {
"editor": "編輯器",
"newChat": "新增圖紙",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板",
"showPanel": "顯示聊天面板",
"aiChat": "AI 對話"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope",
"minimax": "MiniMax",
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"inputLabel": "聊天輸入框",
"send": "傳送",
"stopGeneration": "停止產生",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"uploadFile": "上傳檔案(圖片、PDF、文字)",
"minimalStyle": "簡約",
"styledMode": "精緻",
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
"regenerate": "重新產生回應",
"copyResponse": "複製回應",
"copied": "已複製!",
@@ -59,6 +82,17 @@
"accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 服務商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "服務商",
"modelId": "模型 ID",
"apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰",
"baseUrl": "基礎 URL(可選)",
"customEndpoint": "自訂端點 URL",
"overrides": "覆寫",
"clearSettings": "清除設定",
"useServerDefault": "使用伺服器預設值",
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
@@ -69,6 +103,10 @@
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
"diagramActions": "圖表操作",
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
"history": "歷史紀錄",
"download": "下載",
"proxy": "代理設定",
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
"httpProxy": "HTTP 代理",
@@ -132,9 +170,13 @@
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
"failedToPersist": "卸載前無法持久化狀態",
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
@@ -166,6 +208,8 @@
"quota": {
"dailyLimit": "已達每日配額",
"tokenLimit": "已達每日 token 上限",
"tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制(每分鐘 {limit} token)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。很高興您玩得開心;為了讓大家都能公平使用,示範站對用量做了限制。",
"messageApiSelfHosted": null,
@@ -177,6 +221,7 @@
"configModel": "使用自己的 API Key",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
@@ -203,8 +248,37 @@
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
"title": "開發:XML 串流模擬器",
"preset": "預設:",
"selectPreset": "選擇預設...",
"clear": "清除",
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
"interval": "間隔:",
"chars": "字元:",
"streaming": "串流傳輸中...",
"simulate": "模擬",
"stop": "停止",
"testQuotaToast": "測試配額提示",
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
"successMessage": "成功顯示圖表。"
},
"about": {
"modelChange": "模型變更與用量限制",
"walletCrying": "(別名:我的錢包頂不住了)",
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
"contactMe": "聯絡我",
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2,並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "還沒有圖紙",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除這張圖紙?",
"deleteDescription": "圖紙和它的對話會被永久刪除。",
"justNow": "剛剛",
@@ -277,13 +351,20 @@
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"configure": "配置",
"addProvider": "新增服務商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除服務商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"validationError": "測試沒通過",
"addModelFirst": "先新增一個模型。",
"displayName": "顯示名稱",
"bedrockApiKey": "Bedrock API Key",
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
"awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
@@ -291,6 +372,7 @@
"apiKey": "API Key",
"enterApiKey": "輸入 API Key",
"enterSecretKey": "輸入 Secret Access Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"getApiKey": "取得 API Key",
"noTools": "不支援工具呼叫",
@@ -306,6 +388,8 @@
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入服務商名稱...",
"requestFailed": "請求失敗({status})",
"cancel": "取消",
"delete": "刪除",
@@ -499,6 +583,10 @@
"VALIDATION_TIMEOUT": {
"label": "檢查逾時(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "精簡舊圖",
"description": "對話歷史裡的舊圖表只留簡短標記"
},
"ALLOW_PRIVATE_URLS": {
"label": "存取內網",
"description": "關閉後,自訂模型位址不能指向內網,更安全"
@@ -609,7 +697,8 @@
"lede": "描述一張圖,或上傳截圖、PDF。得到的是可以繼續編輯和匯出的 draw.io 圖。",
"placeholder": "例如:使用者登入的時序圖,包含簡訊驗證碼和失敗重試",
"recent": "最近的圖紙",
"drawYourself": "從空白畫布開始"
"drawYourself": "從空白畫布開始",
"openFileHint": "接著編輯已有的圖"
},
"shell": {
"waiting": "正在等待 MCP 伺服器…",

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