Compare commits

...
Author SHA1 Message Date
Dayuan Jiang 2f92936f03 fix(chat): closing the start screen's MCP video closes the whole card (#991) 2026-10-12 21:47:17 +09:00
Dayuan Jiang 458d8050cc refactor(providers): one record per provider, one place for the key-and-URL decision (#988)
Every fact about a provider lives in one record in lib/providers.ts (SDK kind,
credential kind, env var prefix, default URL, picker group, four-language
notes, and a short model list for the six providers that cannot list models).
The thirteen tables keyed by provider name are gone. resolveEndpoint decides
whose key a call uses and where it goes; usesServerCredentials reads its
answer instead of mirroring getAIModel's branches; serverCredentialError holds
the server credential check once. The UI, the connection test and the routes
read the record's credential and sdk fields instead of comparing provider
names (44 such checks before, 6 after, in the admin panel's fixed-variable
branches).

Intended behavior changes: providers that can list their models have no
hand-written list; the settings dialog fetches the live list in the background
once credentials are entered for a provider without models and offers it
behind "Browse models". Azure has no placeholder default URL (old entries that
hold it still count as unset); the base URL field of Azure and of the new
"custom" OpenAI-compatible preset (CUSTOM_API_KEY, CUSTOM_BASE_URL) is
labelled required. The connection test answers 400 for an unknown provider
name; prototype names are refused everywhere. Provider lists in error messages
and the admin "add provider" dropdown follow the picker's order.

Also fixed, from before the refactor: the redirect guard drops x-access-code
on cross-origin redirects, and edgeOneEndpoint accepts the Origin header only
when it matches Host or X-Forwarded-Host.

Verification: a behavior snapshot of getAIModel's SDK factory calls and the
quota functions over 25 providers, 22 client override shapes and 9 server
environments (5843 rows), recorded on the old code and unchanged apart from
the intended changes; 752 unit tests, 65 provider e2e tests, tsc, biome,
next build. Reviewed by five models from one brief; their findings and the
resulting fixes are recorded in docs/plans/2026-10-provider-refactor.md.
2026-10-12 21:31:03 +09:00
Dayuan Jiang 5b27d30194 docs(mcp-server): promo video in the READMEs, a shorter MCP README with an install prompt, release 0.4.1 (#989)
* docs: MCP promo video at the top of the MCP section in the three READMEs

* docs(mcp-server): the promo video in the package README

* docs(mcp-server): shorter README with an install prompt for AI agents and the right VS Code config

* chore(mcp-server): release 0.4.1
2026-10-12 16:30:14 +09:00
Dayuan Jiang 2ba700e61c feat(chat): MCP card on the start screen, with the promo video until it is closed (#990)
* feat(chat): bring back the MCP server notice on the start screen

* feat(chat): the start screen's MCP card plays the promo video until it is closed
2026-10-12 16:28:55 +09:00
Dayuan Jiang 99ced88e5b fix(chat): use the AI SDK's own mechanisms for quota, retries, stops and notices (#987)
## Problem

A review of how the app uses the Vercel AI SDK (two Claude reviewers and Codex, each finding checked against the SDK's type definitions and source) found five behaviours that are wrong and a set of places where the app re-implements what the SDK already provides:

1. Quota tokens were recorded in `onFinish`, which the SDK does not call when a later step fails; only a user stop was handled (`onAbort`), and the write was not awaited.
2. An automatic retry after a tool error re-sent the request body as it was when the message was sent, so the model was told the "authoritative" canvas XML was the diagram from before the turn, although `display_diagram` commits a drawing before its screenshot check can fail.
3. The screenshot check and the connection test ran their model call on `AbortSignal.timeout` only, so pressing Stop (or closing the dialog) did not stop a paid model call.
4. The client ignored `finishReason`; a reasoning model that spent the whole output budget before calling a tool left the user with no diagram and no message.
5. The connection test counted an `invalid` tool call (a tool that does not exist, unreadable input) as proof of tool support.

## Change

- `onStepFinish` records each step's `usage.totalTokens` and awaits the write; the copies in `onFinish` and `onAbort` are gone.
- `DefaultChatTransport.prepareSendMessagesRequest` sends the current canvas page XML on every request after the first of a turn; the first keeps the exported (or snapshot) XML.
- `validate-diagram` and the connection test pass `req.signal` and use the SDK's `timeout` option; `validate-diagram` reports to Langfuse under `functionId: "validate-diagram"`.
- The connection test only counts a valid tool call.
- The client's `onFinish` shows a notice (with Retry) when `finishReason === "length"` and no tool was called.
- The server validates incoming messages with `safeValidateUIMessages` (400 otherwise), drops client system messages explicitly and prunes empty model messages with `pruneMessages`.
- The chat's tools live in `lib/chat-tools.ts` with an `outputSchema` for the client-side ones; `lib/chat-message.ts` defines `ChatUIMessage` (metadata, a `data-notice` part, `InferUITools`) so `useChat`, `addToolOutput` and the tool handlers are typed.
- Error notices are assistant messages with a `data-notice` part instead of role `system` UI messages.
- Regenerate, edit and retry use `sendMessage({ messageId })`; `isResending` and `flushSync` are gone.
- `isToolUIPart` / `getToolName` replace slicing `"tool-"` off part types; `ToolPartLike` derives from the SDK's part types.

Net: 578 insertions, 439 deletions over 16 files.

## Testing

- New unit tests: a step that fails after a finished step still counts that step's tokens; the screenshot check's model call ends when the request is aborted; an invalid tool call in the connection test gives the warning; malformed messages get a 400. The first and third confirm SDK behaviour the review relied on.
- `tsc`, `biome ci` (0 errors), root `vitest run` 723 passed on the branch rebased onto #986, `next build`: clean
- Playwright, 10 chat-related specs on the dev server: 199 passed, 0 failed, both before and after the rebase onto #986
2026-10-12 12:58:52 +09:00
Dayuan Jiang 60105ddb51 refactor: remove dead code and over-engineering found by two audits (#986)
## Problem

Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.

## Change

Every item below was either approved one by one or covered by "fix all of the others".

- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.

Source lines: 74,983 before, 67,418 after (excluding lockfiles).

The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).

## Testing

- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
2026-10-12 12:35:55 +09:00
Dayuan Jiang 4c7b2cc0c5 feat(mcp-server): the preview is the web app's canvas: same-origin draw.io, version cards, get_selection (#984)
* 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.

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

* refactor(scripts): pin the draw.io release in one json file and share the zip reader

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

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

* build(mcp-server): require the bundled draw.io in the package and cap the tarball at 40 MB

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

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

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

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

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

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

* refactor(canvas): getDrawioSrc takes the editor's source, and CanvasStage can be given one

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

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

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

* ci: install the root dependencies before building the mcp package

The shell build needs the root's esbuild, Tailwind and React.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(mcp-server): the canvas shell is the default preview, the classic page behind DRAWIO_PREVIEW_UI=classic

* docs: the MCP preview is the web app's canvas; get_selection, DRAWIO_BASE_URL and the classic page

* chore(mcp-server): version 0.4.0; check-package requires the draw.io version stamp

* ci: an mcp-shell job builds the package, runs its tests, the draw.io file guard and the shell e2e

* 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

* 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

* 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

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

* fix(mcp-server): get_selection lists every selected id, with detail for the first 100

* fix(mcp-server): a hidden tab's delayed selection answer checks the canvas again first

* 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

* fix(mcp-server): save who made each History entry with it, so the cards look the same after a restart

* fix(mcp-server): the download waits for a page export's projection to end, and the dialog's styles are bundled

* fix(mcp-server): bundle open.html and js/open.js, the picker of Open Library from > Browser

* fix(mcp-server): the shell shows the versions once, in the side panel

* test(mcp-server): get_selection wiring test expects the classic page's answer when the shell is not built
2026-10-11 21:27:11 +09:00
Dayuan Jiang 07f3e3c2a3 feat(mcp-server): screenshot attached to create_new_diagram and edit_diagram results (#983)
* feat(mcp-server): optional screenshot attached to create_new_diagram and edit_diagram results

Both write tools take an optional boolean `screenshot`; the default comes
from DRAWIO_AUTO_SCREENSHOT. The body of screenshot_diagram moved into
captureScreenshot so the write tools can append the PNG and checklist to
their result, or a "Screenshot skipped" note when the preview tab is not
available. The preview page delays the PNG export by 600 ms right after
loading a new version so icon images finish loading.

* fix(mcp-server): review fixes for screenshot attached to create and edit results

* fix(mcp-server): Codex review fixes for the screenshot on write results

- A cleared canvas also gets the screenshot note when one was asked for
- The note for a tab that never polled says it may not have connected yet

* docs: list undo and custom drawing rules among the MCP features

* test(mcp-server): truncation check with named styles and compact cells after the merge
2026-10-11 20:54:17 +09:00
Dayuan Jiang 8ab837679e feat(mcp-server): load .drawio.svg, change summary, XML reference by topic, preview options, truncation message (#982)
* feat(mcp-server): load .drawio.svg (Editable SVG) files with load_diagram

* feat(mcp-server): summarize the user's manual changes per cell in stale rejections and get_diagram

* feat(mcp-server): XML reference for tables, layers and groups via get_drawing_guide topic

* feat(mcp-server): DRAWIO_LANG, DRAWIO_UI and DRAWIO_DARK for the preview page

Three host-config environment variables fix the language, theme and
dark mode of the draw.io editor embedded in the preview page. The new
drawio-themes module holds the theme list and draw.io's locale names
(zh-hant becomes zh-tw); drawioEmbedParams() in http-server.ts builds
the variable tail of the iframe query and getHtmlPage fills the new
{{DRAWIO_PARAMS}} placeholder. Without the variables the page keeps
sending dark=auto as before. Both READMEs document the variables.

* feat(mcp-server): report XML cut off inside a cell and explain drawing in parts

create_new_diagram and add_page now detect bare-cell XML that ends inside an
unfinished mxCell (XML comments stripped first) and return an error with the
last 300 characters of the input, asking the model to resend from that cell or
continue with edit_diagram add operations. isMxCellXmlComplete moves from
lib/utils.ts into packages/mcp-server/src/new-diagram.ts and is re-exported
from lib/utils.ts for the web app. The drawing guide gains a "Large diagrams"
paragraph and the INSTRUCTIONS edit_diagram line mentions drawing in parts.

* fix(mcp-server): review fixes for load .drawio.svg, change summary, XML reference, draw.io embed options, truncation message

* fix(mcp-server): Codex review fixes for .drawio.svg loading, change summary, references, embed options and truncation

- Truncation check: a closing tag such as </mxCell/> that the auto-fix
  repairs is no cut, and input without any cell keeps the validator's
  message
- Guide: a call rejected as cut off drew nothing, so all of its cells are
  sent again; the topic pointer is its own paragraph
- Change summary: decoded labels (no &nbsp; or merged words from <br>),
  a fast path for equal XML, and "the order of the cells changed" as
  the fallback
- DRAWIO_UI in any case, DRAWIO_LANG=zh-Hans maps to draw.io's zh
- load_diagram and export_diagram describe which files load again
2026-10-11 20:40:05 +09:00
Dayuan Jiang cb066a4825 feat: redraw the app icon on a white tile with shorter bottom boxes (#985)
* feat: redraw the app icon on a white tile with shorter bottom boxes

Same org chart shape as before, redrawn as a vector so edges are sharp.
The white tile keeps the icon visible on dark docks, taskbars and tabs,
so dark mode now uses the same image and favicon-white.svg is removed.
The desktop icon is now 1024x1024.

* chore: add SVG sources for the app icon and favicons

resources/icon.svg renders resources/icon.png; resources/favicon.svg renders
the favicon .ico and .png files.

* fix: add a title to the icon SVG sources for the Biome a11y check
2026-10-11 20:32:23 +09:00
Dayuan Jiang 77be43d996 feat(mcp-server): saved sessions, restore_version and History on disk (#981)
* feat(mcp-server): list and resume saved diagram sessions

start_session accepts an optional session_id to continue a saved diagram
under the same preview URL and auto-save file, reporting the restored
pages. New read-only tool list_saved_diagrams prints every auto-saved
file newest first with its pages. Autosaver gains list() and dataDir().
INSTRUCTIONS and both READMEs mention the new tool.

* feat(mcp-server): add restore_version tool for undo and redo from History

Add the restore_version write tool so the model can put an earlier
version from History back on the canvas (steps_back, default 1). The
current canvas is kept in History unless it is the blank page, so a redo
is possible. http-server.ts exports restoreHistoryEntry, shared by the
HTTP restore endpoint and the tool; history.ts exports HistoryEntry and
otherVersions (distinct versions other than the canvas, newest first).
The drawing guide tells the model to call restore_version when the user
asks to undo, and both READMEs document the tool.

* feat(mcp-server): save the diagram History next to the auto-saved file

The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files.

* fix(mcp-server): review fixes for saved sessions, undo tool, history on disk

* fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk

- start_session resumes every saved file, also a cleared one or one of
  empty named pages, and decompresses pages draw.io compressed
- History kept by a lost user edit or a recovering tab is saved too
- A diagram cleared before its first save keeps its History on disk
- The History file is written again after the 50-file cap removed it
- restore_version compares versions like the edit gate (a bare
  mxGraphModel from the browser has no page name), checks the restore
  result, and says "none" when no version is left
- The preview closes an open History list when the server state was
  recreated, since the entries got new ids
- start_session's description mentions session_id

* fix(mcp-server): compare History versions pairwise and tie restores to the server state

Second Codex review of the History fixes:
- One bare mxGraphModel in History no longer hides page renames in every
  comparison: names are left out only when one of the two is bare
- A History list asked for before the server recreated the session is
  dropped, and a restore names the state its list belongs to; the server
  refuses one from a lost state (History ids change after a restart)

* fix(mcp-server): restore from a History list with the state it was taken in

Third Codex review: a restore sent the tab's current state, so an old
list shown after a failed refresh passed the server's check. The list now
keeps the state it came from, and the server refuses a missing (null)
state too; only a tab of an older version, which sends none, is let
through.
2026-10-11 20:23:23 +09:00
Dayuan Jiang c774a39c33 feat(mcp-server): drawing guide hints and the user's own drawing rules (#980)
* feat(mcp-server): add source-reading and clear-canvas hints to the drawing guide

Carry the missing web-prompt sentences into the MCP drawing guide: draw from
a document, image or web page by reading it yourself first, compare image
replications with screenshot_diagram, keep replies short after a successful
draw, compose artistic requests from standard shapes, and clear the canvas by
sending only the two root cells. Replace the "If the diagram is large" bullet
with guidance on choosing create_new_diagram vs edit_diagram.

The server INSTRUCTIONS mention reading files yourself and opening .drawio
files with load_diagram; the create_new_diagram description documents the
clear-canvas call, and its handler reports "Canvas cleared" when the prepared
XML holds only the root cells. Add tests for both and the matching README
lines in the mcp-server and claude-plugin packages.

* feat(mcp-server): append the user's instructions.md to the drawing guide

Read <DRAWIO_DATA_DIR>/instructions.md (default ~/.next-ai-drawio) on
every call and append its first 5000 characters to the drawing guide
under a "## Custom Instructions" heading, for start_session,
get_drawing_guide and the diagram-workflow prompt. start_session now
tells the model where the file lives. Document the feature in both
READMEs and cover it with unit and server-wiring tests.

* fix(mcp-server): review fixes for drawing guide text and custom instructions file

* fix(mcp-server): Codex review fixes for the guide and custom instructions

- Report "Canvas cleared" only for a one-page document; several empty
  pages get the page summary
- Build the instructions.md path with path.join (Windows separators)
- Keep the 15000-character guide budget in the wiring test
- Plugin README: DRAWIO_DATA_DIR also holds instructions.md
2026-10-11 20:04:59 +09:00
Dayuan Jiang 3d1a020b35 docs(mcp): mention the xml source of load_diagram in the tool table (#978) 2026-10-11 12:05:18 +09:00
caoxiaole07andcaoxiaole07 8d690503d4 feat(mcp): load_diagram accepts inline 'xml' content as an alternative to 'path' (#946)
Agents frequently hold .drawio content in memory (another tool's output,
a repository read, an API response) and previously had to write it to a
temporary file just so load_diagram could read it back. Add a mutually
exclusive 'xml' argument that goes through the same parser as the 'path'
branch, so both plain XML and draw.io's compressed save format work.

Edit-gate semantics by source:
- 'path': unchanged — the model has not seen the content, one
  get_diagram round-trip is still required before editing.
- plain 'xml': the model supplied the exact content (same rationale as
  create_new_diagram), so it is recorded as seen and can be edited
  immediately.
- compressed 'xml': the session stores the decompressed form, which the
  model cannot derive from the compressed input — the gate is kept.

Argument validation (mutual exclusion / presence) fires before the
session check so callers get useful errors regardless of session state.
parseDrawioFileContent now reports whether any page was decompressed,
which drives the gate decision. The diagram-workflow prompt is updated
to document the new argument.

Co-authored-by: caoxiaole07 <[email protected]>
2026-10-11 10:03:13 +09:00
renovate[bot] 4b4f36e374 chore(deps): update dependency @biomejs/biome to v2.5.15 (#931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 08:33:56 +09:00
Dayuan Jiang a9091e96f1 ci: remind and close issues that wait for the reporter (#977)
Issues labelled "wait for confrim" get a reminder and the "no response"
label after 15 days without activity, and are closed as not planned after
15 more. A reply from the reporter removes both labels.
2026-10-11 08:02:28 +09:00
Dayuan Jiang 213b672e92 test(e2e): deselect by clicking a corner away from the shape (#975)
The test clicked the canvas at (10, 10) to deselect. When the click on the
shape before it needed retries, Playwright scrolled the shape into the
container's top-left corner, so the next click landed on the shape and the
selection stayed (seen on the main run of cd5352c and on #973). The new
clickEmptyCanvas helper clicks the corner farthest from the shape.
Reproduced locally by scrolling the shape into the corner: the old click
keeps the selection, the helper clears it.
2026-10-10 23:53:30 +09:00
Dayuan Jiang d1ee1594f1 fix(chat): the AI reads and draws the page the user is viewing (#974)
* fix(chat): the AI reads and draws the page the user is viewing

The model always got the first page and display_diagram replaced the whole
file with one page, so drawing on page 3 of a multi-page file deleted the
other pages (#241), and edits went to page 1 while the user looked at
another page.

Now a turn records the page the user is viewing when the message is sent.
That page's model goes to the model (also as previousXml, on regenerate and
in edit_diagram error messages), selected shapes are sent on any page,
display_diagram and append_diagram replace only that page (a drawing that
brings several pages still replaces the file), edit_diagram targets it,
and the streaming preview draws on it. Compact cells and default styles
take the layer and the edges of that page, in the preview and in
editDiagram. The editor bridge replaces the current page in place when the
other pages are unchanged, so Ctrl+Z undoes an AI change on a multi-page
file in one step; when a full load is needed, the user's page is shown
again afterwards. Version summaries and the compare dialog count the page
the change was made on.

* test: read the failed edit's output without unsafe optional chaining
2026-10-10 23:30:51 +09:00
Dayuan Jiang cd5352ca88 feat(diagram): named styles and compact cells for shorter model output (#969)
The model defines reused styles once as <mxStyle name="..." value="..."/> and
refers to them by name, and writes shapes as one self-closing mxCell with
x, y, w, h and edges with source and target. style-classes.ts and
compact-cells.ts expand both back into standard draw.io XML after validation
and add draw.io's html=1 / whiteSpace=wrap defaults; the diagram shown to the
model is folded into the same notation. Prompts, tool descriptions and the MCP
drawing guide teach the notation with shared examples.

Measured on the five start-screen examples against main: gpt-6-luna 35% fewer
output tokens, Claude Opus 5.5 41% fewer. Two five-model review rounds fixed
quote-aware cell matching, attribute escaping, edge/vertex inference, root-id
and layer handling, and several auto-fix gaps.
2026-10-10 21:58:21 +09:00
Dayuan Jiang 498ee628f1 fix(electron): upgrade to Electron 41.10.7 and let the builder follow package.json (#973)
electron-builder.yml hard-coded electronVersion: 39.2.7 since #448, so every
desktop release, including v0.5.0, shipped Electron 39.2.7 no matter which
version package.json asked for. Removing the line makes electron-builder use
the installed version (checked: the packaged Electron Framework reports
41.10.7 and the app starts and serves its page). Electron 41.10.7 fixes the
four open Electron advisories (popup sandbox, webview Node integration,
protocol handler cross-origin reads, sandboxed window inheritance) and its
@electron/get 5 drops extract-zip, which clears two more alerts.
2026-10-10 21:53:37 +09:00
Dayuan Jiang a6e812d3a5 fix(deps): update proxy-addr, shell-quote and wrangler to patched versions (#971)
npm audit fix in the root and in packages/mcp-server. proxy-addr 2.0.8 fixes
the IP spoofing through IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h),
shell-quote 1.12.0 fixes the command injection through a line terminator, and
wrangler 4.149.0 brings a miniflare that no longer bundles the vulnerable
sharp 0.35.4. No package is added; 56 lock entries for miniflare's sharp
binaries go away.
2026-10-10 19:53:55 +09:00
Dayuan Jiang 02eaa0bd22 ci: give the SignPath approval one hour instead of ten minutes (#968) 2026-10-10 12:57:21 +09:00
Dayuan Jiang 4cba863fd9 chore: bump version to 0.5.0 (#967) 2026-10-10 12:02:12 +09:00
Dayuan Jiang 7682e479e3 chore: replace the cat example with a new drawing (#966)
Update the README cat image and the cached example response for
"Draw a cat for me" with a new cat diagram.
2026-10-10 10:33:23 +09:00
Dayuan Jiang a7ae114edf fix(chat): keep the request alive while its answer streams, so a stop still reaches the provider (#965)
A Request's signal follows the signal it was created with through a weak
link inside undici: once the Request object is garbage collected, the link is
gone and request.signal never aborts (nodejs/undici#3644). The chat route
passes req.signal to streamText and holds nothing else from the request, so a
GC pause during a stream could leave the model call running after the client
stopped or disconnected, with no onAbort accounting.

The route now keeps each request in a WeakMap keyed by its response, which
Next holds while the body is piped.

This is also the cause of the unit test chat-route-abort hanging on CI (three
times this week, 60 ms locally): the test's Request is dropped as soon as the
route returns, and a GC in that window lost the abort. With a forced GC the
test hangs on Node 20 and 24 without this change and passes with it.
2026-10-10 09:24:25 +09:00
Dayuan Jiang e117095c7b feat(admin): rewrite the admin panel copy in plain words (#963)
Labels are two to four words, descriptions one short line, and the
wording follows the reader rather than the implementation: "服务商"
instead of "provider", "本页设置" instead of "后台设置" for the source
chip (it shared its name with the page title), "需重启" as a badge, a
verb on every switch ("访问内网"), "Langfuse 密钥" instead of "私钥".
Descriptions keep the facts the old ones had (where to change an
env-managed provider, that users must enter an access code), and the
same style is applied to the English, Japanese and Traditional Chinese
dictionaries. Five user-facing strings in the same files are aligned
with the new terms (服务商, token).
2026-10-10 08:38:26 +09:00
Dayuan Jiang f0f0bc0dc0 feat(ui): tidy the start screen and the chat panel details (#962)
Start screen
- The five examples are one row of picture tiles (title under the picture); the old cards cut their descriptions off with an ellipsis
- "Open .drawio file" leaves the example grid and sits with "Start with a blank canvas" below it, both as outlined buttons
- The "Paper to diagram" example gets a thumbnail of its result (public/paper_demo.svg) like the other four
- The caption says what happens: examples use saved answers, so they appear at once and do not call the AI
- Shorter lede

Chat panel
- The version card's "</>" icon button becomes a "Show XML" text link in the summary line; the summary runs under the title row, so Undo is the card's only button
- Settings uses the gear icon; new diagram uses a file-plus icon instead of the pen that reads as "edit"
- The message box has the same side padding as the message column

Copy
- The model picker says "Server model" instead of "Default" when the server's model is in use
- The theme description no longer claims the diagram always stays on a white sheet (the canvas follows the theme)
- en, zh, ja and zh-Hant dictionaries updated together
2026-10-10 08:22:36 +09:00
Dayuan Jiang d5096b9628 docs: restructure the README around the new promo video (#961)
* docs: restructure the README around the new promo video

The three READMEs now follow the reader's path: what it is (video and
two paragraphs), highlights grouped as draw / edit / use, examples, how
to use it (demo, desktop app, MCP), how to self-host (local run, a
one-click deploy table, models and providers), and support.

Removed the table of contents, the sponsor blocks, the "How It Works"
section, the duplicated demo button and the model recommendation list.
The provider list now matches env.example (24 providers). The video is
the new 30-second promo. Bold markers inside the HTML example table are
now <strong> tags, so they render.

* docs: keep the TrendShift badge under the language switch

* chore: remove the sponsor material for Doubao and Atlas Cloud

The about pages lose the "Sponsored by ByteDance Doubao" card, the
promotional links in the provider list and the thanks paragraph; the
"bring your own key" note stays. The quota toast no longer shows the
Volcengine sign-up paragraph, and its messages in the four languages no
longer mention a sponsor. The provider guides lose the "free tokens"
promo line. The logo and invite images used only by that material are
deleted. Doubao and Atlas Cloud remain supported providers.
2026-10-10 08:22:16 +09:00
Dayuan Jiang 2418c47764 fix(edit): refuse cut-off cell XML in edit_diagram operations (#960)
While an edit streams, the preview applies the operations received so
far. Chrome's DOMParser keeps a cell whose XML is cut off, so a vertex
without its geometry reached the canvas. draw.io placed it at the origin,
shifted the page layout and compensated the scroll position, and the
browser's clamping of that compensation left the diagram out of view
once the edit finished.

Check each new_xml with getXmlSyntaxError, as the whole document
already is, so an incomplete operation is reported instead of applied.
2026-10-09 21:06:49 +09:00
Dayuan Jiang 04d34534fd ci: run the e2e tests in six shards (#959)
* ci: run the e2e tests in four shards

One job ran all 300+ tests with one worker, about 20 minutes. Four
jobs at once take a quarter each, still with one worker per job.

* ci: six e2e shards

With four, the shard of the long workspace tests took 8 minutes while
the others took 4 to 5.
2026-10-08 18:44:14 +09:00
Dayuan Jiang 17ea4811fb feat(settings): reorganize the settings dialog and the model settings (#958)
Tabs Models, General, Drawing and About, each with a fixed title row.

The models tab becomes three pages: the user's providers with one status
each and the server's models; a searchable picker of all 24 providers in
four groups; and a provider page in three steps (connection, models,
test). A new provider gets its first suggested model, and a passing test
offers to use the model in the chat. Bedrock asks for one way of signing
in, with an optional session token for temporary access keys.

The composer's model picker lists every model, marking untested and
failed ones, and can open the provider picker. A chat error that offers
model settings opens the provider the request went to.
2026-10-08 18:10:07 +09:00
Dayuan Jiang c513d1a0c2 feat(ui): canvas-first redesign with a self-hosted draw.io (#957)
The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV
  import) and page tabs; the shape library starts closed
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- Bedrock in the model settings also takes an API key instead of access keys
- phones switch between canvas and chat
- dark mode uses draw.io's own dark mode, with neutral greys around it

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the features above
that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
2026-10-08 16:20:15 +09:00
Dayuan Jiang 18a20aa1ca feat(api): count the quota by the CDN's client IP and refuse direct calls (#956)
Two optional settings for deployments behind a CDN such as Cloudflare.

CLIENT_IP_HEADER names the header that holds the visitor's real IP
(cf-connecting-ip on Cloudflare). The per-IP daily quota used the first
X-Forwarded-For entry, which visitors can set to anything, so a made-up
IP on every request got a fresh quota.

ORIGIN_SECRET makes proxy.ts refuse /api requests whose X-Origin-Secret
header does not match. The CDN adds the header, so a call that skips the
CDN, and could fake the IP header, gets 403. Pages are not checked, which
keeps health checks on / working.

Both are unset by default, and nothing changes then.
2026-10-06 10:32:43 +09:00
dependabot[bot] 802f0ada9f fix(deps): update vulnerable dependencies with npm audit fix (#804)
Root: 36 vulnerable packages to 9, no critical left (next 16.3.8, electron 39.8.10). MCP server: 11 to 0.
2026-10-06 09:56:06 +09:00
Marvelous Ikponmwosa 257a4bca06 docs: explain how to use LM Studio with the OpenAI provider (#897)
Set the base URL to http://localhost:1234/v1 and use any non-empty API key.
2026-10-06 09:55:26 +09:00
Dayuan Jiang 83f190e1cd feat(chat): show a quota hint when the server's key is refused (#953)
On the server's own credentials a provider 403 now gets its own code,
server_key_forbidden, with a hint in all four languages: today's free
quota is used up, it resets tomorrow, and users can add their own key
in model settings. The generic "The provider returned an error." line
is left out for this code.

A daily spend cap that blocks a shared key makes every call return 403.
The old hint said the key may lack access to the model or region, which
points users at a setting they cannot change.

A 403 on the user's own key keeps the old hint and the provider's text.
2026-10-06 09:49:48 +09:00
Dayuan Jiang 8639779a23 fix(ollama): send chat requests to the /api path (#954)
The Ollama SDK appends /chat to the base URL, and Ollama serves chat at
/api/chat. The model list already added the missing /api, so with
"http://localhost:11434" (the address our docs showed) models were listed
but every chat request went to /chat and got a 404. An OpenAI-style
".../v1" address went to /v1/chat.

ollamaApiUrl() turns the server address, ".../v1" and ".../api" into
".../api". Chat, the model list and the settings dialog's request URL
hint all use it. The docs now show http://localhost:11434/api, which also
works on released versions.
2026-10-06 09:37:05 +09:00
Bryon Nevis 38b72e89b6 feat: hide the MCP server card when NEXT_PUBLIC_SELFHOSTED is true (#711)
The welcome title and the Quick examples heading stay.
2026-10-06 08:38:33 +09:00
Semianchuk Vitalii 6e77b08eb3 chore: remove the unused base-64 dependency and list all providers in env.example (#881)
Keep the viewport settings so iOS Safari does not zoom on input focus.
2026-10-06 08:38:27 +09:00
renovate[bot] 3855ff2b02 chore(deps): update dependency electron-builder to v26.15.3 (#856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 08:21:32 +09:00
Ngo Quoc Viet 668f79c0ab fix: validate admin settings file target (#894) 2026-10-06 08:21:27 +09:00
Dayuan Jiang 75be7cb3ea Merge pull request #326 from JoeGlenn1213/fix/deepseek-skip-images
fix: skip image parts for DeepSeek to avoid unknown variant "image_url"
2026-10-06 08:10:36 +09:00
Dayuan Jiang 29736bb528 Merge pull request #795 from octo-patch/fix/issue-644-mcp-png-export-corrupt
fix: prevent SVG data from being misidentified as PNG in MCP export
2026-10-06 08:10:30 +09:00
Dayuan Jiang 73aa6a45f6 Merge pull request #820 from octo-patch/fix/issue-819-azure-endpoint-construction
fix: use createAzure in validate-model and fix resourceName fallback
2026-10-06 08:10:26 +09:00
Dayuan Jiang 3c9bcfb978 Merge pull request #884 from chaochaoweb3/codex/model-validation-max-tokens
fix: increase model validation token budget
2026-10-06 08:10:21 +09:00
Dayuan Jiang ba2a789d20 Merge pull request #892 from mvanhorn/fix/861-bedrock-claude-temperature-deprecated
fix: skip temperature parameter for models that reject it
2026-10-06 08:10:16 +09:00
Dayuan Jiang 1a63498d5d Merge pull request #513 from xinyang20/feature/add-more-themes
feat: add all Draw.io themes to settings panel (#499)
2026-10-06 08:10:11 +09:00
dayuan.jiang dee0c80b3e Merge branch 'main' into fix/deepseek-skip-images
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 4612f7c38d Merge branch 'main' into fix/issue-644-mcp-png-export-corrupt
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang f3a4c98f26 Merge branch 'main' into fix/issue-819-azure-endpoint-construction
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 6857633105 Merge branch 'main' into codex/model-validation-max-tokens
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 57729cf174 Merge branch 'main' into fix/861-bedrock-claude-temperature-deprecated
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
dayuan.jiang 9c46e760af Merge branch 'main' into feature/add-more-themes
Keep main's version of every file. Main now covers this fix,
so this PR can be merged with its commits in the history.
2026-10-06 08:03:04 +09:00
Dayuan Jiang 21ab839be7 Merge pull request #920 from NgoQuocViet2001/fix-server-model-id-collisions
fix: prevent server model ID collisions
2026-10-06 07:24:30 +09:00
Dayuan Jiang 23b76c098b Merge pull request #934 from xiajiadi/fix/deepseek-v4-vision-image-input
fix: support DeepSeek V4 Vision image input
2026-10-06 07:24:26 +09:00
Dayuan Jiang a87ada3776 Merge pull request #945 from DawnSouther/fix/mcp-export-queue
fix(mcp): queue png/svg exports by job id so concurrent exports no longer time out
2026-10-06 07:24:21 +09:00
dayuan.jiang 4c8d8427bc Merge branch 'main' into fix-server-model-id-collisions
Keep main's version of every file. The fix landed in main through #951,
so this PR can be merged with its commits in the history.
2026-10-06 07:14:15 +09:00
dayuan.jiang d47c5ebb4e Merge branch 'main' into fix/deepseek-v4-vision-image-input
Keep main's version of every file. The fix landed in main through #951,
so this PR can be merged with its commits in the history.
2026-10-06 07:14:15 +09:00
dayuan.jiang 52b1f24447 Merge branch 'main' into fix/mcp-export-queue
Keep main's version of every file. The fix landed in main through #951,
so this PR can be merged with its commits in the history.
2026-10-06 07:14:15 +09:00
Dayuan Jiang 7fccf68edf fix(mcp-server): read npm 12's pack output in the package check (#952)
The publish workflow installs the latest npm, and npm 12 prints
`npm pack --json` as an object keyed by package name instead of a list,
so check-package failed and 0.3.0 was not published. Accept both.
2026-10-05 22:58:56 +09:00
dawn-book 113608ec02 fix(mcp): queue png/svg exports by job id so concurrent exports no longer time out
The old export protocol used a single state slot per session: the MCP tool
set exportFormat/exportXml, the browser bridge picked it up on its 2s poll,
and the rendered image came back as one exportData field that whichever
tool call polled first consumed and cleared. Under concurrency this meant:
requests overwrote each other, at most one of N parallel exports succeeded,
a late render could satisfy the next export with the WRONG image, the
browser's leaked 10s fallback timer could silently swallow a successor's
request, and a closed preview tab made every export hang to timeout.

Replace it with a per-session export job queue:

- each export_diagram call enqueues a job (unique id, format, optional
  single-page projection) and awaits its own job's promise
- GET /api/state exposes only the head of the queue; the browser renders
  jobs strictly one at a time and reports each result/failure BY JOB ID,
  which resolves exactly the waiting call; stale ids are ignored
- browser-side fallback timer (30s) is tracked and cleared per job and
  only ever fails its own job, so failures advance the queue
- server-side per-job backstop is 120s (DRAWIO_EXPORT_TIMEOUT_MS) since a
  queued job also waits for its predecessors
- browser heartbeat (20s staleness) fails enqueues/waiters fast when the
  preview tab is gone, instead of hanging to the timeout
- expired-session cleanup now fails waiting jobs and drops queue state

Add tests/export-queue.test.ts covering serialization, per-job routing,
stale-result rejection, fail/timeout queue advance, and the untouched
autosave paths.
2026-09-27 08:30:49 +08:00
xiajiadi 58b73ef761 fix: support DeepSeek Vision image input 2026-09-04 17:07:05 +08:00
xiajiadi ea16ea1ece test: cover DeepSeek Vision image payload 2026-09-04 17:07:04 +08:00
NgoQuocViet2001 e355a50890 fix: disambiguate colliding server model IDs 2026-08-14 15:36:42 +07:00
chaochaoweb3 42ab388118 refactor: hardcode model validation token budget 2026-07-20 17:08:10 +08:00
Matt Van Horn dfedd1b881 chore: drop workspace metadata 2026-07-12 01:47:10 -07:00
Matt Van Horn 29acbc33b8 fix: address round-2 residual (surgical round) 2026-07-12 01:29:26 -07:00
Matt Van Horn d6bdb25f58 fix: address self-review findings 2026-07-12 01:26:59 -07:00
dayuan.jiang eb2fa69f58 refactor: drop request-body token override, keep env-only config
The unauthenticated /api/validate-model endpoint should not let callers
raise the token budget; the env var / admin setting alone fixes #883.
Also import the shared constants in the settings registry instead of
duplicating them, and move the setting to the Features group alongside
the other validation settings.
2026-07-11 22:30:20 +09:00
chaochaoweb3 c1e4b1c9bf fix: make model validation token budget configurable 2026-07-02 15:48:44 +08:00
octo-patch d08b28821e fix: use createAzure in validate-model and fix resourceName fallback (fixes #819)
Two related bugs caused Azure OpenAI to pass validation but fail in actual use:

1. validate-model/route.ts used createOpenAI for Azure, while ai-providers.ts
   uses createAzure. These differ in URL construction and authentication headers
   (api-key vs Authorization: Bearer), so validation did not test the real code
   path. Switch to createAzure for consistency.

2. In ai-providers.ts, when a user provided their own API key without a custom
   base URL, resourceName was unconditionally set to undefined. This left
   createAzure with no endpoint information, resulting in resource not found.
   resourceName is an endpoint component, not a credential, so it is safe to
   fall back to AZURE_RESOURCE_NAME from the environment whenever no baseURL is
   available.
2026-04-22 20:07:28 +08:00
Octopus 8986217a73 fix: prevent SVG data from being misidentified as PNG in MCP export (fixes #644)
The fallback condition for PNG detection in the MCP export handler was too broad:
it would match any string longer than 100 chars that didn't start with '<', which
includes SVG data URLs (data:image/svg+xml;base64,...).

This caused a race condition where an autosave SVG export response could arrive
while a PNG export was pending, resulting in SVG data being sent to the MCP server
as the PNG export result. The server would then try to write it as binary PNG,
producing a corrupt file (broken image).

Fix: exclude strings starting with 'data:' from the fallback condition, so only
raw base64 strings (without a data URL prefix) match as PNG, while SVG data URLs
are correctly rejected.
2026-04-07 09:44:27 +08:00
Octopus 73788abff9 fix: remove redundant status(modified:false) call to restore undo/redo (fixes #779) 2026-04-04 09:54:20 +08:00
Xinyang Gao-PC de0ba6b718 Merge branch 'feature/add-more-themes' of github.com:xinyang20/next-ai-draw-io into feature/add-more-themes 2026-01-12 22:19:38 +08:00
Xinyang Gao-PC a8ff1ea2bc fix:add missing DRAWIO_THEMES import in settings-dialog 2026-01-12 22:17:18 +08:00
Xinyang Gao b723406a48 Merge branch 'main' into feature/add-more-themes 2026-01-12 22:15:40 +08:00
Xinyang Gao-PC 783cb4a6f1 fix:resolve the Copilot-suggested changes 2026-01-04 20:13:47 +08:00
Xinyang Gao-PC 801974a18b feat: add all Draw.io themes to settings panel (#499) 2026-01-04 19:41:49 +08:00
Your Name 5f71b2783f style: enhance UI with modern glassmorphism, animations and refined spacing 2025-12-19 15:11:42 +08:00
Your Name 691c7e34bf feat: image capability detection and user-friendly errors; warn and filter images for unsupported models 2025-12-19 14:12:31 +08:00
Your Name 0d50a179aa fix: skip image parts for DeepSeek to avoid unknown variant "image_url" 2025-12-19 12:07:06 +08:00
373 changed files with 51617 additions and 31616 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
-42
View File
@@ -1,42 +0,0 @@
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,14 +4,11 @@ on:
push: push:
branches: branches:
- main - main
- master
- dev
tags: tags:
- 'v*' - 'v*'
pull_request: pull_request:
branches: branches:
- main - main
- master
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
+5 -19
View File
@@ -34,15 +34,7 @@ jobs:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Download draw.io static files for offline use # npm run build downloads draw.io first (prebuild)
run: |
rm -rf public/drawio
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
@@ -66,16 +58,7 @@ jobs:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Download draw.io static files for offline use # npm run build downloads draw.io first (prebuild)
shell: bash
run: |
rm -rf public/drawio
git clone --depth 1 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
@@ -103,6 +86,9 @@ jobs:
artifact-configuration-slug: 'windows-exe' artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true wait-for-completion: true
# The release-signing policy needs a manual approval in SignPath;
# the default 600 s wait made the job fail before anyone could approve.
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: release-signed output-artifact-directory: release-signed
- name: Upload signed artifacts to release - name: Upload signed artifacts to release
+25 -2
View File
@@ -1,7 +1,8 @@
name: Publish MCP Server name: Publish MCP Server
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing # Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
# (no token, no OTP). Triggers when packages/mcp-server changes on main; # (no token, no OTP). Triggers when packages/mcp-server or the web app code
# bundled into the canvas shell (scripts/build-shell.mjs) changes on main;
# skips silently if the package.json version is already on npm — so a # skips silently if the package.json version is already on npm — so a
# release is just "bump the version in a PR and merge". # release is just "bump the version in a PR and merge".
on: on:
@@ -10,6 +11,16 @@ on:
- main - main
paths: paths:
- "packages/mcp-server/**" - "packages/mcp-server/**"
# The canvas shell is bundled from these with the root's lockfile
- "components/**"
- "contexts/**"
- "hooks/**"
- "lib/**"
- "stores/**"
- "app/globals.css"
- "package-lock.json"
# The package's build scripts import the root's scripts/drawio-zip.mjs
- "scripts/**"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -35,7 +46,9 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
cache-dependency-path: packages/mcp-server/package-lock.json cache-dependency-path: |
package-lock.json
packages/mcp-server/package-lock.json
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
# Trusted publishing requires npm >= 11.5.1 # Trusted publishing requires npm >= 11.5.1
@@ -54,6 +67,13 @@ jobs:
echo "publish=true" >> "$GITHUB_OUTPUT" echo "publish=true" >> "$GITHUB_OUTPUT"
fi fi
# The canvas shell (scripts/build-shell.mjs) is bundled from the web
# app's components with the root's esbuild, Tailwind and React
- name: Install repository dependencies
if: steps.version.outputs.publish == 'true'
working-directory: .
run: npm ci
- name: Install dependencies - name: Install dependencies
if: steps.version.outputs.publish == 'true' if: steps.version.outputs.publish == 'true'
run: npm ci run: npm ci
@@ -62,6 +82,9 @@ jobs:
if: steps.version.outputs.publish == 'true' if: steps.version.outputs.publish == 'true'
run: npm test run: npm test
# The build downloads the pinned draw.war from GitHub releases
# (scripts/fetch-drawio.mjs) and bundles a trimmed draw.io into
# dist/drawio; check-package confirms it is in the tarball
- name: Build and check package contents - name: Build and check package contents
if: steps.version.outputs.publish == 'true' if: steps.version.outputs.publish == 'true'
run: npm run build && npm run check-package run: npm run build && npm run check-package
+110 -10
View File
@@ -6,6 +6,13 @@ on:
push: push:
branches: [main] branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
lint-and-unit: lint-and-unit:
name: Lint & Unit Tests name: Lint & Unit Tests
@@ -16,42 +23,135 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Run lint - name: Run lint
run: npm run check run: npm run check
- name: Run unit tests - name: Run unit tests
run: npm run test -- --run run: npm run test -- --run
# The MCP server package ships its own vitest because its DOM polyfill # The MCP server package: its own vitest (its DOM polyfill, linkedom, needs
# (linkedom) needs `environment: node`, while the root vitest uses jsdom # `environment: node`, while the root vitest uses jsdom for the Next.js
# for the Next.js app. Install + run its tests separately so CI catches # app), the npm package as it is built, the draw.io file list, and the
# multi-page mxfile regressions. # canvas shell in a browser.
mcp-shell:
name: MCP Server & Shell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
# HEAD^1 is the base branch for the version bump reminder below
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
cache: "npm"
cache-dependency-path: |
package-lock.json
packages/mcp-server/package-lock.json
# The shell is bundled from the web app's components with the root's
# esbuild, Tailwind and React; Playwright comes from the root too
- name: Install dependencies
run: npm ci
- name: Install MCP server dependencies - name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci run: npm --prefix packages/mcp-server ci
- name: Cache Playwright browsers
uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium --with-deps
- name: Install Playwright deps (cached)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Run MCP server unit tests - name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test run: npm --prefix packages/mcp-server test
# Tests run from src/, so check the built npm package separately # Tests run from src/, so check the built npm package separately. The
# build downloads the pinned draw.war and bundles a trimmed draw.io.
- name: Build MCP server and check package contents - name: Build MCP server and check package contents
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
# The trimmed draw.io is cut from this list; the guard drives the full
# copy (public/drawio) in Chromium and fails when the editor requests a
# file the list does not have
- name: Download the full draw.io
run: node scripts/fetch-drawio.mjs
- name: Check the MCP draw.io file list
run: npm --prefix packages/mcp-server run check-drawio
# The canvas shell in a browser, from the package as npm installs it:
# the packed tarball goes into an empty directory, its dist/index.js is
# started over stdio and headless Chromium opens the URL start_session
# returns (packages/mcp-server/tests/e2e)
- name: Run the MCP shell E2E tests on the packed tarball
run: |
mkdir -p "$RUNNER_TEMP/mcp-pack" "$RUNNER_TEMP/mcp-install"
npm pack ./packages/mcp-server --pack-destination "$RUNNER_TEMP/mcp-pack"
npm --prefix "$RUNNER_TEMP/mcp-install" install "$RUNNER_TEMP"/mcp-pack/*.tgz
MCP_SERVER_ENTRY="$RUNNER_TEMP/mcp-install/node_modules/@next-ai-drawio/mcp-server/dist/index.js" \
npm --prefix packages/mcp-server run test:e2e
env:
CI: true
# The shell bundles the web app's canvas and the root's dependencies,
# so a change under these paths (the ones publish-mcp.yml triggers on)
# reaches users with the next MCP release, and publish-mcp.yml only
# publishes a new version number
- name: Remind to bump the MCP version when bundled code changed
if: github.event_name == 'pull_request'
run: |
changed=$(git diff --name-only HEAD^1 HEAD -- components contexts hooks lib stores app/globals.css package-lock.json scripts packages/mcp-server)
base_version=$(git show HEAD^1:packages/mcp-server/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version")
head_version=$(node -p "require('./packages/mcp-server/package.json').version")
if [ -n "$changed" ] && [ "$base_version" = "$head_version" ]; then
echo "::warning::Code bundled into the MCP server changed without a version bump in packages/mcp-server/package.json (still ${head_version}). Bump it if the change should be published."
fi
- name: Upload test results
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report-mcp-shell
path: packages/mcp-server/test-results/
retention-days: 7
e2e: e2e:
name: E2E Tests # Six jobs at once, each with a sixth of the tests and one worker: the
# same conditions per test, a fraction of the time
name: E2E Tests (${{ matrix.shard }}/6)
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "24"
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
@@ -76,7 +176,7 @@ jobs:
run: npm run build run: npm run build
- name: Run E2E tests - name: Run E2E tests
run: npm run test:e2e run: npm run test:e2e -- --shard=${{ matrix.shard }}/6
env: env:
CI: true CI: true
@@ -84,6 +184,6 @@ jobs:
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6
if: always() if: always()
with: with:
name: playwright-report name: playwright-report-${{ matrix.shard }}
path: playwright-report/ path: playwright-report/
retention-days: 7 retention-days: 7
@@ -0,0 +1,55 @@
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
# days without activity the issue gets a reminder and the "no response"
# label; after 15 more days it is closed. A reply from the reporter removes
# both labels, so the issue shows up as needing the maintainer again.
name: Wait for confirmation
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
issue_comment:
types: [created]
permissions:
issues: write
jobs:
remind-and-close:
if: github.event_name != 'issue_comment'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-issue-labels: "wait for confrim"
days-before-issue-stale: 15
days-before-issue-close: 15
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: "no response"
stale-issue-message: >-
Still there? We asked for more information 15 days ago. A reply
would help; if we hear nothing in another 15 days this issue will
be closed. It can be reopened any time.
close-issue-message: >-
No reply in 30 days, so this issue is closed for now. If the
problem is still there, reply or reopen it.
close-issue-reason: not_planned
remove-stale-when-updated: true
reporter-replied:
if: >-
github.event_name == 'issue_comment' &&
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
github.event.comment.user.type != 'Bot' &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
steps:
- name: The reporter answered, so the issue is the maintainer's again
run: >-
gh issue edit "$NUMBER" --repo "$REPO"
--remove-label "wait for confrim" --remove-label "no response"
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
+2
View File
@@ -16,6 +16,8 @@ packages/*/dist
/coverage /coverage
/playwright-report/ /playwright-report/
/test-results/ /test-results/
packages/*/playwright-report/
packages/*/test-results/
# next.js # next.js
/.next/ /.next/
+3 -2
View File
@@ -23,8 +23,9 @@ COPY . .
# Disable Next.js telemetry during build # Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Build-time argument for self-hosted draw.io URL # Build-time argument for an external draw.io URL. Leave empty to use the
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # copy bundled into public/drawio (downloaded by `npm run build`)
ARG NEXT_PUBLIC_DRAWIO_BASE_URL=""
ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL} ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
# Build-time argument to show About link and Notice icon # Build-time argument to show About link and Notice icon
+78 -199
View File
@@ -1,120 +1,100 @@
# Next AI Draw.io
<div align="center"> <div align="center">
**AI-Powered Diagram Creation Tool - Chat, Draw, Visualize** # Next AI Draw.io
**Draw and edit draw.io diagrams by chatting with an AI.**
English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md) English | [中文](./docs/cn/README_CN.md) | [日本語](./docs/ja/README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [**Live Demo**](https://next-ai-drawio.jiang.jp/) · [**Desktop App**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP Server**](#in-your-ai-agent-mcp)
</div> </div>
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. https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model! Describe a diagram in a sentence and the AI draws it on a real draw.io canvas. Edit it by hand like any draw.io file, or select a few shapes and tell the AI what to change. Every AI change is a version you can compare, restore or undo, and the result exports as `.drawio`, `.png` or `.svg`.
<p align="center"> Available as a web app, a desktop app for Windows, macOS and Linux, and an MCP server for AI agents such as Claude Code, Cursor and VS Code.
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
</picture>
</a>
</p>
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan). ## Highlights
**Draw**
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 - Architecture diagrams, flowcharts, sequence diagrams and more from one sentence, with built-in AWS, Azure, GCP and Kubernetes icon libraries
- Connectors can carry a flowing animation
- Upload a screenshot or a sketch and the AI redraws it; upload PDF, Markdown, code and other text files and get a diagram of their content
**Edit**
- Edit by chat: changes stream onto the canvas and the shapes the AI just changed are highlighted
- Ask about a selection: select shapes on the canvas and the AI changes only those
- Versions and undo: every AI change is a version card with a thumbnail; compare it with the canvas, restore it or undo it, and Ctrl+Z on the canvas also takes back an AI change in one step
- It is a normal draw.io diagram: double-click to rename, drag, restyle, use several pages, and export as `.drawio`, `.png`, `.svg` or `.drawio.svg` at any time
## Table of Contents **Use**
- [Next AI Draw.io](#next-ai-drawio)
- [Table of Contents](#table-of-contents) - 25 model providers, including an OpenAI-compatible preset for any other URL; enter your own API key in the browser and it stays on your machine
- [Examples](#examples) - Models that reason show their thinking
- [Features](#features) - Dark mode; the interface is available in English, Simplified Chinese, Traditional Chinese and Japanese
- [MCP Server](#mcp-server)
- [Claude Code CLI](#claude-code-cli)
- [Getting Started](#getting-started)
- [Try it Online](#try-it-online)
- [Desktop Application](#desktop-application)
- [Run with Docker](#run-with-docker)
- [Installation](#installation)
- [Deployment](#deployment)
- [Deploy to EdgeOne Pages](#deploy-to-edgeone-pages)
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works)
- [Support \& Contact](#support--contact)
- [FAQ](#faq)
- [Star History](#star-history)
## Examples ## Examples
Here are some example prompts and their generated diagrams:
<div align="center"> <div align="center">
<table width="100%"> <table width="100%">
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>Animated transformer connectors</strong><br /> <strong>Transformer architecture with animated connectors</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p> <p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
<img src="./public/animated_connectors.svg" alt="Transformer Architecture with Animated Connectors" width="480" /> <img src="./public/animated_connectors.svg" alt="Transformer architecture with animated connectors" width="440" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>RAG Technique Diagram</strong><br /> <strong>RAG architecture</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
<img src="./public/rag_prod.svg" alt="RAG Architecture Diagram" width="480" /> <img src="./public/rag_prod.svg" alt="RAG architecture diagram" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Authentication using React and AWS</strong><br /> <strong>Authentication with React and AWS</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p> <p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
<img src="./public/auth.svg" alt="Authentication Architecture Diagram" width="480" /> <img src="./public/auth.svg" alt="Authentication architecture diagram" width="400" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Open Innovation</strong><br /> <strong>Open Innovation model</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="./public/inno.svg" alt="Open Innovation Diagram" width="480" /> <img src="./public/inno.svg" alt="Open Innovation diagram" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>Cat sketch</strong><br /> <strong>Cat sketch</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="./public/cat_demo.svg" alt="Cat Drawing" width="240" /> <img src="./public/cat_demo.svg" alt="Cat drawing" width="200" />
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
## Features ## Use it
- **LLM-Powered Diagram Creation**: Leverage Large Language Models to create and manipulate draw.io diagrams directly through natural language commands ### Online demo
- **Image-Based Diagram Replication**: Upload existing diagrams or images and have the AI replicate and enhance them automatically
- **PDF & Text File Upload**: Upload PDF documents and text files to extract content and generate diagrams from existing documents
- **AI Reasoning Display**: View the AI's thinking process for supported models (OpenAI o1/o3, Gemini, Claude, etc.)
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
## MCP Server Open [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/), nothing to install. The demo has a usage limit; click the settings icon in the chat panel and enter your own provider and API key to lift it. The key stays in your browser and is never sent to the server for storage.
Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via MCP (Model Context Protocol). ### Desktop app
Download the Windows, macOS or Linux installer from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases).
### In your AI agent (MCP)
https://github.com/user-attachments/assets/6f53a67b-ab21-4113-9fee-7fb32f9de678
Through MCP (Model Context Protocol, the protocol AI agents use to call outside tools), Claude Desktop, Cursor, VS Code and others can draw draw.io diagrams directly. Add this to your client's MCP configuration:
```json ```json
{ {
@@ -127,165 +107,64 @@ Use Next AI Draw.io with AI agents like Claude Desktop, Cursor, and VS Code via
} }
``` ```
### Claude Code CLI For Claude Code, one command does it:
```bash ```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
``` ```
Then ask Claude to create diagrams: Then ask the AI for "a flowchart of user authentication with login, MFA and session management" and the diagram appears in your browser as it is drawn. The MCP server has most of the web app's drawing features:
> "Create a flowchart showing user authentication with login, MFA, and session management"
The diagram appears in your browser in real-time! - The same drawing rules and icon libraries (AWS, Azure, GCP, Kubernetes and more)
- The preview is the same canvas as the web app: the AI's changes are outlined, one Ctrl+Z takes them back, and the AI can read the shapes you select
- The draw.io editor ships inside the package, so after install the preview works offline (`DRAWIO_BASE_URL` points it at an external draw.io instead)
- A screenshot tool, so the AI can look at the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
- Ask the AI to undo, and keep your own drawing rules in `~/.next-ai-drawio/instructions.md`
The MCP server includes most of the web app's drawing features: See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more) ## Self-host
- A screenshot tool, so the AI can check the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations. ### Run locally
## Getting Started
### Try it Online
No installation needed! Try the app directly on our demo site:
[![Live Demo](./public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **Bring Your Own API Key**: You can use your own API key to bypass usage limits on the demo site. Click the Settings icon in the chat panel to configure your provider and API key. Your key is stored locally in your browser and is never stored on the server.
### Desktop Application
Download the native desktop app for your platform from the [Releases page](https://github.com/DayuanJiang/next-ai-draw-io/releases):
Supported platforms: Windows, macOS, Linux.
### Run with Docker
[Go to Docker Guide](./docs/en/docker.md)
### Installation
1. Clone the repository:
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
npm install npm install
cp env.example .env.local cp env.example .env.local # add your provider and API key, see "Models and providers" below
```
See the [Provider Configuration Guide](./docs/en/ai-providers.md) for detailed setup instructions for each provider.
2. Run the development server:
```bash
npm run dev npm run dev
``` ```
3. Open [http://localhost:6002](http://localhost:6002) in your browser to see the application. Open [http://localhost:6002](http://localhost:6002).
## Deployment ### One-click deploy
### Deploy to EdgeOne Pages | Platform | How |
| --- | --- |
| Tencent EdgeOne Pages | [![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Deploying there also gives you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai) |
| Vercel | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) Set the same environment variables in the Vercel dashboard as in your `.env.local` |
| Cloudflare Workers | [Cloudflare deploy guide](./docs/en/cloudflare-deploy.md) |
| Docker | [Docker guide](./docs/en/docker.md) |
| Offline or intranet | [Offline deployment](./docs/en/offline-deployment.md) |
You can deploy with one click using [Tencent EdgeOne Pages](https://pages.edgeone.ai/). ### Models and providers
Deploy by this button: 25 providers are supported: AWS Bedrock (default), OpenAI, Anthropic, Google AI, Google Vertex AI, Azure OpenAI, Ollama, OpenRouter, AIHubMix, DeepSeek, SiliconFlow, SGLang, Vercel AI Gateway, Tencent EdgeOne, ByteDance Doubao, ModelScope, Zhipu GLM, Qwen, Qiniu, Kimi, MiniMax, Novita, Xiaomi MiMo, Atlas Cloud, and an OpenAI-compatible preset for any other URL (vLLM, LM Studio, a gateway). The environment variables and notes for each are in the [provider configuration guide](./docs/en/ai-providers.md).
[![Deploy to EdgeOne Pages](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) **Which model**: the task is to produce long text in a strict format (draw.io XML), so pick a capable model; small models tend to produce broken diagrams.
Check out the [Tencent EdgeOne Pages documentation](https://pages.edgeone.ai/document/deployment-overview) for more details. **Several models and the admin panel**: list several model IDs in `AI_MODEL` separated by commas, or configure models from several providers with the `AI_MODELS_CONFIG` environment variable or an `ai-models.json` file, and every user can use them without a key of their own. Set `ADMIN_PASSWORD` and open `/admin` to manage models, access codes, feature switches, observability and quotas from a web page; see the [admin panel guide](./docs/en/admin-panel.md).
Additionally, deploying through Tencent EdgeOne Pages will also grant you a [daily free quota for DeepSeek models](https://pages.edgeone.ai/document/edge-ai). ## Support
### Deploy on Vercel - Questions and ideas: open a [GitHub issue](https://github.com/DayuanJiang/next-ai-draw-io/issues) or email me[at]jiang.jp
- Common problems: [FAQ](./docs/en/FAQ.md)
- If the project is useful to you, consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help keep the demo site running
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) <div align="center">
The easiest way to deploy is using [Vercel](https://vercel.com/new), the creators of Next.js. Be sure to **set the environment variables** in the Vercel dashboard as you did in your local `.env.local` file.
See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### Deploy on Cloudflare Workers
[Go to Cloudflare Deploy Guide](./docs/en/cloudflare-deploy.md)
## Multi-Provider Support
- [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- AWS Bedrock (default)
- OpenAI
- Anthropic
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
All providers except AWS Bedrock and OpenRouter support custom endpoints.
📖 **[Detailed Provider Configuration Guide](./docs/en/ai-providers.md)** - See setup instructions for each provider.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1.
Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice.
## How It Works
The application uses the following technologies:
- **Next.js**: For the frontend framework and routing
- **Vercel AI SDK** (`ai` + `@ai-sdk/*`): For streaming AI responses and multi-provider support
- **react-drawio**: For diagram representation and manipulation
Diagrams are represented as XML that can be rendered in draw.io. The AI processes your commands and generates or modifies this XML accordingly.
## Support & Contact
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
- Email: me[at]jiang.jp
## FAQ
See [FAQ](./docs/en/FAQ.md) for common issues and solutions.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
--- </div>
-402
View File
@@ -1,402 +0,0 @@
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">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
由字节跳动豆包提供支持
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
好消息!感谢{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
字节跳动豆包
</a>
的慷慨赞助,演示站点现已接入强大的{" "}
<span className="font-semibold text-amber-700">
glm-4.7
</span>{" "}
模型,图表生成效果更佳!点击链接注册即可领取{" "}
<span className="font-semibold text-amber-700">
50万免费Token
</span>
,适用于所有模型!
</p>
</div>
{/* Invite Poster */}
<div className="text-center mb-5">
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="/volcengine-invite.png"
alt="火山引擎方舟 Coding Plan"
width={300}
height={400}
className="mx-auto rounded-lg"
/>
</a>
</div>
{/* Bring Your Own Key */}
<div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2">
使用自己的 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编辑前的图表版本
</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>react-drawio</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>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
字节跳动豆包
</a>
</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 mb-4 font-semibold">
特别感谢{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
字节跳动豆包
</a>{" "}
为本站提供 API Token 支持!
</p>
<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>
)
}
-400
View File
@@ -1,400 +0,0 @@
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">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
ByteDance Doubao提供
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
朗報です!
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
様のご支援により、デモサイトでは強力な{" "}
<span className="font-semibold text-amber-700">
glm-4.7
</span>{" "}
モデルを利用できるようになり、より高品質なダイアグラム生成が可能になりました。リンクから登録すると、すべてのモデルで使える{" "}
<span className="font-semibold text-amber-700">
50万トークン
</span>
が無料でもらえます!
</p>
</div>
{/* 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編集前のダイアグラムの以前のバージョンを表示・復元可能
</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>react-drawio</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>
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
</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 mb-4 font-semibold">
デモサイトのAPIトークン使用を支援してくださった{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>{" "}
様に、心より感謝申し上げます。
</p>
<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>
)
}
+468 -225
View File
@@ -1,9 +1,52 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { FaGithub } from "react-icons/fa" import type { ReactNode } from "react"
import { GitHubIcon } from "@/components/icons/github"
import Image from "@/components/image-with-basepath" import Image from "@/components/image-with-basepath"
import type { Locale } from "@/lib/i18n/config"
import { hasLocale } from "@/lib/i18n/dictionaries"
export const metadata: Metadata = { 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
}
const texts: Record<"en" | "zh" | "ja", AboutTexts> = {
en: {
title: "About - Next AI Draw.io", title: "About - Next AI Draw.io",
description: description:
"AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.", "AI-Powered Diagram Creation Tool - Chat, Draw, Visualize. Create AWS, GCP, and Azure architecture diagrams with natural language.",
@@ -15,9 +58,363 @@ export const metadata: Metadata = {
"Azure diagram", "Azure diagram",
"LLM", "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搭載ダイアグラムジェネレーター",
},
} }
export default function About() { // 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
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation */} {/* Navigation */}
@@ -25,32 +422,32 @@ export default function About() {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link <Link
href="/" href={`/${lang}`}
className="text-xl font-bold text-gray-900 hover:text-gray-700" className="text-xl font-bold text-gray-900 hover:text-gray-700"
> >
Next AI Draw.io Next AI Draw.io
</Link> </Link>
<nav className="flex items-center gap-6 text-sm"> <nav className="flex items-center gap-6 text-sm">
<Link <Link
href="/" href={`/${lang}`}
className="text-gray-600 hover:text-gray-900 transition-colors" className="text-gray-600 hover:text-gray-900 transition-colors"
> >
Editor {t.nav.editor}
</Link> </Link>
<Link <Link
href="/about" href={`/${lang}/about`}
className="text-blue-600 font-semibold" className="text-blue-600 font-semibold"
> >
About {t.nav.about}
</Link> </Link>
<a <a
href="https://github.com/DayuanJiang/next-ai-draw-io" href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors" className="text-gray-600 hover:text-gray-900 transition-colors"
aria-label="View on GitHub" aria-label={t.nav.github}
> >
<FaGithub className="w-5 h-5" /> <GitHubIcon className="w-5 h-5" />
</a> </a>
</nav> </nav>
</div> </div>
@@ -66,136 +463,63 @@ export default function About() {
Next AI Draw.io Next AI Draw.io
</h1> </h1>
<p className="text-xl text-gray-600 font-medium"> <p className="text-xl text-gray-600 font-medium">
AI-Powered Diagram Creation Tool - Chat, Draw, {t.tagline}
Visualize
</p> </p>
</div> </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="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="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"> <div className="relative rounded-2xl bg-white/80 backdrop-blur-sm p-6">
{/* Header */}
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 tracking-tight">
Sponsored by ByteDance Doubao
</h3>
</div>
{/* Story */}
<div className="space-y-3 text-sm text-gray-700 leading-relaxed mb-5">
<p>
Great news! Thanks to the generous
sponsorship from{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
, the demo site now uses the powerful{" "}
<span className="font-semibold text-amber-700">
glm-4.7
</span>{" "}
model for better diagram generation! Sign up
via the link to get{" "}
<span className="font-semibold text-amber-700">
500K free tokens
</span>{" "}
for all models!
</p>
</div>
{/* Bring Your Own Key */} {/* Bring Your Own Key */}
<div className="text-center"> <div className="text-center">
<h4 className="text-base font-bold text-gray-900 mb-2"> <h4 className="text-base font-bold text-gray-900 mb-2">
Bring Your Own API Key {t.byok.title}
</h4> </h4>
<p className="text-sm text-gray-600 mb-2 max-w-md mx-auto"> <p className="text-sm text-gray-600 mb-2 max-w-md mx-auto">
You can also use your own API key with any {t.byok.body}
supported provider. Click the Settings icon
in the chat panel to configure your provider
and API key.
</p> </p>
<p className="text-xs text-gray-500 max-w-md mx-auto"> <p className="text-xs text-gray-500 max-w-md mx-auto">
Your key is stored locally in your browser {t.byok.note}
and is never stored on the server.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<p className="text-gray-700"> <p className="text-gray-700">{t.intro}</p>
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 */} {/* Features */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Features {t.featuresHeading}
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li> {t.features.map((feature) => (
<strong>LLM-Powered Diagram Creation</strong>: <li key={feature.title}>
Leverage Large Language Models to create and <strong>{feature.title}</strong>
manipulate draw.io diagrams directly through natural {t.colon}
language commands {feature.text}
</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>Diagram History</strong>: Comprehensive
version control that tracks all changes, allowing
you to view and restore previous versions of your
diagrams before the AI editing
</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> </li>
))}
</ul> </ul>
{/* Examples */} {/* Examples */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Examples {t.examplesHeading}
</h2> </h2>
<p className="text-gray-700 mb-6"> <p className="text-gray-700 mb-6">{t.examplesIntro}</p>
Here are some example prompts and their generated
diagrams:
</p>
<div className="space-y-8"> <div className="space-y-8">
{/* ResNet50 Architecture */} {/* First example on its own */}
<div className="text-center"> <div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
Animated ResNet50 Model Architecture {t.examples[hero.id].title}
</h3> </h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
<strong>Prompt:</strong> Give me an{" "} <strong>Prompt:</strong> {hero.prompt}
<strong>animated</strong> architecture diagram
of the ResNet50 model.
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 inline-block"> <div className="bg-neutral-950 rounded-lg p-4 inline-block">
<Image <Image
src="/resnet50.svg" src={hero.src}
alt="Architecture diagram for ResNet50 model" alt={t.examples[hero.id].alt}
width={480} width={480}
height={360} height={360}
className="mx-auto" className="mx-auto"
@@ -205,134 +529,67 @@ export default function About() {
{/* Diagram Grid */} {/* Diagram Grid */}
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="text-center"> {grid.map((example) => (
<div key={example.id} className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2"> <h3 className="text-lg font-semibold text-gray-900 mb-2">
RAG Technique Diagram {t.examples[example.id].title}
</h3> </h3>
<p className="text-gray-600 text-sm mb-4"> <p className="text-gray-600 text-sm mb-4">
<strong>Prompt:</strong> Generate a RAG <strong>Prompt:</strong>{" "}
architecture diagram for{" "} {example.prompt}
<strong>chat application</strong>. Use
connected diagram for data ingestion
</p> </p>
<div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]"> <div className="bg-neutral-950 rounded-lg p-4 flex items-center justify-center w-full h-[400px]">
<Image <Image
src="/rag_prod.svg" src={example.src}
alt="RAG Architecture Diagram" alt={t.examples[example.id].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">
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} width={480}
height={360} height={360}
className="max-w-full max-h-full object-contain" className="max-w-full max-h-full object-contain"
/> />
</div> </div>
</div> </div>
))}
</div> </div>
</div> </div>
{/* How It Works */} {/* How It Works */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
How It Works {t.howHeading}
</h2> </h2>
<p className="text-gray-700 mb-4"> <p className="text-gray-700 mb-4">{t.howIntro}</p>
The application uses the following technologies:
</p>
<ul className="list-disc pl-6 text-gray-700 space-y-2"> <ul className="list-disc pl-6 text-gray-700 space-y-2">
<li> <li>
<strong>Next.js</strong>: For the frontend framework <strong>Next.js</strong>
and routing {t.colon}
{t.tech.nextjs}
</li> </li>
<li> <li>
<strong>Vercel AI SDK</strong> (<code>ai</code> +{" "} <strong>Vercel AI SDK</strong>
<code>@ai-sdk/*</code>): For streaming AI responses {t.parenOpen}
and multi-provider support <code>ai</code> + <code>@ai-sdk/*</code>
{t.parenClose}
{t.colon}
{t.tech.aiSdk}
</li> </li>
<li> <li>
<strong>react-drawio</strong>: For diagram <strong>draw.io</strong>
representation and manipulation {t.colon}
{t.tech.drawio}
</li> </li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">{t.howOutro}</p>
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 */} {/* Multi-Provider Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Multi-Provider Support {t.providersHeading}
</h2> </h2>
<ul className="list-disc pl-6 text-gray-700 space-y-1"> <ul className="list-disc pl-6 text-gray-700 space-y-1">
<li>{t.doubao}</li>
<li>{t.bedrock}</li>
<li> <li>
<a {t.openai.before}
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" <code>OPENAI_BASE_URL</code>
target="_blank" {t.openai.after}
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>
</li>
<li>AWS Bedrock (default)</li>
<li>
OpenAI / OpenAI-compatible APIs (via{" "}
<code>OPENAI_BASE_URL</code>)
</li> </li>
<li>Anthropic</li> <li>Anthropic</li>
<li>Google AI</li> <li>Google AI</li>
@@ -345,60 +602,47 @@ export default function About() {
<li>ModelScope</li> <li>ModelScope</li>
</ul> </ul>
<p className="text-gray-700 mt-4"> <p className="text-gray-700 mt-4">
Note that <code>claude-sonnet-4-5</code> has trained on {t.modelNote.before}
draw.io diagrams with AWS logos, so if you want to <code>claude-sonnet-4-5</code>
create AWS architecture diagrams, this is the best {t.modelNote.after}
choice.
</p> </p>
{/* Support */} {/* Support */}
<h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4"> <h2 className="text-2xl font-semibold text-gray-900 mt-10 mb-4">
Support &amp; Contact {t.supportHeading}
</h2> </h2>
<p className="text-gray-700 mb-4 font-semibold">
Special thanks to{" "}
<a
href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
ByteDance Doubao
</a>{" "}
for sponsoring the API token usage of the demo site!
</p>
<p className="text-gray-700"> <p className="text-gray-700">
If you find this project useful, please consider{" "} {t.sponsor.before}{" "}
<a <a
href="https://github.com/sponsors/DayuanJiang" href="https://github.com/sponsors/DayuanJiang"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
> >
sponsoring {t.sponsor.link}
</a>{" "} </a>{" "}
to help host the live demo site! {t.sponsor.after}
</p> </p>
<p className="text-gray-700 mt-2"> <p className="text-gray-700 mt-2">
For support or inquiries, please open an issue on the{" "} {t.contact.before}{" "}
<a <a
href="https://github.com/DayuanJiang/next-ai-draw-io" href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
> >
GitHub repository {t.contact.link}
</a>{" "} </a>{" "}
or contact: me[at]jiang.jp {t.contact.after}
</p> </p>
{/* CTA */} {/* CTA */}
<div className="mt-12 text-center"> <div className="mt-12 text-center">
<Link <Link
href="/" href={`/${lang}`}
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors" className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
> >
Open Editor {t.openEditor}
</Link> </Link>
</div> </div>
</article> </article>
@@ -408,8 +652,7 @@ export default function About() {
<footer className="bg-white border-t border-gray-200 mt-16"> <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"> <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"> <p className="text-center text-gray-600 text-sm">
Next AI Draw.io - Open Source AI-Powered Diagram {t.footer}
Generator
</p> </p>
</div> </div>
</footer> </footer>
+6 -17
View File
@@ -1,3 +1,5 @@
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 { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config" import type { ProviderName } from "@/lib/types/model-config"
@@ -5,7 +7,10 @@ export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ───────────────────────────────────────────────────── // ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string } // 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 function isSecretValue(v: unknown): v is SecretValue { export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v return typeof v === "object" && v !== null && "isSet" in v
@@ -24,22 +29,6 @@ export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : "" 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 // Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider { export interface EnvProvider {
name: string name: string
+10 -13
View File
@@ -33,14 +33,9 @@ import {
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { STORAGE_KEYS } from "@/lib/storage" import { STORAGE_KEYS } from "@/lib/storage"
import { import { generateId } from "@/lib/types/model-config"
FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
type AdminProvider, type AdminProvider,
@@ -72,8 +67,10 @@ function ProviderDetail({
Record<string, { ok: boolean; message: string }> Record<string, { ok: boolean; message: string }>
>({}) >({})
const info = PROVIDER_INFO[provider.provider] const info = PROVIDERS[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter( // Only providers that cannot list their models have a list here; for
// the others the admin types the model id
const suggestions = (info.models ?? []).filter(
(m) => !provider.models.includes(m), (m) => !provider.models.includes(m),
) )
@@ -500,7 +497,7 @@ export function ModelsSection({
> >
<ProviderLogo provider={p.provider} /> <ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate"> <span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label} {p.name || PROVIDERS[p.provider].label}
</span> </span>
{p.isDefault && ( {p.isDefault && (
<Star <Star
@@ -526,13 +523,13 @@ export function ModelsSection({
{dict.modelConfig.addProvider} {dict.modelConfig.addProvider}
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-72"> <SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map( {(Object.keys(PROVIDERS) as ProviderName[]).map(
(p) => { (p) => {
// Global-credential providers already in // Global-credential providers already in
// the env config can't be added here — // the env config can't be added here —
// panel credentials would override theirs // panel credentials would override theirs
const envBlocked = const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) && !!PROVIDERS[p].fixedCredentials &&
envProviders.some( envProviders.some(
(e) => e.provider === p, (e) => e.provider === p,
) )
@@ -544,7 +541,7 @@ export function ModelsSection({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProviderLogo provider={p} /> <ProviderLogo provider={p} />
{PROVIDER_INFO[p].label} {PROVIDERS[p].label}
{envBlocked && ( {envBlocked && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{ {
-27
View File
@@ -3,13 +3,6 @@ import { useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry" import type { SettingDef } from "@/lib/admin/settings-registry"
@@ -206,26 +199,6 @@ export function SettingField({
) )
break 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": case "secret":
control = ( control = (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
+31 -20
View File
@@ -1,19 +1,26 @@
import { GoogleAnalytics } from "@next/third-parties/google" import { GoogleAnalytics } from "@next/third-parties/google"
import type { Metadata, Viewport } from "next" import type { Metadata, Viewport } from "next"
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google" import { Instrument_Sans, JetBrains_Mono } from "next/font/google"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import Script from "next/script"
import { LocaleProvider } from "@/components/canvas/locale-context"
import { AppToaster } from "@/components/workspace/app-toaster"
import { DiagramProvider } from "@/contexts/diagram-context" import { DiagramProvider } from "@/contexts/diagram-context"
import { DictionaryProvider } from "@/hooks/use-dictionary" import { DictionaryProvider } from "@/hooks/use-dictionary"
import type { Locale } from "@/lib/i18n/config" import type { Locale } from "@/lib/i18n/config"
import { i18n } from "@/lib/i18n/config" import { i18n } from "@/lib/i18n/config"
import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries" import { getDictionary, hasLocale } from "@/lib/i18n/dictionaries"
import { THEME_INIT_SCRIPT } from "@/stores/settings-store"
import "../globals.css" import "../globals.css"
const plusJakarta = Plus_Jakarta_Sans({ const instrumentSans = Instrument_Sans({
variable: "--font-sans", variable: "--font-latin",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"], weight: ["400", "500", "600", "700"],
// Chinese and Japanese text falls through to system fonts, in the
// order globals.css picks for the page's language
fallback: [],
}) })
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
@@ -34,6 +41,13 @@ export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ lang: locale })) 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 // Generate metadata per locale
export async function generateMetadata({ export async function generateMetadata({
params, params,
@@ -41,22 +55,19 @@ export async function generateMetadata({
params: Promise<{ lang: string }> params: Promise<{ lang: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { lang: rawLang } = await params const { lang: rawLang } = await params
const lang = ( const lang: Locale = hasLocale(rawLang) ? rawLang : i18n.defaultLocale
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
// Default to English metadata
const titles: Record<Locale, string> = { const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator", en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator", zh: "Next AI Draw.io - AI 驱动的图表生成器",
ja: "Next AI Draw.io - AI-powered diagram generator", ja: "Next AI Draw.io - AI 搭載ダイアグラム生成ツール",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器", "zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
} }
const descriptions: Record<Locale, string> = { 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.", en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.", zh: "使用 AI 创建 AWS 架构图、流程图和技术图表。免费在线工具,整合 draw.io 与 AI 辅助,轻松创建专业图表。",
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.", ja: "AI で AWS アーキテクチャ図、フローチャート、技術図を作成。draw.io と AI アシスタントを統合した無料オンラインツールで、プロ品質の図を簡単に作成できます。",
"zh-Hant": "zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。", "使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
} }
@@ -85,14 +96,7 @@ export async function generateMetadata({
type: "website", type: "website",
url: "https://next-ai-drawio.jiang.jp", url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io", siteName: "Next AI Draw.io",
locale: locale: OG_LOCALE[lang],
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
images: [ images: [
{ {
url: "/architecture.png", url: "/architecture.png",
@@ -165,17 +169,24 @@ export default async function RootLayout({
return ( return (
<html lang={validLang} suppressHydrationWarning> <html lang={validLang} suppressHydrationWarning>
<head> <head>
{/* Apply dark mode before the first paint (no white flash) */}
<Script id="theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
<script <script
type="application/ld+json" type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/> />
</head> </head>
<body <body
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`} className={`${instrumentSans.variable} ${jetbrainsMono.variable} antialiased`}
> >
<DictionaryProvider dictionary={dictionary}> <DictionaryProvider dictionary={dictionary}>
<LocaleProvider locale={validLang}>
<DiagramProvider>{children}</DiagramProvider> <DiagramProvider>{children}</DiagramProvider>
</LocaleProvider>
</DictionaryProvider> </DictionaryProvider>
<AppToaster />
</body> </body>
{process.env.NEXT_PUBLIC_GA_ID && ( {process.env.NEXT_PUBLIC_GA_ID && (
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} /> <GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_ID} />
+5 -250
View File
@@ -1,256 +1,11 @@
"use client" import { Suspense } from "react"
import { usePathname, useRouter } from "next/navigation" import { Workspace } from "@/components/workspace/workspace"
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels"
import ChatPanel from "@/components/chat-panel"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
export default function Home() { export default function Home() {
const { // The workspace reads ?session= from the URL, which needs a Suspense boundary
drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter()
const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh")
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
// Load preferences from localStorage after mount
useEffect(() => {
// Restore saved locale and redirect if needed
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
const pathParts = pathname.split("/").filter(Boolean)
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
// Keep the query (e.g. ?session=) and hash
const { search, hash } = window.location
router.replace(`/${pathParts.join("/")}${search}${hash}`)
return // Wait for redirect
}
}
const savedUi = localStorage.getItem("drawio-theme")
if (isDrawioTheme(savedUi)) {
setDrawioUi(savedUi)
}
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
if (savedDarkMode !== null) {
const isDark = savedDarkMode === "true"
setDarkMode(isDark)
document.documentElement.classList.toggle("dark", isDark)
} else {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches
setDarkMode(prefersDark)
document.documentElement.classList.toggle("dark", prefersDark)
}
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
setIsLoaded(true)
}, [pathname, router])
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = () => {
const newValue = !darkMode
setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady()
}
const handleDrawioUiChange = (theme: DrawioTheme) => {
localStorage.setItem("drawio-theme", theme)
setDrawioUi(theme)
setIsDrawioReady(false)
resetDrawioReady()
}
// Check mobile. No panel is remounted when crossing the breakpoint, so
// the draw.io ready state and the chat's turn stay as they are.
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
// Give the chat panel the size of this side of the breakpoint. It is
// open on both sides: the mobile panel cannot be collapsed, and one
// collapsed on desktop comes back open
useEffect(() => {
chatPanelRef.current?.resize(isMobile ? 50 : 33)
setIsChatVisible(true)
}, [isMobile])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
if (panel) {
if (panel.isCollapsed()) {
panel.expand()
setIsChatVisible(true)
} else {
panel.collapse()
setIsChatVisible(false)
}
}
}
// Keyboard shortcut for toggling chat panel
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
event.preventDefault()
toggleChatPanel()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
return ( return (
<div className="h-screen bg-background relative overflow-hidden"> <Suspense fallback={<div className="fixed inset-0 bg-canvas" />}>
<ResizablePanelGroup <Workspace />
id="main-panel-group"
direction={isMobile ? "vertical" : "horizontal"}
className="h-full"
>
<ResizablePanel
id="drawio-panel"
defaultSize={isMobile ? 50 : 67}
minSize={20}
>
<div
className={`h-full relative ${
isMobile ? "p-1" : "p-2"
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef}
autosave
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark:
darkMode || drawioUi === "dark",
// draw.io names Traditional Chinese "zh-tw"
lang:
currentLang === "zh-Hant"
? "zh-tw"
: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
}),
}}
/>
</div>
)}
{(!isLoaded || !isDrawioReady) && (
<div className="h-full w-full bg-background flex items-center justify-center">
<span className="text-muted-foreground">
Draw.io panel is loading...
</span>
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Chat Panel */}
<ResizablePanel
id="chat-panel"
ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33}
minSize={isMobile ? 20 : 15}
maxSize={isMobile ? 80 : 50}
collapsible={!isMobile}
collapsedSize={isMobile ? 0 : 3}
onCollapse={() => setIsChatVisible(false)}
onExpand={() => setIsChatVisible(true)}
>
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
<Suspense
fallback={
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
Loading chat...
</div>
}
>
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onDrawioUiChange={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
/>
</Suspense> </Suspense>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
) )
} }
-4
View File
@@ -53,10 +53,6 @@ function validateValue(def: SettingDef, value: string): string | null {
return value === "true" || value === "false" return value === "true" || value === "false"
? null ? null
: 'Must be "true" or "false"' : 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default: default:
return null return null
} }
+11 -24
View File
@@ -1,4 +1,3 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth" import { checkAdminAuth } from "@/lib/admin/auth"
import { import {
AdminProviderSchema, AdminProviderSchema,
@@ -6,6 +5,7 @@ import {
mergeSecrets, mergeSecrets,
} from "@/lib/admin/providers" } from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers" import { globalBaseUrl } from "@/lib/ai-providers"
import { testModelConnection } from "@/lib/test-model-connection"
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -48,37 +48,24 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [], 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) const serverUrl = globalBaseUrl(resolved.provider)
return validateModel( return testModelConnection(
new Request(new URL("/api/validate-model", req.url), { req,
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, provider: resolved.provider,
apiKey: resolved.apiKey, 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, baseUrl: resolved.baseUrl || serverUrl,
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
modelId: body.modelId, modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId, awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey, awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion, awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey, vertexApiKey: resolved.vertexApiKey,
}), },
}), !resolved.baseUrl && !!serverUrl,
) )
} }
+102 -321
View File
@@ -1,33 +1,33 @@
import { import {
APICallError, APICallError,
convertToModelMessages, convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError, InvalidToolInputError,
isFileUIPart,
isTextUIPart,
pruneMessages,
safeValidateUIMessages,
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai" } from "ai"
import { jsonrepair } from "jsonrepair" import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { import {
CACHE_POINT, CACHE_POINT,
edgeOneEndpoint, edgeOneEndpoint,
getAIModel, getAIModel,
getServerProvider, getServerProvider,
SINGLE_SYSTEM_PROVIDERS,
supportsPromptCaching, supportsPromptCaching,
usesServerCredentials, usesServerCredentials,
usesServerEndpoint, usesServerEndpoint,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import { import {
dropInvalidToolCalls, dropInvalidToolCalls,
fixToolInputJson, fixToolInputJson,
replaceHistoricalToolInputs, formatSelectionContext,
validateFileParts, validateFileParts,
} from "@/lib/chat-helpers" } from "@/lib/chat-helpers"
import type { ChatUIMessage } from "@/lib/chat-message"
import { chatTools } from "@/lib/chat-tools"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params" import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import { import {
checkAndIncrementRequest, checkAndIncrementRequest,
@@ -46,6 +46,7 @@ import {
resolveMaxOutputTokens, resolveMaxOutputTokens,
withOutputTokenLimitFallback, withOutputTokenLimitFallback,
} from "@/lib/output-token-limit" } from "@/lib/output-token-limit"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { import {
type FlattenedServerModel, type FlattenedServerModel,
findServerModelById, findServerModelById,
@@ -54,53 +55,38 @@ import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config" import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { hasCells } from "@/packages/mcp-server/src/pages.ts" import { foldCells } from "@/packages/mcp-server/src/compact-cells.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
// No explicit cap: a reasoning model can spend minutes planning before it emits // 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, // 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. // 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 // Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>() const modelStreamResponses = new WeakSet<Response>()
// A Request's signal follows the client's disconnect only while the Request
// object itself is alive: once it is garbage collected, the abort is lost
// (nodejs/undici#3644) and a stopped chat would run on at the provider.
// Each request is kept as long as its answer streams.
const requestOfResponse = new WeakMap<Response, Request>()
// Inner handler function // Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true" 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> { async function handleChatRequest(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req) const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite if (crossSite) return crossSite
@@ -109,7 +95,19 @@ async function handleChatRequest(req: Request): Promise<Response> {
if (accessDenied) return accessDenied if (accessDenied) return accessDenied
const body = await req.json() const body = await req.json()
const { messages, xml, previousXml, sessionId } = body // 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 customSystemMessage = const customSystemMessage =
typeof body.customSystemMessage === "string" typeof body.customSystemMessage === "string"
? body.customSystemMessage.slice(0, 5000) ? body.customSystemMessage.slice(0, 5000)
@@ -128,9 +126,8 @@ 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) // Find the last USER message, not just the last message (which could be assistant in multi-step tool flows)
const lastUserMessage = [...messages] const lastUserMessage = [...messages]
.reverse() .reverse()
.find((m: any) => m.role === "user") .find((m) => m.role === "user")
const userInputText = const userInputText = lastUserMessage?.parts.find(isTextUIPart)?.text || ""
lastUserMessage?.parts?.find((p: any) => p.type === "text")?.text || ""
// Update Langfuse trace with input, session, and user // Update Langfuse trace with input, session, and user
setTraceInput({ setTraceInput({
@@ -146,23 +143,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
} }
// === FILE VALIDATION END === // === 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 // Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider") const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url") let baseUrl = req.headers.get("x-ai-base-url")
@@ -193,14 +173,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
// A server model's provider comes from its config: for one set up in // A server model's provider comes from its config: for one set up in
// the admin panel the header holds the provider name's slug. Without // the admin panel the header holds the provider name's slug. Without
// either, the server's own AI_PROVIDER. // either, the server's own AI_PROVIDER.
const isEdgeOne = const requestedProvider =
(serverModelConfig.provider || provider || getServerProvider()) === serverModelConfig.provider || provider || getServerProvider()
"edgeone" // A provider without credentials (EdgeOne) is this deployment's own
// function, whatever URL the request names: another host would get the
// EdgeOne is this deployment's own function, whatever URL the request // user's EdgeOne cookies, and the quota counts it. Absolute, as the SDK
// names: another host would get the user's EdgeOne cookies, and the // needs.
// quota counts it. Absolute, as the SDK needs. const onSiteFunction =
if (isEdgeOne) baseUrl = edgeOneEndpoint(req) !!requestedProvider &&
Object.hasOwn(PROVIDERS, requestedProvider) &&
PROVIDERS[requestedProvider as ProviderName].credential === "none"
if (onSiteFunction) baseUrl = edgeOneEndpoint(req)
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may // Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
// not point the server at a private or internal address // not point the server at a private or internal address
@@ -215,12 +198,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
const cookieHeader = req.headers.get("cookie") const cookieHeader = req.headers.get("cookie")
const clientOverrides = { const clientOverrides = {
// Server model provider takes precedence over client header; EdgeOne // Server model provider takes precedence over client header; the
// named only in AI_PROVIDER is named here, for its own base URL // site's function named only in AI_PROVIDER is named here, for its
// own base URL
provider: provider:
serverModelConfig.provider || serverModelConfig.provider ||
provider || provider ||
(isEdgeOne ? "edgeone" : null), (onSiteFunction ? requestedProvider : null),
baseUrl, baseUrl,
apiKey: req.headers.get("x-ai-api-key"), apiKey: req.headers.get("x-ai-api-key"),
// A server model runs the model it was configured with, whatever the header says // A server model runs the model it was configured with, whatever the header says
@@ -236,7 +220,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
vertexApiKey: req.headers.get("x-vertex-api-key"), vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication, and the access code, // Pass cookies for EdgeOne Pages authentication, and the access code,
// which the EdgeOne function checks too // which the EdgeOne function checks too
...(isEdgeOne && { ...(onSiteFunction && {
headers: { headers: {
...(cookieHeader && { cookie: cookieHeader }), ...(cookieHeader && { cookie: cookieHeader }),
"x-access-code": req.headers.get("x-access-code") || "", "x-access-code": req.headers.get("x-access-code") || "",
@@ -338,16 +322,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
`[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`, `[Prompt Caching] ${shouldCache ? "ENABLED" : "DISABLED"} for model: ${modelId}`,
) )
// Get the appropriate system prompt based on model (extended for Opus/Haiku 4.5) // System prompt, with the model name filled in and the style section chosen
const systemMessage = getSystemPrompt(modelId, minimalStyle) const systemMessage = getSystemPrompt(modelId, minimalStyle)
const finalSystemMessage = customSystemMessage const finalSystemMessage = customSystemMessage
? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}` ? `${systemMessage}\n\n## Custom Instructions\n${customSystemMessage}`
: systemMessage : systemMessage
// Extract file parts (images) from the last user message // Extract file parts (images) from the last user message
const fileParts = const fileParts = lastUserMessage?.parts.filter(isFileUIPart) ?? []
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Note: we used to pre-emptively reject images for models we guessed were // Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models // text-only (by name matching). That heuristic misfired on newer models
@@ -360,98 +342,37 @@ async function handleChatRequest(req: Request): Promise<Response> {
${userInputText} ${userInputText}
"""` """`
// Convert UIMessages to ModelMessages and add system message. A tool // Convert UIMessages to ModelMessages. A tool call that never got its
// call that never got its result (the user stopped while it ran) is // result (the user stopped while it ran) is left out: the SDK would
// left out: the SDK would refuse this and every later request of the // refuse this and every later request of the chat
// chat (MissingToolResultsError) // (MissingToolResultsError). Messages left without content (a notice
const modelMessages = await convertToModelMessages(messages, { // 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, ignoreIncompleteToolCalls: true,
}),
}) })
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure // DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) { if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log( console.log(
`[route.ts] Message ${idx} role:`, "[route.ts] incoming",
msg.role, JSON.stringify(describeMessages(messages)),
"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) // 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 // 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. // JSON object, and every provider rejects a tool result whose call is gone.
enhancedMessages = dropInvalidToolCalls(enhancedMessages) let enhancedMessages = dropInvalidToolCalls(modelMessages)
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model // DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) { if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log( console.log(
`[route.ts] ModelMsg ${idx} role:`, "[route.ts] to model",
msg.role, JSON.stringify(describeMessages(enhancedMessages)),
"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) // Update the last message with user input only (XML moved to separate cached system message)
@@ -503,7 +424,7 @@ ${userInputText}
// Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio) // Also merge for OpenAI-compatible providers with custom base URLs (e.g. vLLM, LMStudio)
// because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages // because open-source model chat templates (Qwen, Llama, etc.) typically reject multiple system messages
const isCustomOpenAIEndpoint = const isCustomOpenAIEndpoint =
resolvedProvider === "openai" && PROVIDERS[resolvedProvider].sdk === "openai" &&
!!( !!(
baseUrl || baseUrl ||
process.env.OPENAI_BASE_URL || process.env.OPENAI_BASE_URL ||
@@ -511,23 +432,26 @@ ${userInputText}
process.env[serverModelConfig.baseUrlEnv]) process.env[serverModelConfig.baseUrlEnv])
) )
const isSingleSystemProvider = const isSingleSystemProvider =
SINGLE_SYSTEM_PROVIDERS.has(resolvedProvider) || isCustomOpenAIEndpoint !!PROVIDERS[resolvedProvider].singleSystem || isCustomOpenAIEndpoint
const selectionContext = formatSelectionContext(body.selectedCells)
// The model reads the diagram in the compact notation it writes
// (compact-cells.ts); the canvas itself keeps the full XML
const xmlContext = `${ const xmlContext = `${
previousXml previousXml
? `Previous diagram XML (before user's last message): ? `Previous diagram XML (before user's last message):
"""xml """xml
${previousXml} ${foldCells(previousXml)}
""" """
` `
: "" : ""
}Current diagram XML (AUTHORITATIVE - the source of truth): }Current diagram XML (AUTHORITATIVE - the source of truth), shown in the same compact notation you write (shapes with x, y, w, h; edges with source and target):
"""xml """xml
${xml || ""} ${foldCells(xml || "")}
""" """
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.` IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.${selectionContext ? `\n\n${selectionContext}` : ""}`
const systemMessages = isSingleSystemProvider const systemMessages = isSingleSystemProvider
? [ ? [
@@ -553,13 +477,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const allMessages = [...systemMessages, ...enhancedMessages] const allMessages = [...systemMessages, ...enhancedMessages]
// Set by onAbort, which records the finished steps' tokens itself
let stopped = false
const result = streamText({ const result = streamText({
model, model,
// The system messages carry cache points, so they go in messages. // The system messages carry cache points, so they go in messages
// A client's own system messages have string content and were // (the client's own system messages were dropped above)
// dropped by the empty-content filter above.
allowSystemInMessages: true, allowSystemInMessages: true,
abortSignal: req.signal, abortSignal: req.signal,
// Must be sent: unset means the provider's own default, and Bedrock's is // Must be sent: unset means the provider's own default, and Bedrock's is
@@ -606,161 +527,31 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
}, },
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs ...(providerOptions && { providerOptions }), // This now includes all reasoning configs
// Langfuse telemetry config (returns undefined if not configured) // Langfuse telemetry (undefined when not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({ experimental_telemetry: getTelemetryConfig({
sessionId: validSessionId, sessionId: validSessionId,
userId, userId,
}), }),
}), // The quota counts each step as it ends: a later step that fails or
onFinish: ({ text, totalUsage }) => { // 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 }) => {
// AI SDK 6 telemetry auto-reports token usage on its spans // AI SDK 6 telemetry auto-reports token usage on its spans
setTraceOutput(text) 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 // onFinish is skipped when the stream fails or is aborted, so end the trace here
onError: ({ error }) => { onError: ({ error }) => {
console.error(error) // what AI SDK does without an onError console.error(error) // what AI SDK does without an onError
endTrace() endTrace()
}, },
onAbort: ({ steps }) => { onAbort: () => endTrace(),
stopped = true tools: chatTools,
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 mxCell elements - wrapper tags and root cells are added automatically.
VALIDATION RULES (XML will be rejected if violated):
1. Generate ONLY 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. Every mxCell needs a valid parent attribute (use "1" for top-level)
6. Escape special chars in values: &lt; &gt; &amp; &quot;
Example (generate ONLY this - no wrapper tags):
${SWIMLANE_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 must be a complete mxCell element including mxGeometry.
⚠️ 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=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
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 ';" vertex="1">...' 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 && { ...(process.env.TEMPERATURE !== undefined && {
temperature: parseFloat(process.env.TEMPERATURE), temperature: parseFloat(process.env.TEMPERATURE),
}), }),
@@ -772,19 +563,9 @@ Call this tool to get shape names and usage syntax for a specific library.`,
// Keyless endpoints keep theirs: the desktop app's Ollama is the // Keyless endpoints keep theirs: the desktop app's Ollama is the
// user's own, and EdgeOne's text is our function's explanation. // user's own, and EdgeOne's text is our function's explanation.
onError: (error) => streamErrorText(error, onServerCredentials), 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) modelStreamResponses.add(response)
requestOfResponse.set(response, req)
return response return response
} }
@@ -821,7 +602,7 @@ async function safeHandler(req: Request): Promise<Response> {
} catch (error) { } catch (error) {
response = handleError(error) response = handleError(error)
} }
// Early returns, cache hits and errors never reach streamText's callbacks, // Early returns and errors never reach streamText's callbacks,
// so their Langfuse trace has to be ended here // so their Langfuse trace has to be ended here
if (!modelStreamResponses.has(response)) endTrace() if (!modelStreamResponses.has(response)) endTrace()
return response return response
-321
View File
@@ -1,321 +0,0 @@
# 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,8 +3,5 @@ import { NextResponse } from "next/server"
export async function GET() { export async function GET() {
return NextResponse.json({ return NextResponse.json({
accessCodeRequired: !!process.env.ACCESS_CODE_LIST, 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,
}) })
} }
+8 -60
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"
import { z } from "zod" import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse" import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({ const feedbackSchema = z.object({
@@ -37,74 +36,23 @@ export async function POST(req: Request) {
const userId = getUserIdFromRequest(req) const userId = getUserIdFromRequest(req)
try { try {
// Find the most recent chat trace for this session to attach the score to // Score the session's latest chat trace; without one, a standalone
const tracesResponse = await langfuse.api.trace.list({ // feedback trace carries the score
await scoreLatestTrace(langfuse, {
sessionId, sessionId,
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", name: "user-feedback",
sessionId, value: feedback === "good" ? 1 : 0,
comment: `User gave ${feedback} feedback`,
fallbackTrace: {
name: "user-feedback",
userId, userId,
input: { messageId, feedback }, input: { messageId, feedback },
metadata: { metadata: {
source: "feedback-button", source: "feedback-button",
note: "standalone - no chat trace found", 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 }) return Response.json({ success: true, logged: true })
} catch (error) { } catch (error) {
+5 -29
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"
import { z } from "zod" import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse" import { getLangfuseClient, scoreLatestTrace } from "@/lib/langfuse"
const saveSchema = z.object({ const saveSchema = z.object({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
@@ -33,39 +32,16 @@ export async function POST(req: Request) {
} }
try { try {
const timestamp = new Date().toISOString() // Flag the session's latest chat trace as saved; a session without a
// trace (no chat yet) is skipped
// Find the most recent chat trace for this session to attach the save flag const logged = await scoreLatestTrace(langfuse, {
const tracesResponse = await langfuse.api.trace.list({
sessionId, sessionId,
limit: 1,
})
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", name: "diagram-saved",
value: 1, value: 1,
comment: `User saved diagram as ${filename}.${format}`, 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 }) return Response.json({ success: true, logged })
} catch (error) { } catch (error) {
console.error("Langfuse save error:", error) console.error("Langfuse save error:", error)
return Response.json( return Response.json(
+3 -9
View File
@@ -6,23 +6,16 @@ import {
listProviderModels, listProviderModels,
ModelListError, ModelListError,
} from "@/lib/provider-models" } from "@/lib/provider-models"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { import {
allowPrivateUrls, allowPrivateUrls,
isPrivateUrl, isPrivateUrl,
RedirectRefusedError, RedirectRefusedError,
redirectGuardedFetch, redirectGuardedFetch,
} from "@/lib/ssrf-protection" } from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs" export const runtime = "nodejs"
// Public lists need no key
const NO_KEY_NEEDED = new Set<ProviderName>([
"ollama",
"openrouter",
"aihubmix",
])
/** /**
* The models a provider offers, for the "Fetch models" button in model * The models a provider offers, for the "Fetch models" button in model
* settings. Answers { models: null } for providers that cannot list them, * settings. Answers { models: null } for providers that cannot list them,
@@ -47,7 +40,8 @@ export async function POST(req: Request) {
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) { if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 }) return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
} }
if (!apiKey && !NO_KEY_NEEDED.has(provider)) { // Public and local lists need no key
if (!apiKey && !PROVIDERS[provider].listNeedsNoKey) {
return NextResponse.json( return NextResponse.json(
{ error: "API key is required" }, { error: "API key is required" },
{ status: 400 }, { status: 400 },
+1 -4
View File
@@ -7,8 +7,5 @@ export const dynamic = "force-dynamic"
export async function GET() { export async function GET() {
const models = await loadFlattenedServerModels() const models = await loadFlattenedServerModels()
return NextResponse.json({ return NextResponse.json({ models })
models,
hasConfig: models.length > 0,
})
} }
+20 -26
View File
@@ -1,9 +1,9 @@
/** /**
* API endpoint for VLM-based diagram validation. * API endpoint for VLM-based diagram validation.
* Accepts a PNG image and streams validation results using useObject-compatible format. * Accepts a PNG image and answers with the validation result as JSON.
*/ */
import { Output, streamText } from "ai" import { generateText, Output } from "ai"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers" import { getValidationModel } from "@/lib/ai-providers"
import { import {
@@ -11,6 +11,7 @@ import {
isQuotaEnabled, isQuotaEnabled,
recordTokenUsage, recordTokenUsage,
} from "@/lib/dynamo-quota-manager" } from "@/lib/dynamo-quota-manager"
import { getTelemetryConfig } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts" import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import { import {
@@ -35,13 +36,6 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [], 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> { export async function POST(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req) const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite if (crossSite) return crossSite
@@ -53,7 +47,7 @@ export async function POST(req: Request): Promise<Response> {
// Check if VLM validation is enabled (default: true) // Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false" const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) { if (!enableValidation) {
return createStreamingResponse(DEFAULT_VALID_RESULT) return Response.json(DEFAULT_VALID_RESULT)
} }
const body: ValidateDiagramRequest = await req.json() const body: ValidateDiagramRequest = await req.json()
@@ -123,7 +117,7 @@ export async function POST(req: Request): Promise<Response> {
error, error,
) )
// Return valid if no vision model is configured // Return valid if no vision model is configured
return createStreamingResponse(DEFAULT_VALID_RESULT) return Response.json(DEFAULT_VALID_RESULT)
} }
// Parse timeout with validation (minimum 1000ms, default 10000ms) // Parse timeout with validation (minimum 1000ms, default 10000ms)
@@ -133,8 +127,7 @@ export async function POST(req: Request): Promise<Response> {
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10), parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000 ) || 10000
// Stream the VLM response for useObject consumption const { output, totalUsage } = await generateText({
const result = streamText({
model, model,
output: Output.object({ schema: ValidationResultSchema }), output: Output.object({ schema: ValidationResultSchema }),
system: VALIDATION_SYSTEM_PROMPT, system: VALIDATION_SYSTEM_PROMPT,
@@ -154,24 +147,25 @@ export async function POST(req: Request): Promise<Response> {
}, },
], ],
maxOutputTokens: 1024, maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout), // Ends with the request: the user pressed Stop, or left
onFinish: ({ output, totalUsage }) => { abortSignal: req.signal,
if (countsQuota && totalUsage) { timeout,
recordTokenUsage( // Its usage and cost show in Langfuse next to the chat's
experimental_telemetry: getTelemetryConfig({
functionId: "validate-diagram",
sessionId,
userId, userId,
(totalUsage.inputTokens || 0) + }),
(totalUsage.outputTokens || 0), })
) if (countsQuota) {
await recordTokenUsage(userId, totalUsage.totalTokens ?? 0)
} }
if (sessionId && output) { if (sessionId) {
console.log( console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`, `[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
) )
} }
}, return Response.json(output)
})
return result.toTextStreamResponse()
} catch (error) { } catch (error) {
// Log with session context if available // Log with session context if available
const errorMessage = const errorMessage =
@@ -179,6 +173,6 @@ export async function POST(req: Request): Promise<Response> {
console.error("[validate-diagram] Error:", errorMessage) console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user // On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT) return Response.json(DEFAULT_VALID_RESULT)
} }
} }
+12 -229
View File
@@ -1,246 +1,29 @@
import { streamText, tool } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code" import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import { import {
edgeOneEndpoint, type TestModelRequest,
getAIModel, testModelConnection,
globalBaseUrl, } from "@/lib/test-model-connection"
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" export const runtime = "nodejs"
interface ValidateRequest { // The Settings dialog's Test button: a model on the user's own credentials
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) { export async function POST(req: Request) {
const crossSite = rejectCrossSite(req) const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite if (crossSite) return crossSite
// Lets the server send requests to arbitrary URLs, so require the access // Lets the server send requests to arbitrary URLs, so require the access code
// code, or the admin password (the admin panel's Test button)
const accessError = checkAccessCode(req) const accessError = checkAccessCode(req)
if (accessError && checkAdminAuth(req)) return accessError if (accessError) return accessError
let body: TestModelRequest
try { try {
const body: ValidateRequest = await req.json() body = await req.json()
const { } catch {
provider,
apiKey,
modelId,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
awsSessionToken,
// Note: Express Mode only needs vertexApiKey
vertexApiKey,
} = body
if (!provider || !modelId) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Provider and model ID are required" }, { valid: false, error: "Invalid JSON body" },
{ status: 400 }, { status: 400 },
) )
} }
// EdgeOne is this site's own function, as in the chat; the admin // A URL in the request is one the user typed, never the server's own
// panel's Test sends no URL, and a relative one cannot be fetched return testModelConnection(req, body, false)
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 (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
return NextResponse.json(
{
valid: false,
error: "AWS credentials (Access Key ID, Secret Access Key, 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, code, error: message },
{ status: 200 }, // Return 200 so client can read error message
)
}
} }
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+201 -307
View File
@@ -8,21 +8,8 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-latin), var(--font-cjk), system-ui, sans-serif;
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
@@ -43,161 +30,16 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
}
:root { /* App-specific tokens */
--radius: 0.75rem; --color-canvas: var(--canvas);
--color-faint: var(--faint);
/* Clean Light Modern Palette */ --color-marker: var(--marker);
--background: oklch(0.985 0.002 240); --color-marker-soft: var(--marker-soft);
--foreground: oklch(0.23 0.02 260); --color-marker-ink: var(--marker-ink);
--card: oklch(1 0 0);
--card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */
--secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */
--muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.5 0.02 260);
/* Soft lavender accent */
--accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270);
/* Muted rose destructive */
--destructive: oklch(0.45 0.12 10);
/* Subtle borders */
--border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260);
/* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.7 0.18 45);
--chart-4: oklch(0.6 0.2 330);
--chart-5: oklch(0.5 0.15 200);
/* Sidebar */
--sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265);
}
.dark {
--background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260);
--card: oklch(0.2 0.015 260);
--card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.2 0.015 260);
--popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.7 0.16 265);
--primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.9 0.01 260);
--muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.3 0.04 280);
--accent-foreground: oklch(0.9 0.03 270);
--destructive: oklch(0.55 0.12 10);
--border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260);
--ring: oklch(0.7 0.16 265);
--chart-1: oklch(0.7 0.16 265);
--chart-2: oklch(0.7 0.13 170);
--chart-3: oklch(0.75 0.16 45);
--chart-4: oklch(0.7 0.18 330);
--chart-5: oklch(0.6 0.13 200);
--sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.7 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.9 0.02 265);
--sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.7 0.16 265);
}
/* ============================================
REFINED MINIMAL DESIGN SYSTEM
============================================ */
:root {
/* Surface layers for depth */
--surface-0: oklch(1 0 0);
--surface-1: oklch(0.985 0.002 240);
--surface-2: oklch(0.97 0.004 240);
--surface-elevated: oklch(1 0 0);
/* Subtle borders */
--border-subtle: oklch(0.94 0.008 260);
--border-default: oklch(0.91 0.012 260);
/* Interactive states */
--interactive-hover: oklch(0.96 0.015 260);
--interactive-active: oklch(0.93 0.02 265);
/* Success state */
--success: oklch(0.65 0.18 145);
--success-muted: oklch(0.95 0.03 145);
/* Animation timing */
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.dark {
--surface-0: oklch(0.15 0.015 260);
--surface-1: oklch(0.18 0.015 260);
--surface-2: oklch(0.22 0.015 260);
--surface-elevated: oklch(0.25 0.015 260);
--border-subtle: oklch(0.25 0.012 260);
--border-default: oklch(0.3 0.015 260);
--interactive-hover: oklch(0.25 0.02 265);
--interactive-active: oklch(0.3 0.025 270);
--success: oklch(0.7 0.16 145);
--success-muted: oklch(0.25 0.04 145);
}
/* Expose surface colors to Tailwind */
@theme inline {
--color-surface-0: var(--surface-0); --color-surface-0: var(--surface-0);
--color-surface-1: var(--surface-1); --color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2); --color-surface-2: var(--surface-2);
--color-surface-elevated: var(--surface-elevated);
--color-border-subtle: var(--border-subtle); --color-border-subtle: var(--border-subtle);
--color-border-default: var(--border-default); --color-border-default: var(--border-default);
--color-interactive-hover: var(--interactive-hover); --color-interactive-hover: var(--interactive-hover);
@@ -206,12 +48,149 @@
--color-success-muted: var(--success-muted); --color-success-muted: var(--success-muted);
} }
/*
* Light: a quiet light table. Ink for primary actions, a highlighter
* yellow reserved for what the AI just changed.
*/
:root {
--radius: 0.75rem;
--canvas: #f4f5f7;
--background: #f4f5f7;
--foreground: #14181d;
--card: #ffffff;
--card-foreground: #14181d;
--popover: #ffffff;
--popover-foreground: #14181d;
--primary: #14181d;
--primary-foreground: #ffffff;
--secondary: #f1f3f5;
--secondary-foreground: #14181d;
--muted: #f1f3f5;
--muted-foreground: #5d6672;
--faint: #687180;
--accent: #eef0f3;
--accent-foreground: #14181d;
--destructive: #c9372c;
--border: #e4e7eb;
--input: #dfe3e8;
--ring: #14181d;
--marker: #ffd84d;
--marker-soft: #fff4bf;
--marker-ink: #6b5200;
--surface-0: #ffffff;
--surface-1: #f7f8fa;
--surface-2: #f1f3f5;
--border-subtle: #eceef1;
--border-default: #e4e7eb;
--interactive-hover: #f1f3f5;
--interactive-active: #e7eaee;
--success: #1f9e5a;
--success-muted: #e3f5ea;
--shadow-float:
0 1px 2px rgb(20 24 29 / 0.05), 0 10px 30px -8px rgb(20 24 29 / 0.14);
--shadow-pop:
0 1px 2px rgb(20 24 29 / 0.06), 0 18px 48px -12px rgb(20 24 29 / 0.22);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
/*
* Han characters are drawn differently in Simplified Chinese, Traditional
* Chinese and Japanese: each language lists its own system fonts first.
*/
:root {
--font-cjk:
"PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC",
"Microsoft YaHei", "Hiragino Sans", "Meiryo";
}
:root:lang(ja) {
--font-cjk:
"Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans CJK JP",
"Yu Gothic UI", "Meiryo", "PingFang SC";
}
:root:lang(zh-Hant) {
--font-cjk:
"PingFang TC", "Noto Sans CJK TC", "Microsoft JhengHei",
"Hiragino Sans", "PingFang SC";
}
/*
* Dark: a blueprint-blue table. Surfaces get lighter the higher they float.
* The diagram stays on a white sheet so it looks the way it will export;
* primary actions are paper-white, and amber only marks AI changes.
*/
.dark {
/* Neutral greys: the canvas matches draw.io's own dark workspace */
--canvas: #1b1d1e;
--background: #1b1d1e;
--foreground: #ececec;
--card: #242627;
--card-foreground: #ececec;
--popover: #2a2c2d;
--popover-foreground: #ececec;
--primary: #ececec;
--primary-foreground: #1b1d1e;
--secondary: #2d2f30;
--secondary-foreground: #ececec;
--muted: #2d2f30;
--muted-foreground: #a7a9aa;
--faint: #8a8c8d;
--accent: #353738;
--accent-foreground: #ececec;
--destructive: #f07167;
--border: rgb(255 255 255 / 0.1);
--input: rgb(255 255 255 / 0.16);
--ring: #ececec;
--marker: #f5b13d;
--marker-soft: rgb(245 177 61 / 0.16);
--marker-ink: #f7c46b;
--surface-0: #242627;
--surface-1: #202223;
--surface-2: #2d2f30;
--border-subtle: rgb(255 255 255 / 0.08);
--border-default: rgb(255 255 255 / 0.13);
--interactive-hover: #353738;
--interactive-active: #3c3e3f;
--success: #4fd18b;
--success-muted: rgb(79 209 139 / 0.14);
--shadow-float:
0 0 0 1px rgb(255 255 255 / 0.07), 0 14px 34px -12px rgb(0 0 0 / 0.6);
--shadow-pop:
0 0 0 1px rgb(255 255 255 / 0.1), 0 22px 56px -14px rgb(0 0 0 / 0.7);
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body { body {
@apply bg-background text-foreground font-sans; @apply bg-background text-foreground font-sans;
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--marker-soft);
color: inherit;
} }
} }
@@ -221,46 +200,59 @@
width: 100% !important; width: 100% !important;
} }
/* Custom scrollbar */
@layer utilities { @layer utilities {
.scrollbar-thin { .scrollbar-thin {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: oklch(0.85 0.01 260) transparent; scrollbar-color: color-mix(in oklab, var(--foreground) 18%, transparent)
transparent;
} }
.scrollbar-thin::-webkit-scrollbar { .scrollbar-thin::-webkit-scrollbar {
width: 6px; width: 6px;
} }
.scrollbar-thin::-webkit-scrollbar-track { .scrollbar-thin::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.scrollbar-thin::-webkit-scrollbar-thumb { .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.85 0.01 260); background-color: color-mix(
in oklab,
var(--foreground) 18%,
transparent
);
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260); background-color: color-mix(
in oklab,
var(--foreground) 30%,
transparent
);
} }
/* Dark mode scrollbar */ /* Diagram pictures always render as on paper, also in dark mode
.dark .scrollbar-thin { (draw.io SVGs use light-dark() colors) */
scrollbar-color: oklch(0.35 0.015 260) transparent; .sheet-light {
color-scheme: light;
} }
.shadow-float {
.dark .scrollbar-thin::-webkit-scrollbar-thumb { box-shadow: var(--shadow-float);
background-color: oklch(0.35 0.015 260);
} }
.shadow-pop {
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { box-shadow: var(--shadow-pop);
background-color: oklch(0.45 0.015 260);
} }
} }
/* Smooth page transitions */
@keyframes fadeIn { @keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes messageIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(8px);
@@ -271,144 +263,46 @@
} }
} }
@keyframes slideInRight { /* The first prompt moves from the middle of the canvas into the panel */
@keyframes panelIn {
from { from {
opacity: 0; opacity: 0;
transform: translateX(16px); transform: translateX(12px) scale(0.985);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0) scale(1);
}
}
/* A highlight sweeps across "Thinking..." text (Shimmer) */
@keyframes shimmer {
from {
background-position: 100% center;
}
to {
background-position: 0% center;
} }
} }
.animate-fade-in { .animate-fade-in {
animation: fadeIn 0.3s ease-out forwards; animation: fadeIn 0.25s var(--ease-out) forwards;
} }
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards;
}
/* Message bubble animations */
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-message-in { .animate-message-in {
animation: messageIn 0.25s ease-out forwards; animation: messageIn 0.22s var(--ease-out) forwards;
}
.animate-panel-in {
animation: panelIn 0.32s var(--ease-out) both;
}
.animate-shimmer {
animation: shimmer var(--shimmer-duration, 2s) linear infinite;
} }
/* Subtle floating shadow for cards */
.shadow-soft {
box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04);
}
.shadow-soft-lg {
box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06);
}
/* Gradient text utility */
.text-gradient-primary {
background: linear-gradient(
135deg,
oklch(0.55 0.18 265),
oklch(0.6 0.2 290)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ============================================
REFINED DIALOG STYLES
============================================ */
/* Refined dialog shadow - multi-layer soft shadow */
.shadow-dialog {
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.03),
0 2px 4px oklch(0 0 0 / 0.02),
0 12px 24px oklch(0 0 0 / 0.06),
0 24px 48px oklch(0 0 0 / 0.04);
}
.dark .shadow-dialog {
box-shadow:
0 0 0 1px oklch(1 0 0 / 0.05),
0 2px 4px oklch(0 0 0 / 0.2),
0 12px 24px oklch(0 0 0 / 0.3),
0 24px 48px oklch(0 0 0 / 0.2);
}
/* Dialog animations */
@keyframes dialog-in {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes dialog-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
}
.animate-dialog-in {
animation: dialog-in var(--duration-normal) var(--ease-out) forwards;
}
.animate-dialog-out {
animation: dialog-out 150ms var(--ease-out) forwards;
}
/* Check pop animation for validation success */
@keyframes check-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-check-pop {
animation: check-pop 0.25s var(--ease-spring) forwards;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-dialog-in, .animate-fade-in,
.animate-dialog-out, .animate-message-in,
.animate-check-pop { .animate-panel-in,
.animate-shimmer {
animation: none; animation: none;
} }
} }
+1
View File
@@ -11,6 +11,7 @@
"**", "**",
"!public", "!public",
"!packages/mcp-server/src/preview", "!packages/mcp-server/src/preview",
"!packages/mcp-server/shell/index.html",
"!lib/model-catalog.json" "!lib/model-catalog.json"
] ]
}, },
-238
View File
@@ -1,238 +0,0 @@
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>
)
+22 -125
View File
@@ -1,66 +1,36 @@
"use client" "use client"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { BrainIcon, ChevronDownIcon } from "lucide-react" import { BrainIcon, ChevronDownIcon } from "lucide-react"
import type { ComponentProps, ReactNode } from "react" import type { ReactNode } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react" import { useEffect, useState } from "react"
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible" } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Shimmer } from "./shimmer"
type ReasoningContextValue = { export type ReasoningProps = {
isStreaming: boolean className?: string
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 isStreaming?: boolean
open?: boolean /** Open while streaming, then folds once about a second after it ends */
defaultOpen?: boolean defaultOpen?: boolean
onOpenChange?: (open: boolean) => void getThinkingMessage: (isStreaming: boolean, duration?: number) => ReactNode
duration?: number children: string
} }
const AUTO_CLOSE_DELAY = 1000 const AUTO_CLOSE_DELAY = 1000
const MS_IN_S = 1000 const MS_IN_S = 1000
export const Reasoning = memo( export function Reasoning({
({
className, className,
isStreaming = false, isStreaming = false,
open,
defaultOpen = true, defaultOpen = true,
onOpenChange, getThinkingMessage,
duration: durationProp,
children, children,
...props }: ReasoningProps) {
}: ReasoningProps) => { const [isOpen, setIsOpen] = useState(defaultOpen)
const [isOpen, setIsOpen] = useControllableState({ const [duration, setDuration] = useState<number>()
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const [duration, setDuration] = useControllableState({
prop: durationProp,
defaultProp: undefined,
})
const [hasAutoClosed, setHasAutoClosed] = useState(false) const [hasAutoClosed, setHasAutoClosed] = useState(false)
const [startTime, setStartTime] = useState<number | null>(null) const [startTime, setStartTime] = useState<number | null>(null)
@@ -74,12 +44,12 @@ export const Reasoning = memo(
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S)) setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S))
setStartTime(null) setStartTime(null)
} }
}, [isStreaming, startTime, setDuration]) }, [isStreaming, startTime])
// Auto-open when streaming starts, auto-close when streaming ends (once only) // Auto-close once when streaming ends, after a short delay so the user
// sees the content
useEffect(() => { useEffect(() => {
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) { if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
// Add a small delay before closing to allow user to see the content
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsOpen(false) setIsOpen(false)
setHasAutoClosed(true) setHasAutoClosed(true)
@@ -87,64 +57,15 @@ export const Reasoning = memo(
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]) }, [isStreaming, isOpen, defaultOpen, hasAutoClosed])
const handleOpenChange = (newOpen: boolean) => {
setIsOpen(newOpen)
}
return ( return (
<ReasoningContext.Provider
value={{ isStreaming, isOpen, setIsOpen, duration }}
>
<Collapsible <Collapsible
className={cn("not-prose mb-4", className)} className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange} onOpenChange={setIsOpen}
open={isOpen} open={isOpen}
{...props}
> >
{children} <CollapsibleTrigger className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
</Collapsible>
</ReasoningContext.Provider>
)
},
)
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" /> <BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)} {getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon <ChevronDownIcon
@@ -153,34 +74,10 @@ export const ReasoningTrigger = memo(
isOpen ? "rotate-180" : "rotate-0", isOpen ? "rotate-180" : "rotate-0",
)} )}
/> />
</>
)}
</CollapsibleTrigger> </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">
},
)
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> <div className="whitespace-pre-wrap">{children}</div>
</CollapsibleContent> </CollapsibleContent>
), </Collapsible>
) )
}
Reasoning.displayName = "Reasoning"
ReasoningTrigger.displayName = "ReasoningTrigger"
ReasoningContent.displayName = "ReasoningContent"
+5 -22
View File
@@ -1,13 +1,6 @@
"use client" "use client"
import { motion } from "motion/react" import { type CSSProperties, type ElementType, memo, useMemo } from "react"
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type TextShimmerProps = { export type TextShimmerProps = {
@@ -25,39 +18,29 @@ const ShimmerComponent = ({
duration = 2, duration = 2,
spread = 2, spread = 2,
}: TextShimmerProps) => { }: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements,
)
const dynamicSpread = useMemo( const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread, () => (children?.length ?? 0) * spread,
[children, spread], [children, spread],
) )
return ( return (
<MotionComponent <Component
animate={{ backgroundPosition: "0% center" }}
className={cn( className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent", "animate-shimmer 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]", "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className, className,
)} )}
initial={{ backgroundPosition: "100% center" }}
style={ style={
{ {
"--spread": `${dynamicSpread}px`, "--spread": `${dynamicSpread}px`,
"--shimmer-duration": `${duration}s`,
backgroundImage: backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))", "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties } as CSSProperties
} }
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
> >
{children} {children}
</MotionComponent> </Component>
) )
} }
-36
View File
@@ -1,36 +0,0 @@
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>
)
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useRef, useState } from "react"
import { DrawioFrame } from "@/components/canvas/drawio-frame"
import { useLocale } from "@/components/canvas/locale-context"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import {
DRAWIO_CSS,
type DrawioSource,
getDrawioSrc,
webDrawioSource,
} from "@/lib/drawio/drawio-config"
import { keepCenter, refitIfRecent } from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn } from "@/lib/utils"
import { useCanvasStore } from "@/stores/canvas-store"
import { useSettingsStore } from "@/stores/settings-store"
/** Size of the canvas area, for overlays that stay inside it */
export interface CanvasSize {
width: number
height: number
}
/**
* The draw.io canvas, with draw.io's own toolbar, menus and page tabs, and
* an overlay laid over it (the web app's "ask AI" button; only when the
* page can drive draw.io directly, not with an external cross-origin
* draw.io).
*/
export function CanvasStage({
className,
style,
hideOverlays = false,
overlay,
drawioSource,
waitForSavedLocale = false,
}: {
className?: string
style?: React.CSSProperties
/** Hide the overlay (e.g. while the start screen covers the canvas) */
hideOverlays?: boolean
/** Rendered over the canvas once draw.io is ready */
overlay?: (size: CanvasSize) => React.ReactNode
/** Where the editor comes from; none: the web app's own copy (the MCP
* shell passes what its server configured) */
drawioSource?: DrawioSource
/** Web app: the page is about to switch to the language picked last
* time (useSavedLocale in the workspace); do not load draw.io in the
* URL's language meanwhile */
waitForSavedLocale?: boolean
}) {
const dict = useDictionary()
const lang = useLocale()
const {
drawioRef,
handleDiagramAutoSave,
handleDiagramExport,
onDrawioLoad,
resetDrawioReady,
isDrawioReady,
} = useDiagram()
const isDark = useSettingsStore((s) => s.isDark)
const hasEditor = useCanvasStore((s) => s.hasEditor)
// Canvas width when the chat panel starts sliding in or out
const slideStartWidthRef = useRef(0)
const [src, setSrc] = useState<string | null>(null)
// null until draw.io tells us whether we can drive it directly
const [editorAccess, setEditorAccess] = useState<boolean | null>(null)
const stageRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState<CanvasSize>({ width: 0, height: 0 })
useEffect(() => {
// Load draw.io once, in the language the page will have
if (waitForSavedLocale) {
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (
saved &&
saved !== lang &&
i18n.locales.includes(saved as Locale)
) {
return
}
}
// The theme class is set before the page renders; the settings store
// may not have read it yet
const dark = document.documentElement.classList.contains("dark")
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), dark))
}, [lang])
// A new iframe starts over: draw.io reports load and access again
useEffect(() => {
resetDrawioReady()
setEditorAccess(null)
}, [src])
// Cross-origin draw.io only takes its dark mode when it starts: reload
// it when that is not the theme now (also one switched while it
// loaded). Same-origin switches it in place.
useEffect(() => {
if (editorAccess !== false || !src) return
const startedDark = new URL(src).searchParams.get("dark") === "1"
if (startedDark === isDark) return
setSrc(getDrawioSrc(drawioSource ?? webDrawioSource(lang), isDark))
}, [isDark, editorAccess, lang, src])
useEffect(() => {
const stage = stageRef.current
if (!stage) return
// React passes no transitionstart
const onSlideStart = (event: TransitionEvent) => {
if (event.target !== stage || event.propertyName !== "right") return
slideStartWidthRef.current = stage.clientWidth
}
stage.addEventListener("transitionstart", onSlideStart)
const observer = new ResizeObserver(([entry]) => {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
refitIfRecent()
})
observer.observe(stage)
return () => {
observer.disconnect()
stage.removeEventListener("transitionstart", onSlideStart)
}
}, [])
const showOverlays = hasEditor && isDrawioReady && !hideOverlays
return (
<div
ref={stageRef}
className={cn("absolute overflow-hidden bg-canvas", className)}
style={style}
data-testid="canvas-stage"
// The chat panel slides in or out (the canvas's right edge moves):
// once the canvas has its final size, fit a diagram just drawn,
// or keep the view's middle
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (event.propertyName !== "right") return
const stage = event.currentTarget
// Let draw.io take the new size first
setTimeout(() => {
if (refitIfRecent(4000)) return
keepCenter(slideStartWidthRef.current, stage.clientWidth)
}, 60)
}}
>
{src && (
<DrawioFrame
key={src}
ref={drawioRef}
src={src}
css={DRAWIO_CSS}
dark={isDark}
onLoad={onDrawioLoad}
onAutoSave={handleDiagramAutoSave}
onExport={handleDiagramExport}
onEditorAccess={setEditorAccess}
/>
)}
{/* Covers the iframe until draw.io has loaded and been styled */}
{(!isDrawioReady || editorAccess === null) && (
<div
className="absolute inset-0 flex items-center justify-center bg-canvas"
data-testid="canvas-loading"
>
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
<span className="size-3.5 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
{dict.workspace.canvasLoading}
</div>
</div>
)}
{showOverlays && overlay?.(size)}
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
import { ChevronLeft, ChevronRight, RotateCcw } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { VersionThumb } from "@/components/canvas/version-card"
import { useVersionsContext } from "@/components/canvas/versions-context"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
import { formatMessage } from "@/lib/i18n/utils"
import { describeChangeSummary } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
function Sheet({
label,
svg,
}: {
label: string
svg: string | null | undefined
}) {
return (
<figure className="flex min-w-0 flex-1 flex-col gap-2">
<figcaption className="text-xs font-medium text-muted-foreground">
{label}
</figcaption>
<div className="sheet-light flex aspect-[16/10] items-center justify-center overflow-hidden rounded-xl border border-border bg-white p-2">
<VersionThumb svg={svg} className="max-h-full max-w-full" />
</div>
</figure>
)
}
/** A saved version next to the current canvas, with restore */
export function CompareDialog() {
const dict = useDictionary()
const t = dict.versions
const { versions, isBusy, restore } = useVersionsContext()
const { getVersionSvg, chartXML } = useDiagram()
const versionId = useUiStore((s) => s.compareVersionId)
const openCompare = useUiStore((s) => s.openCompare)
const closeCompare = useUiStore((s) => s.closeCompare)
const [currentSvg, setCurrentSvg] = useState<string | null>(null)
const index = versions.findIndex((v) => v.id === versionId)
const version = index >= 0 ? versions[index] : null
// Whether the canvas is this version, on every page
const same = useMemo(
() => !version || isSameDocument(version.xml, chartXML),
[version, chartXML],
)
// What the canvas has changed since this version on the page it was
// made on (null: nothing there)
const changes = useMemo(() => {
if (!version || same) return null
const { summary } = diffDiagrams(version.xml, chartXML, version.pageId)
return Object.values(summary).some((n) => n > 0) ? summary : null
}, [version, chartXML, same])
// The version went away (dropped as the oldest, another chat): the
// dialog closes, and nothing waits for its Escape
useEffect(() => {
if (versionId && !version) closeCompare()
}, [versionId, version, closeCompare])
// Picture of the canvas as it is now, taken again once an answer that
// was running is done ("" when none could be made)
useEffect(() => {
if (!versionId) return
let cancelled = false
setCurrentSvg(null)
getVersionSvg().then((svg) => {
if (!cancelled) setCurrentSvg(svg ?? "")
})
return () => {
cancelled = true
}
}, [versionId, isBusy])
return (
<Dialog
open={!!version}
onOpenChange={(open) => !open && closeCompare()}
>
<DialogContent className="gap-5 sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t.compareTitle}</DialogTitle>
<DialogDescription>
{!version
? ""
: same
? t.isOnCanvas
: changes
? `${formatMessage(t.compareSince, { n: version.number })} ${describeChangeSummary(changes, t)}`
: formatMessage(t.differsElsewhere, {
n: version.number,
})}
</DialogDescription>
</DialogHeader>
{version && (
<div className="flex flex-col gap-4 sm:flex-row">
<Sheet
label={formatMessage(t.versionLabel, {
n: version.number,
})}
svg={version.svg}
/>
<Sheet label={t.currentCanvas} svg={currentSvg} />
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={index <= 0}
onClick={() => openCompare(versions[index - 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.previous}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
disabled={index < 0 || index >= versions.length - 1}
onClick={() => openCompare(versions[index + 1].id)}
className="inline-flex size-8 items-center justify-center rounded-lg border border-border text-muted-foreground hover:text-foreground disabled:opacity-30"
aria-label={t.next}
>
<ChevronRight className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{index + 1} / {versions.length}
</span>
<button
type="button"
disabled={!version || same || isBusy}
onClick={() => {
if (!version) return
restore(version.id)
closeCompare()
}}
className="ml-auto inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3.5 text-[13px] font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
data-testid="compare-restore"
>
<RotateCcw className="size-4" />
{formatMessage(t.restoreVersion, {
n: version?.number ?? 0,
})}
</button>
</div>
</DialogContent>
</Dialog>
)
}
+255
View File
@@ -0,0 +1,255 @@
"use client"
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
import {
DRAWIO_CONFIG,
EMPTY_DRAWIO_DOCUMENT,
} from "@/lib/drawio/drawio-config"
import {
attachEditor,
detachEditor,
watchForEditorUi,
} from "@/lib/drawio/editor-bridge"
export interface DrawioExportEvent {
event: "export"
format: string
data: string
xml: string
message: { message?: string; [key: string]: unknown }
}
export interface DrawioFrameHandle {
load: (data: { xml: string }) => void
exportDiagram: (data: {
format: string
message?: string
[key: string]: unknown
}) => void
}
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
/** draw.io's dark mode, switched in place when the editor is reachable */
dark: boolean
onLoad?: () => void
onAutoSave?: (data: { xml?: string }) => void
onExport?: (data: DrawioExportEvent) => void
/** Called once we know whether the editor can be driven directly */
onEditorAccess?: (hasEditor: boolean) => void
}
const STYLE_ID = "next-ai-drawio-theme"
/**
* The draw.io editor in an iframe, speaking draw.io's postMessage embed
* protocol (same messages react-drawio used). When the iframe is same-origin
* it also hands the editor object to editor-bridge for direct control.
*/
export const DrawioFrame = forwardRef<DrawioFrameHandle, DrawioFrameProps>(
function DrawioFrame(
{ src, css, 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 darkRef = useRef(dark)
darkRef.current = dark
// Handlers change on every render; the message listener reads refs
const handlersRef = useRef({
onLoad,
onAutoSave,
onExport,
onEditorAccess,
})
handlersRef.current = { onLoad, onAutoSave, onExport, onEditorAccess }
// draw.io's origin: messages go only there, and only its messages
// count (the frame could be navigated to another site)
// (read on use: there is no window while the page renders on the server)
const drawioOrigin = () => new URL(src, window.location.href).origin
const send = (action: string, data: Record<string, unknown> = {}) => {
iframeRef.current?.contentWindow?.postMessage(
JSON.stringify({ action, ...data }),
drawioOrigin(),
)
}
useImperativeHandle(ref, () => ({
load: ({ xml }) => send("load", { xml, autosave: 1 }),
exportDiagram: (data) => send("export", data),
}))
// Same-origin only: keep the theme CSS current without a reload
const applyThemeCss = () => {
if (!attachedRef.current) return
try {
const doc = iframeRef.current?.contentDocument
if (!doc?.head) return
let style = doc.getElementById(STYLE_ID)
if (!style) {
style = doc.createElement("style")
style.id = STYLE_ID
doc.head.appendChild(style)
}
style.textContent = cssRef.current
} catch {
// Cross-origin: the configure message already applied it
}
}
useEffect(() => {
applyThemeCss()
}, [css])
// Same-origin only: draw.io's own dark mode, without a reload
const applyDarkMode = () => {
if (!attachedRef.current) return
try {
// Runs on every load: switch only when it differs
const frameWindow = iframeRef.current?.contentWindow as any
if (frameWindow?.Editor?.isDarkMode?.() === darkRef.current) {
return
}
editorUiRef.current?.setDarkMode?.(darkRef.current)
} catch (error) {
console.warn("Failed to set draw.io's dark mode:", error)
}
}
useEffect(() => {
applyDarkMode()
}, [dark])
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
editorUiRef.current = null
attachedRef.current = false
let reported: boolean | null = null
const report = (value: boolean) => {
if (reported === value) return
reported = value
handlersRef.current.onEditorAccess?.(value)
}
// Watch for draw.io's scripts so the editor object can be caught
// as it starts. Stops once hooked, or when the frame turns out to
// be cross-origin.
const startedAt = Date.now()
const poll = setInterval(() => {
const frameWindow = iframe.contentWindow as any
if (!frameWindow) return
let ready = false
try {
ready =
frameWindow.location.href !== "about:blank" &&
!!frameWindow.EditorUi
} catch {
clearInterval(poll)
report(false)
return
}
if (ready) {
clearInterval(poll)
// Called on every action state update: once per editor
const ok = watchForEditorUi(frameWindow, (ui) => {
if (editorUiRef.current === ui) return
editorUiRef.current = ui
// draw.io follows the system's theme on its own:
// keep the app's
ui.addListener?.("darkModeChanged", () =>
applyDarkMode(),
)
})
if (!ok) report(false)
} else if (Date.now() - startedAt > 60_000) {
clearInterval(poll)
}
}, 5)
const handleMessage = (event: MessageEvent) => {
if (event.source !== iframe.contentWindow) return
if (event.origin !== drawioOrigin()) return
if (typeof event.data !== "string") return
let data: any
try {
data = JSON.parse(event.data)
} catch {
return
}
const handlers = handlersRef.current
switch (data.event) {
case "init":
send("load", {
xml: EMPTY_DRAWIO_DOCUMENT,
autosave: 1,
})
break
case "configure":
send("configure", {
config: {
...DRAWIO_CONFIG,
css: cssRef.current,
},
})
break
case "load": {
const tryAttach = () => {
const ui = editorUiRef.current
if (!ui || !iframe.contentWindow) return false
attachEditor(ui, iframe.contentWindow)
attachedRef.current = true
applyThemeCss()
applyDarkMode()
report(true)
return true
}
// The editor object is usually caught before the
// first load; give it a moment, then fall back to
// draw.io's own toolbar
if (!tryAttach()) {
setTimeout(() => {
if (!tryAttach()) report(false)
}, 800)
}
handlers.onLoad?.()
break
}
case "autosave":
handlers.onAutoSave?.(data)
break
case "export":
handlers.onExport?.(data)
break
}
}
window.addEventListener("message", handleMessage)
return () => {
clearInterval(poll)
window.removeEventListener("message", handleMessage)
detachEditor()
}
}, [src])
return (
<iframe
ref={iframeRef}
src={src}
title="draw.io"
allow="clipboard-read; clipboard-write"
className="block h-full w-full border-0"
/>
)
},
)
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { createContext, useContext } from "react"
import type { Locale } from "@/lib/i18n/config"
const LocaleContext = createContext<Locale | null>(null)
/** The page's language; the web layout feeds it from its route param */
export function LocaleProvider({
locale,
children,
}: {
locale: Locale
children: React.ReactNode
}) {
return (
<LocaleContext.Provider value={locale}>
{children}
</LocaleContext.Provider>
)
}
export function useLocale(): Locale {
const locale = useContext(LocaleContext)
if (!locale) {
throw new Error("useLocale must be used within a LocaleProvider")
}
return locale
}
+71
View File
@@ -0,0 +1,71 @@
import { Sparkles } from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { modKey } from "@/lib/platform"
import { useCanvasStore } from "@/stores/canvas-store"
import { useUiStore } from "@/stores/ui-store"
const BUTTON_HEIGHT = 32
const GAP = 10
/**
* "Ask AI to change this" next to the selected shapes. Clicking it moves
* focus to the chat input, where the selection already shows as a chip.
* Hidden while an answer is changing the canvas.
*/
export function SelectionAsk({
width,
height,
}: {
/** Size of the canvas area, for keeping the button inside it */
width: number
height: number
}) {
const dict = useDictionary()
const rect = useCanvasStore((s) => s.selectionRect)
const selection = useCanvasStore((s) => s.selection)
const isFreehand = useCanvasStore((s) => s.isFreehand)
const popupOpen = useCanvasStore((s) => s.isDrawioPopupOpen)
const isBusy = useCanvasStore((s) => s.isBusy)
const focusComposer = useUiStore((s) => s.focusComposer)
if (isBusy || popupOpen || !rect || selection.length === 0 || isFreehand) {
return null
}
// Selection scrolled out of view
if (
rect.x + rect.width < 0 ||
rect.y + rect.height < 0 ||
rect.x > width ||
rect.y > height
) {
return null
}
// Above the selection's top-right corner, kept inside the canvas
const approxWidth = 168
const left = Math.min(
Math.max(8, rect.x + rect.width - approxWidth / 2),
width - approxWidth - 8,
)
const above = rect.y - BUTTON_HEIGHT - GAP
const top =
above > 64
? above
: Math.min(rect.y + rect.height + GAP, height - BUTTON_HEIGHT - 72)
return (
<button
type="button"
onClick={focusComposer}
className="pointer-events-auto absolute z-10 flex h-8 items-center gap-1.5 rounded-xl bg-card pr-2.5 pl-2 text-[13px] font-medium text-foreground shadow-pop transition-transform animate-fade-in hover:-translate-y-px"
style={{ left, top }}
data-testid="selection-ask"
>
<Sparkles className="size-4 text-marker-ink" />
{dict.workspace.askAi}
<kbd className="ml-1 font-sans text-[11px] text-muted-foreground">
{modKey}/
</kbd>
</button>
)
}
+217
View File
@@ -0,0 +1,217 @@
import { Redo2, RotateCcw, Undo2 } from "lucide-react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import {
useVersionsContext,
type VersionItem,
} from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import { describeChangeSummary, describeTotals } from "@/lib/version-text"
import { useUiStore } from "@/stores/ui-store"
/** Picture of a version (SVG data URL), or what stands in for it */
export function VersionThumb({
svg,
dimmed,
className,
}: {
/** undefined or null: still being made; "": none could be made */
svg: string | null | undefined
dimmed?: boolean
className?: string
}) {
const t = useDictionary().versions
if (svg) {
return (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={svg}
alt=""
className={cn(
"object-contain transition-opacity",
dimmed && "opacity-40",
className,
)}
/>
)
}
if (svg === "") {
return <span className="text-xs text-faint">{t.noPreview}</span>
}
return (
<Shimmer as="span" className="text-xs">
{t.rendering}
</Shimmer>
)
}
interface VersionCardProps extends React.ComponentProps<"div"> {
version: VersionItem
/** The newest version shows its picture; older ones fold into one row */
isLatest: boolean
/** What happened ("Drew the diagram"); an undone change says so instead */
label: string
/** Goes after the summary text (the chat adds its "Show XML" link) */
afterSummary?: React.ReactNode
/** Panel below the card (the chat shows the AI's XML there) */
children?: React.ReactNode
}
/**
* Card for one diagram version: picture, number, label, what changed, and
* the action (undo or redo on the latest, restore on older ones). The
* picture opens Compare. Other attributes go on the card's element.
*/
export function VersionCard({
version,
isLatest,
label,
afterSummary,
children,
className,
...rest
}: VersionCardProps) {
const t = useDictionary().versions
const source = useVersionsContext()
const openCompare = useUiStore((s) => s.openCompare)
const isUndone = source.undoneId === version.id
const isOnCanvas = source.onCanvasId === version.id
const title = isUndone ? t.undone : label
const summary = version.fromScratch
? describeTotals(version.summary, t)
: describeChangeSummary(version.summary, t)
const badge = (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[11px] font-semibold tabular-nums",
isOnCanvas
? "bg-marker-soft text-marker-ink"
: "bg-muted text-muted-foreground",
)}
>
v{version.number}
</span>
)
const actionClass =
"inline-flex h-7 shrink-0 items-center gap-1 rounded-lg px-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40"
const restoreButton = (
<button
type="button"
disabled={source.isBusy || isOnCanvas}
onClick={() => source.restore(version.id)}
className={actionClass}
data-testid="version-restore"
>
<RotateCcw className="size-3.5" />
{formatMessage(t.restoreVersion, { n: version.number })}
</button>
)
const summaryLine = (
<div className="text-xs text-muted-foreground">
{summary}
{afterSummary}
</div>
)
if (!isLatest) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
className,
)}
data-testid="version-card"
{...rest}
>
<div className="flex items-center gap-2.5 p-1.5 pr-2">
<button
type="button"
onClick={() => openCompare(version.id)}
className="sheet-light flex h-9 w-14 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white hover:border-foreground/30"
aria-label={t.compare}
title={t.compare}
>
<VersionThumb
svg={version.svg}
className="max-h-8 max-w-[52px]"
/>
</button>
{badge}
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-foreground">
{title}
</div>
{summaryLine}
</div>
{restoreButton}
</div>
{children}
</div>
)
}
// Undo and redo belong to the newest change while it is (or was) on
// the canvas; after an older version was restored, this one is
// restored too
const canUndo = source.canUndo || source.canRedo
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
isOnCanvas && "border-foreground/15",
className,
)}
data-testid="version-card"
{...rest}
>
<button
type="button"
onClick={() => openCompare(version.id)}
className="group/thumb sheet-light relative flex h-24 w-full items-center justify-center border-b border-border bg-white"
aria-label={t.compare}
>
<VersionThumb
svg={version.svg}
dimmed={isUndone}
className="max-h-20 max-w-[88%]"
/>
<span className="absolute right-2 bottom-2 rounded-md bg-black/60 px-1.5 py-0.5 text-[11px] text-white opacity-0 transition-opacity group-hover/thumb:opacity-100">
{t.compare}
</span>
</button>
{/* Title row with the action; the summary runs under both */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-3 py-2.5">
{badge}
<div className="truncate text-[13px] font-medium text-foreground">
{title}
</div>
{canUndo ? (
<button
type="button"
disabled={source.isBusy}
onClick={() =>
source.canRedo ? source.redo() : source.undo()
}
className={actionClass}
data-testid="version-undo"
>
{source.canRedo ? (
<Redo2 className="size-3.5" />
) : (
<Undo2 className="size-3.5" />
)}
{source.canRedo ? t.redo : t.undo}
</button>
) : (
restoreButton
)}
<div className="col-span-2 col-start-2 mt-0.5">
{summaryLine}
</div>
</div>
{children}
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useRef } from "react"
import { useVersionsContext } from "@/components/canvas/versions-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
import { useUiStore } from "@/stores/ui-store"
/** Row of version thumbnails under the panel header; click to compare */
export function VersionStrip({
minVersions = 2,
}: {
/** Hidden below this many versions: one version alone adds nothing to
* its card, unless the caller knows there is no card for it */
minVersions?: number
}) {
const dict = useDictionary()
const { versions, onCanvasId } = useVersionsContext()
const openCompare = useUiStore((s) => s.openCompare)
const scrollRef = useRef<HTMLDivElement>(null)
// Keep the newest version in view
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTo({ left: el.scrollWidth, behavior: "smooth" })
}, [versions.length])
if (versions.length < minVersions) return null
return (
<div className="flex items-center gap-2 border-b border-border px-3.5 pt-1 pb-3">
<span className="shrink-0 text-xs text-muted-foreground">
{dict.versions.strip}
</span>
<div
ref={scrollRef}
className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto p-1 scrollbar-thin"
>
{versions.map((version) => {
const isCurrent = version.id === onCanvasId
return (
<button
key={version.id}
type="button"
onClick={() => openCompare(version.id)}
title={`v${version.number}${isCurrent ? ` · ${dict.versions.currentCanvas}` : ""}`}
className={cn(
"sheet-light relative flex h-12 w-[72px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-white transition-colors hover:border-foreground/30",
// An outline with a gap reads on the white
// picture in both themes
isCurrent &&
"outline-2 outline-offset-2 outline-foreground",
)}
data-testid="version-thumb"
>
{version.svg && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={version.svg}
alt=""
className="max-h-9 max-w-[62px] object-contain"
/>
)}
<span
className={cn(
"absolute bottom-0.5 left-1 text-[10px] font-medium tabular-nums text-neutral-500",
isCurrent &&
"font-semibold text-neutral-900",
)}
>
v{version.number}
</span>
</button>
)
})}
</div>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
"use client"
import { createContext, useContext } from "react"
import type { ChangeSummary } from "@/lib/diagram-diff"
/** One saved state of the diagram, as the version card and the compare
* dialog show it */
export interface VersionItem {
id: string
/** Shown as v{number} */
number: number
/** Full multi-page document */
xml: string
/** The page the change was made on; none: the first page */
pageId?: string
/** Document before the change, when it is known */
beforeXml?: string
/** SVG data URL thumbnail; undefined: still being made; "": none could
* be made */
svg?: string
summary: ChangeSummary
/** Drawn on an empty canvas (the card shows totals, not changes) */
fromScratch?: boolean
}
/**
* Where the versions come from and what can be done with them. The web app
* fills it from the chat's version store; the MCP shell from the server's
* history.
*/
export interface VersionsSource {
versions: VersionItem[]
/** The version the canvas shows now, or null (hand edits, undone) */
onCanvasId: string | null
/** The latest version, when its change was undone */
undoneId: string | null
/** Something is changing the canvas: version actions wait */
isBusy: boolean
/** The latest version's change can be undone */
canUndo: boolean
/** The latest version's change was undone and can be put back */
canRedo: boolean
restore: (id: string) => void
undo: () => void
redo: () => void
}
const VersionsContext = createContext<VersionsSource | null>(null)
export function VersionsProvider({
value,
children,
}: {
value: VersionsSource
children: React.ReactNode
}) {
return (
<VersionsContext.Provider value={value}>
{children}
</VersionsContext.Provider>
)
}
export function useVersionsContext(): VersionsSource {
const source = useContext(VersionsContext)
if (!source) {
throw new Error(
"useVersionsContext must be used within a VersionsProvider",
)
}
return source
}
-224
View File
@@ -1,224 +0,0 @@
"use client"
import {
Cloud,
FileText,
GitBranch,
Palette,
Terminal,
Zap,
} from "lucide-react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
interface ExampleCardProps {
icon: React.ReactNode
title: string
description: string
onClick: () => void
isNew?: boolean
}
function ExampleCard({
icon,
title,
description,
onClick,
isNew,
}: ExampleCardProps) {
const dict = useDictionary()
return (
<button
onClick={onClick}
className={`group w-full text-left p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm ${
isNew
? "border-primary/40 ring-1 ring-primary/20"
: "border-border/60"
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors ${
isNew
? "bg-primary/20 group-hover:bg-primary/25"
: "bg-primary/10 group-hover:bg-primary/15"
}`}
>
{icon}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
{isNew && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-primary text-primary-foreground rounded">
{dict.common.new}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{description}
</p>
</div>
</div>
</button>
)
}
export default function ExamplePanel({
setInput,
setFiles,
minimal = false,
}: {
setInput: (input: string) => void
setFiles: (files: File[]) => void
minimal?: boolean
}) {
const dict = useDictionary()
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
try {
const response = await fetch(getAssetUrl("/example.png"))
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style")
try {
const response = await fetch(getAssetUrl("/architecture.png"))
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
const handlePdfExample = async () => {
setInput("Summarize this paper as a diagram")
try {
const response = await fetch(getAssetUrl("/chain-of-thought.txt"))
const blob = await response.blob()
const file = new File([blob], "chain-of-thought.txt", {
type: "text/plain",
})
setFiles([file])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* MCP Server Notice */}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.examples.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.examples.subtitle}
</p>
</div>
</>
)}
{/* Examples grid */}
<div className="space-y-3">
{!minimal && (
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
{dict.examples.quickExamples}
</p>
)}
<div className="grid gap-2">
<ExampleCard
icon={<FileText className="w-4 h-4 text-primary" />}
title={dict.examples.paperToDiagram}
description={dict.examples.paperDescription}
onClick={handlePdfExample}
isNew
/>
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title={dict.examples.animatedDiagram}
description={dict.examples.animatedDescription}
onClick={() => {
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/>
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title={dict.examples.awsArchitecture}
description={dict.examples.awsDescription}
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title={dict.examples.replicateFlowchart}
description={dict.examples.replicateDescription}
onClick={handleReplicateFlowchart}
/>
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title={dict.examples.creativeDrawing}
description={dict.examples.creativeDescription}
onClick={() => {
setInput("Draw a cat for me")
setFiles([])
}}
/>
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
{dict.examples.cachedNote}
</p>
</div>
</div>
)
}
-669
View File
@@ -1,669 +0,0 @@
"use client"
import {
BookmarkPlus,
Download,
History,
Image as ImageIcon,
Link,
Send,
Square,
} from "lucide-react"
import type React from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ModelSelector } from "@/components/model-selector"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { STORAGE_KEYS } from "@/lib/storage"
import type { FlattenedModel } from "@/lib/types/model-config"
import { extractUrlContent, type UrlData } from "@/lib/url-utils"
import { isRealDiagram } from "@/lib/utils"
import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
interface ValidationResult {
validFiles: File[]
errors: string[]
}
function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// Only check size for images (PDFs/text files are extracted client-side, so file size doesn't matter)
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
const maxSizeMB = MAX_IMAGE_SIZE / 1024 / 1024
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: maxSizeMB,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
)
}
}
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps {
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onStop?: () => void
files?: File[]
onFileChange?: (files: File[]) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
sessionId?: string
error?: Error | null
// Model selector props
models?: FlattenedModel[]
selectedModelId?: string
onModelSelect?: (modelId: string | undefined) => void
onConfigureModels?: () => void
showUnvalidatedModels?: boolean
// Focus control props
shouldFocus?: boolean
onFocused?: () => void
}
export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
function ChatInput(
{
input,
status,
onSubmit,
onChange,
onStop,
files = [],
onFileChange = () => {},
pdfData = new Map(),
urlData,
onUrlChange,
sessionId,
error = null,
models = [],
selectedModelId,
onModelSelect = () => {},
onConfigureModels,
showUnvalidatedModels = false,
shouldFocus = false,
onFocused,
},
ref,
) {
const dict = useDictionary()
const {
chartXML,
diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
// Expose focus method via ref
useImperativeHandle(ref, () => ({
focus: () => {
textareaRef.current?.focus()
},
}))
// Focus the textarea when shouldFocus becomes true
// Use setTimeout to ensure focus happens after drawio iframe settles
useEffect(() => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus()
onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Block sending until attached files and URLs have their text, otherwise
// their content would be silently dropped
const isExtractingAttachments =
files.some((file) => pdfData.get(file)?.isExtracting) ||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
if (stored) setSendShortcut(stored)
const handleChange = (e: CustomEvent<string>) =>
setSendShortcut(e.detail)
window.addEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e)
adjustTextareaHeight()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit()
}
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
// Let validateFiles show a toast for unsupported types
const { validFiles, errors } = validateFiles(
Array.from(e.dataTransfer.files),
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
// Skip if the URL was removed while extracting
onUrlChange((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
id="chat-form"
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
: undefined
}
/>
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={
isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={
isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={isDisabled || !input.trim()}
tooltipContent={dict.templates.saveAsTemplate}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<BookmarkPlus className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
{(status === "streaming" || status === "submitted") &&
onStop ? (
<Button
type="button"
onClick={onStop}
size="sm"
variant="destructive"
className="h-8 w-8 p-0 rounded-xl shadow-sm"
aria-label={dict.chat.stopGeneration}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</Button>
)}
</div>
</div>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()}
/>
</form>
)
},
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-360
View File
@@ -1,360 +0,0 @@
"use client"
import {
ChevronDown,
ChevronUp,
MessageSquare,
Search,
Trash2,
X,
} from "lucide-react"
import { useEffect, useState } from "react"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import ExamplePanel from "@/components/chat-example-panel"
import Image from "@/components/image-with-basepath"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { STORAGE_KEYS } from "@/lib/storage"
import type { Template } from "@/lib/template-storage"
interface SessionMetadata {
id: string
title: string
updatedAt: number
thumbnailDataUrl?: string
}
interface ChatLobbyProps {
sessions: SessionMetadata[]
onSelectSession: (id: string) => void
onDeleteSession?: (id: string) => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
onSendTemplate?: (template: Template) => void
currentInput?: string
dict: {
sessionHistory?: {
recentChats?: string
searchPlaceholder?: string
noResults?: string
justNow?: string
deleteTitle?: string
deleteDescription?: string
}
templates?: {
title?: string
myTemplates?: string
}
examples?: {
quickExamples?: string
}
common: {
delete: string
cancel: string
}
}
}
// Helper to format session date
function formatSessionDate(
timestamp: number,
dict?: { justNow?: string },
): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffMins < 1) return dict?.justNow || "Just now"
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}
function getPanelVisibility() {
if (typeof window === "undefined")
return { recentChats: true, myTemplates: true, quickExamples: true }
return {
recentChats:
localStorage.getItem(STORAGE_KEYS.showRecentChats) !== "false",
myTemplates:
localStorage.getItem(STORAGE_KEYS.showMyTemplates) !== "false",
quickExamples:
localStorage.getItem(STORAGE_KEYS.showQuickExamples) !== "false",
}
}
export function ChatLobby({
sessions,
onSelectSession,
onDeleteSession,
setInput,
setFiles,
onSendTemplate,
currentInput = "",
dict,
}: ChatLobbyProps) {
const [templatesExpanded, setTemplatesExpanded] = useState(true)
const [examplesExpanded, setExamplesExpanded] = useState(true)
const [panelVisibility, setPanelVisibility] = useState(getPanelVisibility)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [sessionToDelete, setSessionToDelete] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
// Listen for panel visibility changes from settings
useEffect(() => {
const handler = () => setPanelVisibility(getPanelVisibility())
window.addEventListener("panelVisibilityChange", handler)
return () =>
window.removeEventListener("panelVisibilityChange", handler)
}, [])
const hasHistory = sessions.length > 0
if (!hasHistory) {
if (!panelVisibility.myTemplates && !panelVisibility.quickExamples) {
return null
}
return (
<div className="animate-fade-in">
{panelVisibility.myTemplates && (
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
)}
{panelVisibility.quickExamples && (
<div className={panelVisibility.myTemplates ? "mt-6" : ""}>
<ExamplePanel setInput={setInput} setFiles={setFiles} />
</div>
)}
</div>
)
}
// Show history + collapsible examples when there are sessions
return (
<div className="py-6 px-2 animate-fade-in">
{/* Recent Chats Section */}
{panelVisibility.recentChats && (
<div className="mb-6">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1 mb-3">
{dict.sessionHistory?.recentChats || "Recent Chats"}
</p>
{/* Search Bar */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={
dict.sessionHistory?.searchPlaceholder ||
"Search chats..."
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border/60 bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 transition-all"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-2">
{sessions
.filter((session) =>
session.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
)
.map((session) => (
// biome-ignore lint/a11y/useSemanticElements: Cannot use button - has nested delete button which causes hydration error
<div
key={session.id}
role="button"
tabIndex={0}
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
onSelectSession(session.id)
}
}}
>
{session.thumbnailDataUrl ? (
<div className="w-12 h-12 shrink-0 rounded-lg border bg-white overflow-hidden">
<Image
src={session.thumbnailDataUrl}
alt=""
width={48}
height={48}
className="object-contain w-full h-full"
/>
</div>
) : (
<div className="w-12 h-12 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{session.title}
</div>
<div className="text-xs text-muted-foreground">
{formatSessionDate(
session.updatedAt,
dict.sessionHistory,
)}
</div>
</div>
{onDeleteSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSessionToDelete(session.id)
setDeleteDialogOpen(true)
}}
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
title={dict.common.delete}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
{sessions.filter((s) =>
s.title
.toLowerCase()
.includes(searchQuery.toLowerCase()),
).length === 0 &&
searchQuery && (
<p className="text-sm text-muted-foreground text-center py-4">
{dict.sessionHistory?.noResults ||
"No chats found"}
</p>
)}
</div>
</div>
)}
{/* Collapsible My Templates Section */}
{panelVisibility.myTemplates && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setTemplatesExpanded(!templatesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.templates?.myTemplates || "My Templates"}
</span>
{templatesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{templatesExpanded && (
<div className="mt-2">
<TemplatePanel
setInput={setInput}
onSendTemplate={onSendTemplate}
currentInput={currentInput}
/>
</div>
)}
</div>
)}
{/* Collapsible Quick Examples Section */}
{panelVisibility.quickExamples && (
<div className="border-t border-border/50 pt-4">
<button
type="button"
onClick={() => setExamplesExpanded(!examplesExpanded)}
className="w-full flex items-center justify-between px-1 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
<span>
{dict.examples?.quickExamples || "Quick Examples"}
</span>
{examplesExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{examplesExpanded && (
<div className="mt-2">
<ExamplePanel
setInput={setInput}
setFiles={setFiles}
minimal
/>
</div>
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>
{dict.sessionHistory?.deleteTitle ||
"Delete this chat?"}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.sessionHistory?.deleteDescription ||
"This will permanently delete this chat session and its diagram. This action cannot be undone."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (sessionToDelete && onDeleteSession) {
onDeleteSession(sessionToDelete)
}
setDeleteDialogOpen(false)
setSessionToDelete(null)
}}
className="border border-red-300 bg-red-50 text-red-700 hover:bg-red-100 hover:border-red-400"
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
-206
View File
@@ -1,206 +0,0 @@
"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" "use client"
import { Bookmark, Edit2 } from "lucide-react" import { Bookmark, Edit2, Plus } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useId, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -16,22 +16,32 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { type Template, updateTemplate } from "@/lib/template-storage" import {
createTemplate,
type Template,
updateTemplate,
} from "@/lib/template-storage"
interface TemplateEditDialogProps { interface TemplateFormDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
template: Template | null
onSuccess: () => void onSuccess: () => void
/** The template to edit; without one the dialog creates a new template */
template?: Template | null
initialPrompt?: string
} }
export function TemplateEditDialog({ // One form for creating and editing a template
export function TemplateFormDialog({
open, open,
onOpenChange, onOpenChange,
template,
onSuccess, onSuccess,
}: TemplateEditDialogProps) { template,
initialPrompt = "",
}: TemplateFormDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const id = useId()
const isEdit = !!template
const [title, setTitle] = useState("") const [title, setTitle] = useState("")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [prompt, setPrompt] = useState("") const [prompt, setPrompt] = useState("")
@@ -39,32 +49,22 @@ export function TemplateEditDialog({
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Populate form each time the dialog opens, dropping any cancelled edits // Fill the form each time the dialog opens, dropping cancelled edits
useEffect(() => { useEffect(() => {
if (open && template) { if (!open) return
setTitle(template.title || "") setTitle(template?.title || "")
setDescription(template.description || "") setDescription(template?.description || "")
setPrompt(template.prompt || "") setPrompt(template ? template.prompt || "" : initialPrompt)
setPinned(template.pinned || false) setPinned(template?.pinned || false)
setError(null) setError(null)
} }, [open, template, initialPrompt])
}, [open, template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setError(null)
}
onOpenChange(newOpen)
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// React submit events bubble through the portal; keep them away from // React submit events bubble through the portal; keep them away from
// any form this dialog may be rendered in // the chat form this dialog may be rendered in
e.stopPropagation() e.stopPropagation()
if (!template) return
const trimmedPrompt = prompt.trim() const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) { if (!trimmedPrompt) {
setError(dict.templates.promptRequired) setError(dict.templates.promptRequired)
@@ -74,40 +74,59 @@ export function TemplateEditDialog({
setIsSubmitting(true) setIsSubmitting(true)
setError(null) setError(null)
const failed = isEdit
? dict.templates.updateFailed
: dict.templates.createFailed
try { try {
const updates: Partial<Omit<Template, "id" | "createdAt">> = { const fields = {
prompt: trimmedPrompt, prompt: trimmedPrompt,
title: title.trim() || template.title,
description: description.trim() || undefined, description: description.trim() || undefined,
pinned, pinned,
} }
// An emptied title keeps the old one when editing; a new template
const updated = await updateTemplate(template.id, updates) // gets a default title from its prompt
if (updated) { const result = template
? await updateTemplate(template.id, {
...fields,
title: title.trim() || template.title,
})
: await createTemplate({
...fields,
title: title.trim() || undefined,
})
if (result) {
onSuccess() onSuccess()
onOpenChange(false) onOpenChange(false)
} else { } else {
setError(dict.templates.updateFailed) setError(failed)
} }
} catch (err) { } catch (err) {
console.error("Failed to update template:", err) console.error("Failed to save template:", err)
setError(dict.templates.updateFailed) setError(failed)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] overflow-hidden"> <DialogContent className="sm:max-w-[500px] overflow-hidden">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{isEdit ? (
<Edit2 className="w-5 h-5" /> <Edit2 className="w-5 h-5" />
{dict.templates.editTitle} ) : (
<Plus className="w-5 h-5" />
)}
{isEdit
? dict.templates.editTitle
: dict.templates.createTitle}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{dict.templates.editDescription} {isEdit
? dict.templates.editDescription
: dict.templates.createDescription}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -115,14 +134,14 @@ export function TemplateEditDialog({
{/* Prompt field - required */} {/* Prompt field - required */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="edit-prompt" htmlFor={`${id}-prompt`}
className="text-foreground" className="text-foreground"
> >
{dict.templates.promptLabel} {dict.templates.promptLabel}
<span className="text-destructive ml-1">*</span> <span className="text-destructive ml-1">*</span>
</Label> </Label>
<Textarea <Textarea
id="edit-prompt" id={`${id}-prompt`}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={dict.templates.promptPlaceholder} placeholder={dict.templates.promptPlaceholder}
@@ -134,13 +153,13 @@ export function TemplateEditDialog({
{/* Title field - optional */} {/* Title field - optional */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="edit-title" htmlFor={`${id}-title`}
className="text-foreground" className="text-foreground"
> >
{dict.templates.titleLabel} {dict.templates.titleLabel}
</Label> </Label>
<Input <Input
id="edit-title" id={`${id}-title`}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder={dict.templates.titlePlaceholder} placeholder={dict.templates.titlePlaceholder}
@@ -153,13 +172,13 @@ export function TemplateEditDialog({
{/* Description field - optional */} {/* Description field - optional */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="edit-description" htmlFor={`${id}-description`}
className="text-foreground" className="text-foreground"
> >
{dict.templates.descriptionLabel} {dict.templates.descriptionLabel}
</Label> </Label>
<Textarea <Textarea
id="edit-description" id={`${id}-description`}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder={ placeholder={
@@ -173,7 +192,7 @@ export function TemplateEditDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label <Label
htmlFor="edit-pinned" htmlFor={`${id}-pinned`}
className="flex items-center gap-2 text-foreground" className="flex items-center gap-2 text-foreground"
> >
<Bookmark className="w-4 h-4" /> <Bookmark className="w-4 h-4" />
@@ -184,7 +203,7 @@ export function TemplateEditDialog({
</p> </p>
</div> </div>
<Switch <Switch
id="edit-pinned" id={`${id}-pinned`}
checked={pinned} checked={pinned}
onCheckedChange={setPinned} onCheckedChange={setPinned}
/> />
@@ -208,7 +227,9 @@ export function TemplateEditDialog({
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting {isSubmitting
? dict.common.loading ? dict.common.loading
: dict.common.save} : isEdit
? dict.common.save
: dict.templates.createButton}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
+24 -80
View File
@@ -11,6 +11,7 @@ import {
Trash2, Trash2,
Upload, Upload,
} from "lucide-react" } from "lucide-react"
import { useParams } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { import {
AlertDialog, AlertDialog,
@@ -23,62 +24,33 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import { import {
deleteTemplate, deleteTemplate,
duplicateTemplate, duplicateTemplate,
exportTemplates, exportTemplates,
getAllTemplates, getAllTemplates,
importTemplates, importTemplates,
incrementClickCount,
incrementRunCount, incrementRunCount,
searchTemplates, searchTemplates,
type Template, type Template,
updateTemplate, updateTemplate,
validateImportData, validateImportData,
} from "@/lib/template-storage" } from "@/lib/template-storage"
import { TemplateCreateDialog } from "./TemplateCreateDialog" import { TemplateFormDialog } from "./TemplateFormDialog"
import { TemplateEditDialog } from "./TemplateEditDialog"
interface TemplatePanelProps { interface TemplatePanelProps {
setInput: (input: string) => void /** Whether it was sent */
onSendTemplate?: (template: Template) => void onSendTemplate: (template: Template) => Promise<boolean>
currentInput?: string 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({ export function TemplatePanel({
setInput,
onSendTemplate, onSendTemplate,
currentInput = "", currentInput = "",
}: TemplatePanelProps) { }: TemplatePanelProps) {
const dict = useDictionary() const dict = useDictionary()
const params = useParams<{ lang: string }>()
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false)
@@ -110,10 +82,6 @@ export function TemplatePanel({
useEffect(() => { useEffect(() => {
loadTemplates() loadTemplates()
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates]) }, [loadTemplates])
const handleCreateSuccess = () => { const handleCreateSuccess = () => {
@@ -132,7 +100,7 @@ export function TemplatePanel({
const handleDuplicate = async (template: Template) => { const handleDuplicate = async (template: Template) => {
const duplicated = await duplicateTemplate( const duplicated = await duplicateTemplate(
template.id, template.id,
dict.templates.copySuffix || "(copy)", dict.templates.copySuffix,
) )
if (duplicated) { if (duplicated) {
loadTemplates() loadTemplates()
@@ -178,18 +146,13 @@ export function TemplatePanel({
// Actually send the template // Actually send the template
const sendTemplate = async (template: Template) => { const sendTemplate = async (template: Template) => {
// Increment click count only when actually sending // A run only when it was sent (not while a message is still being
await incrementClickCount(template.id) // sent, or attachments read)
if (onSendTemplate) { if (await onSendTemplate(template)) {
// Increment run count and update lastUsedAt // Increment run count and update lastUsedAt
await incrementRunCount(template.id) await incrementRunCount(template.id)
// Reload to show updated stats // Reload to show updated stats
loadTemplates() loadTemplates()
// Call the send callback
onSendTemplate(template)
} else {
// Fallback: just fill the input if no send callback provided
setInput(template.prompt)
} }
setConfirmSendDialogOpen(false) setConfirmSendDialogOpen(false)
setTemplateToSend(null) setTemplateToSend(null)
@@ -212,7 +175,7 @@ export function TemplatePanel({
if (templates.length === 0) { if (templates.length === 0) {
setImportMessage({ setImportMessage({
type: "error", type: "error",
text: dict.templates.exportEmpty || "No templates to export", text: dict.templates.exportEmpty,
}) })
return return
} }
@@ -254,8 +217,7 @@ export function TemplatePanel({
if (!file) { if (!file) {
setImportMessage({ setImportMessage({
type: "error", type: "error",
text: text: dict.templates.importNoFile,
dict.templates.importNoFile || "Please select a JSON file",
}) })
return return
} }
@@ -333,14 +295,6 @@ export function TemplatePanel({
if (!loading && templates.length === 0) { if (!loading && templates.length === 0) {
return ( return (
<div className="py-6 px-2 animate-fade-in"> <div className="py-6 px-2 animate-fade-in">
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{dict.templates.title}
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
{dict.templates.subtitle}
</p>
</div>
<div className="flex flex-col items-center justify-center py-8 px-4"> <div className="flex flex-col items-center justify-center py-8 px-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4"> <div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-primary/60" /> <FileText className="w-8 h-8 text-primary/60" />
@@ -372,7 +326,7 @@ export function TemplatePanel({
<div className="mt-3">{importMessageBox}</div> <div className="mt-3">{importMessageBox}</div>
)} )}
<TemplateCreateDialog <TemplateFormDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
@@ -502,7 +456,7 @@ export function TemplatePanel({
<div className="relative shrink-0"> <div className="relative shrink-0">
<div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible"> <div className="text-[11px] text-muted-foreground whitespace-nowrap group-hover:invisible">
{template.runCount > 0 {template.runCount > 0
? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${formatLastUsed(template.lastUsedAt, dict.templates.neverUsed)}` ? `${dict.templates.usedCount.replace("{count}", String(template.runCount))} · ${template.lastUsedAt ? formatRelativeTime(template.lastUsedAt, params.lang, dict.sessionHistory.justNow) : dict.templates.neverUsed}`
: dict.templates.neverUsed} : dict.templates.neverUsed}
</div> </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"> <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">
@@ -519,10 +473,8 @@ export function TemplatePanel({
}`} }`}
title={ title={
template.pinned template.pinned
? dict.templates ? dict.templates.unpin
.unpin || "Unpin" : dict.templates.pin
: dict.templates.pin ||
"Pin"
} }
> >
<Bookmark <Bookmark
@@ -547,10 +499,7 @@ export function TemplatePanel({
handleDuplicate(template) handleDuplicate(template)
}} }}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all" className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
title={ title={dict.templates.duplicate}
dict.templates.duplicate ||
"Duplicate"
}
> >
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
</button> </button>
@@ -572,13 +521,13 @@ export function TemplatePanel({
</div> </div>
</div> </div>
<TemplateCreateDialog <TemplateFormDialog
open={createDialogOpen} open={createDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={setCreateDialogOpen}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
/> />
<TemplateEditDialog <TemplateFormDialog
open={editDialogOpen} open={editDialogOpen}
onOpenChange={setEditDialogOpen} onOpenChange={setEditDialogOpen}
template={templateToEdit} template={templateToEdit}
@@ -593,12 +542,10 @@ export function TemplatePanel({
<AlertDialogContent className="max-w-sm"> <AlertDialogContent className="max-w-sm">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{dict.templates.deleteTitle || {dict.templates.deleteTitle}
"Delete this template?"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{dict.templates.deleteDescription || {dict.templates.deleteDescription}
"This will permanently delete this template. This action cannot be undone."}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -623,12 +570,10 @@ export function TemplatePanel({
<AlertDialogContent className="max-w-sm"> <AlertDialogContent className="max-w-sm">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{dict.templates.confirmSendTitle || {dict.templates.confirmSendTitle}
"Replace current input?"}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{dict.templates.confirmSendDescription || {dict.templates.confirmSendDescription}
"You have unsent content in the input. Sending this template will replace it."}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -636,8 +581,7 @@ export function TemplatePanel({
{dict.common.cancel} {dict.common.cancel}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSend}> <AlertDialogAction onClick={handleConfirmSend}>
{dict.templates.confirmSendButton || {dict.templates.confirmSendButton}
"Send Template"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
-267
View File
@@ -1,267 +0,0 @@
"use client"
import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
import type { Dispatch, SetStateAction } from "react"
import { CodeBlock } from "@/components/code-block"
import { isMxCellXmlComplete } from "@/lib/utils"
import type { DiagramOperation, ToolPartLike } from "./types"
interface ToolCallCardProps {
part: ToolPartLike
expandedTools: Record<string, boolean>
setExpandedTools: Dispatch<SetStateAction<Record<string, boolean>>>
onCopy: (callId: string, text: string, isToolCall: boolean) => void
copiedToolCallId: string | null
copyFailedToolCallId: string | null
dict: {
tools: { complete: string }
chat: { copied: string; failedToCopy: string; copyResponse: string }
}
}
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
// Streamed or invalid input can hold anything: show only what React can
// render (an object in place of a string would crash the whole chat)
const shown = operations.filter(
(op) => typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) => (typeof value === "string" ? value : "")
return (
<div className="space-y-3">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span
className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete"
? "text-red-600"
: op.operation === "add"
? "text-green-600"
: "text-blue-600"
}`}
>
{op.operation}
</span>
<span className="text-xs text-muted-foreground">
cell_id: {text(op.cell_id)}
</span>
</div>
{text(op.new_xml) && (
<div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{text(op.new_xml)}
</pre>
</div>
)}
</div>
))}
</div>
)
}
export function ToolCallCard({
part,
expandedTools,
setExpandedTools,
onCopy,
copiedToolCallId,
copyFailedToolCallId,
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output, errorText } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram"
case "get_shape_library":
return "Get Shape Library"
default:
return name
}
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves. Without an input the JSON was
// broken (the server repairs JSON cut short by the limit).
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof input?.xml === "string" &&
!isMxCellXmlComplete(input.xml)
const handleCopy = () => {
let textToCopy = ""
if (input && typeof input === "object") {
if (input.xml) {
textToCopy = input.xml
} else if (input.operations && Array.isArray(input.operations)) {
textToCopy = JSON.stringify(input.operations, null, 2)
} else if (Object.keys(input).length > 0) {
textToCopy = JSON.stringify(input, null, 2)
}
}
if (
output &&
toolName === "get_shape_library" &&
typeof output === "string"
) {
textToCopy = output
}
if (textToCopy) {
onCopy(callId, textToCopy, true)
}
}
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<>
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
{dict.tools.complete}
</span>
{isExpanded && (
<button
type="button"
onClick={handleCopy}
className="p-1 rounded hover:bg-muted transition-colors"
title={
copiedToolCallId === callId
? dict.chat.copied
: copyFailedToolCallId === callId
? dict.chat.failedToCopy
: dict.chat.copyResponse
}
>
{isCopied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</>
)}
{state === "output-error" &&
(isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
))}
{input && Object.keys(input).length > 0 && (
<button
type="button"
onClick={toggleExpanded}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
)}
</div>
</div>
{input && isExpanded && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
state === "input-streaming" ||
state === "input-available" ? (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all whitespace-pre-wrap"
style={{
fontFamily:
"var(--font-mono), ui-monospace, monospace",
margin: 0,
padding: 0,
}}
>
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
) : typeof input === "object" &&
input.operations &&
Array.isArray(input.operations) ? (
<OperationsDisplay operations={input.operations} />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
{/* AI SDK stores tool errors in errorText */}
{state === "output-error" && (errorText || output) && (
<div
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
: (errorText ?? output)}
</div>
)}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
state === "output-available" &&
isExpanded && (
<div className="px-4 py-3 border-t border-border/40">
<div className="text-xs text-muted-foreground mb-2">
Library loaded (
{typeof output === "string" ? output.length : 0}{" "}
chars)
</div>
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
{typeof output === "string"
? output.substring(0, 800) +
(output.length > 800 ? "\n..." : "")
: String(output)}
</pre>
</div>
)}
</div>
)
}
-4
View File
@@ -16,7 +16,6 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus = export type ValidationStatus =
| "idle"
| "capturing" | "capturing"
| "validating" | "validating"
| "success" | "success"
@@ -159,13 +158,10 @@ export function ValidationCard({
color: "text-gray-600 bg-gray-50", color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />, icon: <Check className="h-4 w-4" aria-hidden="true" />,
} }
default:
return null
} }
} }
const statusDisplay = getStatusDisplay() const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return ( return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"> <div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
+77
View File
@@ -0,0 +1,77 @@
import { MAX_FILES, MAX_FILE_SIZE as MAX_IMAGE_SIZE } from "@/lib/chat-helpers"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
/** Accept list for the attachment picker */
export const ATTACHMENT_ACCEPT =
"image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
}
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
/** Keep the files that can be attached; describe the rest as errors */
export function validateFiles(
newFiles: File[],
existingCount: number,
dict: any,
): { validFiles: File[]; errors: string[] } {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(formatMessage(dict.errors.maxFiles, { max: MAX_FILES }))
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(
formatMessage(dict.errors.onlyMoreAllowed, {
slots: availableSlots,
}),
)
break
}
if (!isValidFileType(file)) {
errors.push(
formatMessage(dict.errors.unsupportedType, { name: file.name }),
)
continue
}
// PDFs and text files are extracted in the browser, so only images
// have a size limit
const isExtractedFile = isPdfFile(file) || isTextFile(file)
if (!isExtractedFile && file.size > MAX_IMAGE_SIZE) {
errors.push(
formatMessage(dict.errors.fileExceeds, {
name: file.name,
size: formatFileSize(file.size),
max: MAX_IMAGE_SIZE / 1024 / 1024,
}),
)
} else {
validFiles.push(file)
}
}
return { validFiles, errors }
}
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
import {
ChevronDown,
Download,
FilePlus,
MessageSquareDashed,
PanelRightClose,
Settings,
} from "lucide-react"
import { useRef } from "react"
import { VersionStrip } from "@/components/canvas/version-strip"
import { DevXmlSimulator } from "@/components/dev-xml-simulator"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { usePanelWidth } from "@/hooks/use-panel-width"
import { modKey } from "@/lib/platform"
import { cn } from "@/lib/utils"
import {
PANEL_MAX_WIDTH,
PANEL_MIN_WIDTH,
useSettingsStore,
} from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { MessageList } from "./message-list"
import { SessionMenu, useSessionTitle } from "./session-menu"
const DEBUG = process.env.NODE_ENV === "development"
/** Drag handle on the panel's left edge (the panel sits on the right) */
function ResizeHandle() {
const panelWidth = usePanelWidth()
const setPanelWidth = useSettingsStore((s) => s.setPanelWidth)
const startRef = useRef<{ x: number; width: number } | null>(null)
return (
// biome-ignore lint/a11y/useSemanticElements: a draggable handle, not a divider line
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={panelWidth}
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuemax={PANEL_MAX_WIDTH}
tabIndex={-1}
className="absolute top-4 -left-1.5 bottom-4 z-10 w-3 cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 after:-translate-x-1/2 after:rounded-full after:bg-foreground/0 after:transition-colors hover:after:bg-foreground/15"
onPointerDown={(e) => {
e.preventDefault()
const panel = e.currentTarget.parentElement
startRef.current = {
x: e.clientX,
width: panel?.getBoundingClientRect().width ?? 384,
}
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
const start = startRef.current
if (!start) return
// Dragging left widens the panel
setPanelWidth(start.width - (e.clientX - start.x))
}}
onPointerUp={(e) => {
startRef.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}}
/>
)
}
function EmptyThread() {
const dict = useDictionary()
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-8 text-center">
<MessageSquareDashed className="size-5 text-faint" />
<p className="max-w-60 text-[13px] text-muted-foreground">
{dict.chat.emptyThread}
</p>
</div>
)
}
/** The floating chat panel: header, versions, conversation, message box */
export function ChatPanel({
mobile = false,
onOpenFile,
}: {
mobile?: boolean
onOpenFile: () => void
}) {
const dict = useDictionary()
const engine = useChatEngine()
const { loadDiagram, isDrawioReady } = useDiagram()
const setSaveDialogOpen = useUiStore((s) => s.setSaveDialogOpen)
const title = useSessionTitle()
const panelWidth = usePanelWidth()
const openSettings = useUiStore((s) => s.openSettings)
const setPanelOpen = useUiStore((s) => s.setPanelOpen)
// Versions with no card in the chat: saved by older app versions, from
// a turn that was retried, or drawn by a call that then failed its
// screenshot check (a call still running gets its card when done). The
// strip shows them even when there is only one.
const versions = useVersionsStore((s) => s.versions)
const hasCardless = versions.some(
(v) =>
!v.toolCallId ||
!engine.messages.some((m) =>
m.parts?.some((p) => {
const part = p as { toolCallId?: string; state?: string }
return (
part.toolCallId === v.toolCallId &&
part.state !== "output-error"
)
}),
),
)
return (
<aside
className={cn(
"flex flex-col bg-card",
mobile
? "absolute inset-0"
: "absolute top-3 right-3 bottom-3 z-20 rounded-2xl shadow-float animate-panel-in",
)}
style={mobile ? undefined : { width: panelWidth }}
aria-label={dict.nav.aiChat}
data-testid="chat-panel"
>
<header className="flex h-[52px] shrink-0 items-center gap-1 pr-2 pl-3">
<BrandMark />
<SessionMenu onOpenFile={onOpenFile}>
<button
type="button"
className="ml-1 flex min-w-0 items-center gap-1 rounded-lg px-1.5 py-1 hover:bg-accent"
data-testid="session-title"
>
<span className="truncate text-[15px] font-semibold tracking-[-0.01em]">
{title}
</span>
<ChevronDown className="size-4 shrink-0 text-faint" />
</button>
</SessionMenu>
<div className="ml-auto flex items-center">
{isDrawioReady && (
<IconButton
label={dict.workspace.export}
onClick={() => setSaveDialogOpen(true)}
data-testid="export-button"
>
<Download />
</IconButton>
)}
<IconButton
label={dict.nav.newChat}
onClick={engine.newChat}
disabled={engine.isBusy}
data-testid="new-chat-button"
>
<FilePlus />
</IconButton>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings />
</IconButton>
{!mobile && (
<IconButton
label={dict.nav.hidePanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(false)}
>
<PanelRightClose />
</IconButton>
)}
</div>
</header>
<VersionStrip minVersions={hasCardless ? 1 : 2} />
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
{engine.messages.length > 0 ? <MessageList /> : <EmptyThread />}
</div>
{DEBUG && (
<DevXmlSimulator
setMessages={engine.setMessages as any}
onDisplayChart={(xml) => loadDiagram(xml, true)}
/>
)}
<div className="shrink-0 px-4 pt-1 pb-3">
<Composer
placeholder={
engine.messages.length > 0 &&
engine.chatSelection.length === 0
? dict.chat.placeholderFollowUp
: undefined
}
/>
</div>
{!mobile && <ResizeHandle />}
</aside>
)
}
+46
View File
@@ -0,0 +1,46 @@
"use client"
import {
VersionsProvider,
type VersionsSource,
} from "@/components/canvas/versions-context"
import { useCanvasStore } from "@/stores/canvas-store"
import { useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
/**
* The chat's versions for the canvas components (version cards, the strip,
* the compare dialog): the version store's list and flags, and the chat
* engine's restore and undo
*/
export function ChatVersionsProvider({
children,
}: {
children: React.ReactNode
}) {
const { restoreVersion, undoVersion } = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const onCanvasId = useVersionsStore((s) => s.onCanvasVersionId)
const undoneId = useVersionsStore((s) => s.undoneVersionId)
const isBusy = useCanvasStore((s) => s.isBusy)
// Only the latest version can be undone, and only while it is (or was)
// on the canvas: after an older version was restored, the latest one
// is restored too
const latest = versions.at(-1)
const undoable = !!latest && latest.beforeXml !== undefined
const value: VersionsSource = {
versions,
onCanvasId,
undoneId,
isBusy,
canUndo: undoable && latest.id === onCanvasId,
canRedo: undoable && latest.id === undoneId,
restore: restoreVersion,
// The engine's undo toggles: it redoes a change that is undone
undo: () => latest && undoVersion(latest.id),
redo: () => latest && undoVersion(latest.id),
}
return <VersionsProvider value={value}>{children}</VersionsProvider>
}
+539
View File
@@ -0,0 +1,539 @@
import {
ArrowUp,
BookMarked,
BookmarkPlus,
Globe,
Paperclip,
Plus,
Square,
SquareDashedMousePointer,
X,
} from "lucide-react"
import type React from "react"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { toast } from "sonner"
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
import { TemplatePanel } from "@/components/chat/TemplatePanel"
import { FilePreviewList } from "@/components/file-preview-list"
import { ModelSelector } from "@/components/model-selector"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { UrlInputDialog } from "@/components/url-input-dialog"
import { IconButton } from "@/components/workspace/icon-button"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { modKey } from "@/lib/platform"
import { extractUrlContent } from "@/lib/url-utils"
import { cn } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments"
import { useChatEngine } from "./chat-engine"
function showValidationErrors(errors: string[], dict: any) {
if (errors.length === 0) return
if (errors.length === 1) {
toast.error(
<span className="text-muted-foreground">{errors[0]}</span>,
{ duration: 5000 },
)
return
}
toast.error(
<div className="flex flex-col gap-1">
<span className="font-medium">
{formatMessage(dict.errors.filesRejected, {
count: errors.length,
})}
</span>
<ul className="list-inside list-disc text-xs text-muted-foreground">
{errors.slice(0, 3).map((err) => (
<li key={err}>{err}</li>
))}
{errors.length > 3 && (
<li>
{formatMessage(dict.errors.andMore, {
count: errors.length - 3,
})}
</li>
)}
</ul>
</div>,
{ duration: 5000 },
)
}
function MenuItem({
icon,
children,
onClick,
disabled,
disabledHint,
}: {
icon: React.ReactNode
children: React.ReactNode
onClick: () => void
disabled?: boolean
/** Why the item can't be used now, shown under it while disabled */
disabledHint?: string
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="group/item flex w-full items-start gap-2.5 rounded-md px-2.5 py-2 text-left text-[13px] hover:bg-accent disabled:pointer-events-none [&_svg]:mt-px [&_svg]:size-4 [&_svg]:text-muted-foreground"
>
<span className="contents group-disabled/item:[&>svg]:opacity-40">
{icon}
</span>
<span className="min-w-0">
<span className="block group-disabled/item:opacity-40">
{children}
</span>
{disabled && disabledHint && (
<span className="block text-xs text-muted-foreground">
{disabledHint}
</span>
)}
</span>
</button>
)
}
/**
* The message box: text, attachments, the selected shapes, the model picker
* and send/stop. `variant="hero"` is the large box on the start screen.
*/
export function Composer({
variant = "panel",
placeholder,
autoFocus = false,
}: {
variant?: "panel" | "hero"
placeholder?: string
autoFocus?: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const {
input,
setInput,
files,
setFiles,
pdfData,
urlData,
setUrlData,
isBusy,
error,
chatSelection,
dismissSelection,
modelConfig,
} = engine
const sendShortcut = useSettingsStore((s) => s.sendShortcut)
const openSettings = useUiStore((s) => s.openSettings)
const focusToken = useUiStore((s) => s.focusComposerToken)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [templatesOpen, setTemplatesOpen] = useState(false)
const [showUrlDialog, setShowUrlDialog] = useState(false)
const [isExtractingUrl, setIsExtractingUrl] = useState(false)
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
const isHero = variant === "hero"
// Allow a retry when the last request failed, even mid-stream. While
// the chat is being left, what is typed could not be sent.
const isDisabled = (isBusy && !error) || engine.isLeaving
const canSend =
!isDisabled && !engine.isExtractingAttachments && !!input.trim()
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`
}, [])
useEffect(() => {
adjustHeight()
}, [input, adjustHeight])
// Focus requests from elsewhere (new chat, "ask AI" button, ⌘/)
useEffect(() => {
if (focusToken === 0 && !autoFocus) return
const timer = setTimeout(() => textareaRef.current?.focus(), 60)
return () => clearTimeout(timer)
}, [focusToken, autoFocus])
// The chat is left while the user types (browser back): the browser
// takes the focus from the input it disables, after this runs. It gets
// the focus back once enabled, unless something else took it meanwhile.
const refocusRef = useRef(false)
const isLeaving = engine.isLeaving
useLayoutEffect(() => {
if (isDisabled) {
if (isLeaving && document.activeElement === textareaRef.current) {
refocusRef.current = true
}
return
}
if (!refocusRef.current) return
refocusRef.current = false
if (document.activeElement === document.body) {
textareaRef.current?.focus()
}
}, [isDisabled, isLeaving])
const addFiles = (newFiles: File[]) => {
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) setFiles([...files, ...validFiles])
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) {
e.preventDefault()
if (canSend) engine.submit()
}
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return
const imageItems = Array.from(e.clipboardData.items).filter((item) =>
item.type.startsWith("image/"),
)
if (imageItems.length === 0) return
const imageFiles = imageItems
.map((item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
})
.filter((f): f is File => f !== null)
addFiles(imageFiles)
}
const handleUrlExtract = async (url: string) => {
setIsExtractingUrl(true)
// Functional updates, so a removal or send made while extracting is
// not overwritten when the request finishes
try {
setUrlData((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const data = await extractUrlContent(url)
setUrlData((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
setShowUrlDialog(false)
} catch (err) {
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
toast.error(
<span className="text-muted-foreground">
{err instanceof Error ? err.message : dict.url.failed}
</span>,
{ duration: 5000 },
)
} finally {
setIsExtractingUrl(false)
}
}
const selectionLabel =
chatSelection.length === 1
? formatMessage(dict.selection.one, {
label:
chatSelection[0].label ||
(chatSelection[0].isEdge
? dict.selection.connector
: dict.selection.shape),
})
: formatMessage(dict.selection.many, {
count: chatSelection.length,
})
const hasChips =
chatSelection.length > 0 || files.length > 0 || urlData.size > 0
return (
<form
id={isHero ? "hero-chat-form" : "chat-form"}
onSubmit={(e) => {
e.preventDefault()
if (canSend) engine.submit()
}}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragging(false)
}}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
if (!isDisabled) addFiles(Array.from(e.dataTransfer.files))
}}
className={cn(
"group/composer relative w-full rounded-[14px] border border-border bg-card transition-[border-color,box-shadow] duration-150",
"focus-within:border-foreground/20 focus-within:shadow-[0_0_0_4px_var(--accent)]",
isHero && "rounded-[18px] border-transparent shadow-pop",
isDragging &&
"border-foreground/30 shadow-[0_0_0_4px_var(--marker-soft)]",
)}
>
{hasChips && (
<div className="flex flex-wrap items-center gap-1.5 px-2.5 pt-2.5">
{chatSelection.length > 0 && (
<span
className="inline-flex max-w-full items-center gap-1.5 rounded-lg bg-marker-soft py-1 pr-1 pl-2 text-xs font-medium text-marker-ink"
data-testid="selection-chip"
>
<SquareDashedMousePointer className="size-3.5 shrink-0" />
<span className="truncate">{selectionLabel}</span>
<button
type="button"
onClick={dismissSelection}
aria-label={dict.selection.remove}
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<X className="size-3" />
</button>
</span>
)}
{(files.length > 0 || urlData.size > 0) && (
// A short window keeps room for the buttons below
<div className="max-h-[min(176px,22vh)] w-full overflow-y-auto scrollbar-thin">
<FilePreviewList
files={files}
onRemoveFile={(file) =>
setFiles(files.filter((f) => f !== file))
}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={(url) =>
setUrlData((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
}
/>
</div>
)}
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={
placeholder ??
(chatSelection.length > 0
? dict.selection.placeholder
: dict.chat.placeholder)
}
disabled={isDisabled}
aria-label={dict.chat.inputLabel}
data-testid="chat-input"
rows={isHero ? 3 : 2}
className={cn(
// A short window keeps room for the buttons below
"block max-h-[min(220px,30vh)] w-full resize-none bg-transparent text-foreground outline-none placeholder:text-faint scrollbar-thin disabled:opacity-60",
isHero
? "min-h-[84px] px-[18px] pt-4 pb-1 text-[15px] leading-relaxed"
: "min-h-[52px] px-3.5 pt-3 pb-1 text-sm",
)}
/>
<div
className={cn(
"flex items-center gap-0.5",
isHero ? "px-2.5 pt-1 pb-2.5" : "px-1.5 pt-0.5 pb-1.5",
)}
>
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<IconButton
label={dict.chat.attach}
tooltipSide="top"
disabled={isDisabled}
data-testid="composer-add"
>
<Plus />
</IconButton>
</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-64 p-1"
>
<MenuItem
icon={<Paperclip />}
onClick={() => {
setAddOpen(false)
fileInputRef.current?.click()
}}
>
{dict.chat.uploadFile}
</MenuItem>
<MenuItem
icon={<Globe />}
onClick={() => {
setAddOpen(false)
setShowUrlDialog(true)
}}
>
{dict.chat.ExtractURL}
</MenuItem>
<MenuItem
icon={<BookmarkPlus />}
disabled={!input.trim()}
disabledHint={dict.templates.saveDisabledHint}
onClick={() => {
setAddOpen(false)
setSaveTemplateOpen(true)
}}
>
{dict.templates.saveAsTemplate}
</MenuItem>
</PopoverContent>
</Popover>
<IconButton
label={dict.templates.myTemplates}
tooltipSide="top"
disabled={isDisabled}
onClick={() => setTemplatesOpen(true)}
>
<BookMarked />
</IconButton>
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
<ModelSelector
models={modelConfig.models}
selectedModelId={modelConfig.selectedModelId}
onSelect={modelConfig.setSelectedModelId}
onConfigure={() => openSettings("models")}
onAddProvider={() => openSettings("models", "picker")}
disabled={isDisabled}
/>
{!isBusy && input.trim() && (
<kbd
className="hidden shrink-0 font-sans text-[11px] text-faint sm:inline"
aria-hidden
>
{sendShortcut === "enter" ? "↵" : `${modKey}↵`}
</kbd>
)}
{isBusy ? (
<button
type="button"
onClick={engine.stop}
aria-label={dict.chat.stopGeneration}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-foreground text-background transition-opacity hover:opacity-85"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
type="submit"
disabled={!canSend}
aria-label={dict.chat.send}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-[opacity,background-color] hover:opacity-90 disabled:bg-muted disabled:text-faint"
>
<ArrowUp className="size-4 stroke-[2.25]" />
</button>
)}
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={ATTACHMENT_ACCEPT}
data-testid="attachment-input"
multiple
disabled={isDisabled}
onChange={(e) => {
addFiles(Array.from(e.target.files || []))
e.target.value = ""
}}
/>
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
<TemplateFormDialog
open={saveTemplateOpen}
onOpenChange={setSaveTemplateOpen}
onSuccess={() => setSaveTemplateOpen(false)}
initialPrompt={input.trim()}
/>
<Dialog open={templatesOpen} onOpenChange={setTemplatesOpen}>
<DialogContent className="flex max-h-[80vh] flex-col gap-0 p-0 sm:max-w-lg">
<DialogHeader className="px-5 pt-5 pb-2">
<DialogTitle>{dict.templates.myTemplates}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 scrollbar-thin">
<TemplatePanel
currentInput={input}
onSendTemplate={(template) => {
setTemplatesOpen(false)
return engine.sendTemplate(template.prompt)
}}
/>
</div>
</DialogContent>
</Dialog>
</form>
)
}
+272
View File
@@ -0,0 +1,272 @@
import { FileUp, History, Pencil } from "lucide-react"
import type React from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
import { useChatEngine } from "./chat-engine"
import { Composer } from "./composer"
import { McpNotice } from "./mcp-notice"
import { SessionMenu } from "./session-menu"
interface Example {
key: string
title: string
description: string
/** Picture of the result (or of the input, for "replicate" examples) */
image: string
prompt: string
/** Bundled file sent with the prompt; matches the cached example */
file?: { path: string; name: string; type: string }
}
/** Secondary start buttons: outlined, so they read as clickable */
const startButtonClass =
"inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-card px-3 text-[13px] text-foreground transition-colors hover:bg-accent [&_svg]:text-muted-foreground"
async function loadExampleFile(file: NonNullable<Example["file"]>) {
const response = await fetch(getAssetUrl(file.path))
const blob = await response.blob()
return new File([blob], file.name, { type: file.type })
}
/** One start-screen example: a small picture over its title */
function ExampleTile({
onClick,
title,
description,
picture,
testId,
}: {
onClick: () => void
title: string
/** Shown as the tooltip: the title alone names the example */
description: string
picture: React.ReactNode
testId?: string
}) {
return (
<button
type="button"
onClick={onClick}
title={description}
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">
{picture}
</span>
<span className="px-1 text-[13px] leading-snug font-medium text-foreground">
{title}
</span>
</button>
)
}
/**
* Start screen over an empty canvas: one large message box and the cached
* examples (they answer instantly without calling a model).
*/
export function LobbyHero({
onDrawYourself,
onOpenFile,
compact = false,
}: {
onDrawYourself: () => void
onOpenFile: () => void
/** Phone layout: no example pictures, tighter spacing */
compact?: boolean
}) {
const dict = useDictionary()
const t = dict.lobby
const engine = useChatEngine()
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
// Prompts and file names must stay as they are: cached responses match them
const examples: Example[] = [
{
key: "paper",
title: dict.examples.paperToDiagram,
description: dict.examples.paperDescription,
image: "/paper_demo.svg",
prompt: "Summarize this paper as a diagram",
file: {
path: "/chain-of-thought.txt",
name: "chain-of-thought.txt",
type: "text/plain",
},
},
{
key: "animated",
title: dict.examples.animatedDiagram,
description: dict.examples.animatedDescription,
image: "/animated_connectors.svg",
prompt: "Give me a **animated connector** diagram of transformer's architecture",
},
{
key: "aws",
title: dict.examples.awsArchitecture,
description: dict.examples.awsDescription,
image: "/aws_demo.svg",
prompt: "Replicate this in aws style",
file: {
path: "/architecture.png",
name: "architecture.png",
type: "image/png",
},
},
{
key: "flowchart",
title: dict.examples.replicateFlowchart,
description: dict.examples.replicateDescription,
image: "/example.png",
prompt: "Replicate this flowchart.",
file: {
path: "/example.png",
name: "example.png",
type: "image/png",
},
},
{
key: "cat",
title: dict.examples.creativeDrawing,
description: dict.examples.creativeDescription,
image: "/cat_demo.svg",
prompt: "Draw a cat for me",
},
]
const pickExample = async (example: Example) => {
engine.setInput(example.prompt)
if (!example.file) {
engine.setFiles([])
return
}
try {
engine.setFiles([await loadExampleFile(example.file)])
} catch (error) {
console.error(dict.errors.failedToLoadExample, error)
}
}
return (
<div
className={cn(
"mx-auto w-full",
compact ? "max-w-xl px-4 py-8" : "max-w-[720px] px-6 py-10",
)}
data-testid="lobby-hero"
>
<h1
className={cn(
"text-center font-semibold tracking-[-0.025em] text-foreground",
compact
? "text-[26px] leading-tight"
: "text-[34px] leading-[1.15]",
)}
>
{t.title}
</h1>
<p
className={cn(
"mx-auto mt-2.5 max-w-[34em] text-center text-muted-foreground",
compact ? "mb-5 text-[13px]" : "mb-6 text-[14.5px]",
)}
>
{t.lede}
</p>
{/* MCP server notice, hidden on self-hosted deployments. The lede's
margin already spans 24 px (20 px compact); pull the card up so
16 px remain above it */}
{!isSelfHosted && (
<McpNotice
className={cn("mb-5", compact ? "-mt-1" : "-mt-2")}
/>
)}
<Composer
variant="hero"
autoFocus={!compact}
placeholder={t.placeholder}
/>
{/* Examples: one row of tiles; the ways to bring your own
diagram follow as a separate group */}
<div
className={cn(
"mt-5 grid gap-2",
compact ? "grid-cols-1" : "grid-cols-5",
)}
>
{examples.map((example) =>
compact ? (
<button
key={example.key}
type="button"
onClick={() => pickExample(example)}
className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-left hover:border-foreground/25"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">
{example.title}
</span>
<span className="block truncate text-xs text-muted-foreground">
{example.description}
</span>
</span>
</button>
) : (
<ExampleTile
key={example.key}
onClick={() => pickExample(example)}
title={example.title}
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]"
/>
}
/>
),
)}
</div>
<p className="mt-2.5 text-center text-xs text-faint">
{dict.examples.cachedNote}
</p>
{/* Ways to start without an example, as plain buttons */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
{engine.sessions.length > 0 && (
<SessionMenu align="center" onOpenFile={onOpenFile}>
<button type="button" className={startButtonClass}>
<History className="size-4" />
{t.recent}
</button>
</SessionMenu>
)}
<button
type="button"
onClick={onOpenFile}
className={startButtonClass}
data-testid="open-file"
>
<FileUp className="size-4" />
{dict.workspace.openFile}
</button>
<button
type="button"
onClick={onDrawYourself}
className={startButtonClass}
data-testid="draw-yourself"
>
<Pencil className="size-4" />
{t.drawYourself}
</button>
</div>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
"use client"
import { ArrowUpRight, Terminal, X } from "lucide-react"
import { useEffect, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { getAssetUrl } from "@/lib/base-path"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn } from "@/lib/utils"
const MCP_URL =
"https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
/**
* Whether the user closed the MCP notice once. Starts as closed so the server
* render and the first client render agree; the stored choice is read after
* mount.
*/
function useVideoClosed() {
const [closed, setClosed] = useState(true)
useEffect(() => {
setClosed(localStorage.getItem(STORAGE_KEYS.mcpVideoClosed) === "true")
}, [])
const close = () => {
localStorage.setItem(STORAGE_KEYS.mcpVideoClosed, "true")
setClosed(true)
}
return [closed, close] as const
}
function PromoVideo({ className }: { className?: string }) {
return (
<video
src={getAssetUrl("/mcp-promo.mp4")}
poster={getAssetUrl("/mcp-promo-poster.jpg")}
autoPlay
muted
loop
playsInline
controls
preload="metadata"
className={cn("aspect-video w-full rounded-lg bg-black", className)}
data-testid="mcp-video"
/>
)
}
function CloseButton({ onClick }: { onClick: () => void }) {
const dict = useDictionary()
return (
<button
type="button"
onClick={onClick}
aria-label={dict.common.close}
title={dict.common.close}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
data-testid="mcp-video-close"
>
<X className="size-4" />
</button>
)
}
/** Icon, title and tagline of the MCP server, linking to its README */
function NoticeLink({ className }: { className?: string }) {
const dict = useDictionary()
return (
<a
href={MCP_URL}
target="_blank"
rel="noopener noreferrer"
className={cn("group flex min-w-0 items-center gap-3", className)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-purple-500/20">
<Terminal className="size-4 text-purple-500" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground transition-colors group-hover:text-purple-500">
{dict.examples.mcpServer}
</span>
<span className="block text-xs leading-relaxed text-balance text-muted-foreground">
{dict.examples.mcpTagline}
</span>
</span>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-purple-500" />
</a>
)
}
const cardClass =
"w-full rounded-xl border border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-blue-500/10"
/**
* MCP server notice on the start screen: the card with the promo video. Once
* the user closes it, it does not come back.
*/
export function McpNotice({ className }: { className?: string }) {
const [closed, close] = useVideoClosed()
if (closed) return null
return (
<div
className={cn(cardClass, "p-3", className)}
data-testid="mcp-notice"
>
<div className="flex items-center gap-2 pl-1">
<NoticeLink className="flex-1" />
<CloseButton onClick={close} />
</div>
<PromoVideo className="mt-3" />
</div>
)
}
+636
View File
@@ -0,0 +1,636 @@
import { isToolUIPart, type UIMessage } from "ai"
import {
AlertCircle,
BookmarkPlus,
Check,
ChevronDown,
Copy,
FileCode,
FileText,
Link,
Pencil,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import { Reasoning } from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { TemplateFormDialog } from "@/components/chat/TemplateFormDialog"
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"
import { useChatEngine } from "./chat-engine"
import {
getMessageTextContent,
getUserOriginalText,
splitTextIntoFileSections,
type TextSection,
} from "./message-text"
import { ToolActivity } from "./tool-activity"
function ActionButton({
label,
onClick,
active,
children,
}: {
label: string
onClick: () => void
active?: boolean
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
className={cn(
"inline-flex size-7 items-center justify-center rounded-lg text-faint transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-3.5",
active && "text-foreground",
)}
>
{children}
</button>
)
}
/** Copies a message's text, with a short "copied" state */
function CopyButton({ text }: { text: string }) {
const dict = useDictionary()
const { copied, copy } = useCopy(() =>
toast.error(dict.chat.failedToCopyDetail),
)
return (
<ActionButton
label={copied ? dict.chat.copied : dict.chat.copyResponse}
onClick={() => copy(text)}
>
{copied ? <Check /> : <Copy />}
</ActionButton>
)
}
function AttachedSection({ section }: { section: TextSection }) {
const [open, setOpen] = useState(false)
const dict = useDictionary()
const Icon =
section.fileType === "pdf"
? FileText
: section.fileType === "url"
? Link
: FileCode
const chars =
section.charCount && section.charCount >= 1000
? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left hover:bg-accent"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{section.filename}
</span>
<span className="shrink-0 text-[11px] text-faint">
{chars} {dict.file.chars}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<pre className="max-h-48 overflow-y-auto whitespace-pre-wrap border-t border-border px-2.5 py-2 text-xs text-muted-foreground scrollbar-thin">
{section.content}
</pre>
)}
</div>
)
}
function MessageText({ text }: { text: string }) {
const sections = splitTextIntoFileSections(text)
return (
<div className="space-y-2">
{sections.map((section, index) =>
section.type === "text" ? (
<div
key={index}
className={cn(
"prose prose-sm max-w-none break-words text-[14px] leading-relaxed text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"prose-p:my-1.5 prose-pre:bg-surface-2 prose-pre:text-foreground prose-code:before:content-none prose-code:after:content-none dark:prose-invert",
)}
>
<ReactMarkdown>{section.content}</ReactMarkdown>
</div>
) : (
<AttachedSection key={index} section={section} />
),
)}
</div>
)
}
function UserMessage({
message,
index,
isLast,
}: {
message: UIMessage
index: number
isLast: boolean
}) {
const dict = useDictionary()
const engine = useChatEngine()
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState("")
const [saveTemplate, setSaveTemplate] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const text = getMessageTextContent(message)
const canEdit = isLast && !engine.isBusy
useEffect(() => {
if (editing) textareaRef.current?.focus()
}, [editing])
const startEdit = () => {
if (!canEdit) return
setEditText(getUserOriginalText(message))
setEditing(true)
}
const submitEdit = async () => {
if (!editText.trim()) return
// Kept open, with its text, when it could not go now
if (await engine.editMessage(index, editText.trim())) setEditing(false)
}
if (editing) {
return (
<div className="ml-6 flex flex-col gap-2">
<textarea
ref={textareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
rows={Math.min(editText.split("\n").length + 1, 6)}
onKeyDown={(e) => {
if (e.key === "Escape") setEditing(false)
else if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey)
) {
e.preventDefault()
submitEdit()
}
}}
className="w-full resize-none rounded-xl border border-foreground/20 bg-card px-3 py-2.5 text-sm outline-none focus:shadow-[0_0_0_4px_var(--accent)]"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditing(false)}
className="h-7 rounded-lg px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{dict.common.cancel}
</button>
<button
type="button"
onClick={submitEdit}
disabled={!editText.trim()}
className="h-7 rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground disabled:opacity-40"
>
{dict.chat.saveAndSubmit}
</button>
</div>
</div>
)
}
const images = (message.parts ?? []).filter((p) => p.type === "file") as {
url: string
}[]
return (
<div className="group/message flex items-start justify-end gap-1">
<div className="mt-1 flex shrink-0 items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{canEdit && (
<ActionButton
label={dict.chat.editMessage}
onClick={startEdit}
>
<Pencil />
</ActionButton>
)}
<CopyButton text={text} />
<ActionButton
label={dict.templates.saveAsTemplate}
onClick={() => setSaveTemplate(true)}
>
<BookmarkPlus />
</ActionButton>
</div>
<div
className={cn(
"min-w-0 max-w-[85%] rounded-2xl rounded-br-md bg-secondary px-3.5 py-2.5",
canEdit &&
"cursor-pointer transition-colors hover:bg-interactive-active",
)}
role={canEdit ? "button" : undefined}
tabIndex={canEdit ? 0 : undefined}
onClick={(e) => {
// Not for a link or an attachment's toggle inside, nor
// when text was selected to copy it
const target = e.target as Element
if (target.closest("a, button")) return
if (window.getSelection()?.toString()) return
startEdit()
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return
if (canEdit && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
startEdit()
}
}}
title={canEdit ? dict.chat.clickToEdit : undefined}
>
{images.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{images.map((image, i) => (
<Image
key={i}
src={image.url}
width={96}
height={72}
alt={dict.chat.attachedImage}
className="h-[72px] w-24 rounded-lg border border-border bg-white object-cover"
/>
))}
</div>
)}
{text && <MessageText text={text} />}
</div>
{saveTemplate && (
<TemplateFormDialog
open
onOpenChange={(open) => !open && setSaveTemplate(false)}
onSuccess={() => setSaveTemplate(false)}
initialPrompt={getUserOriginalText(message)}
/>
)}
</div>
)
}
function AssistantMessage({
message,
index,
isLastMessage,
isLastAssistant,
}: {
message: UIMessage
index: number
isLastMessage: boolean
isLastAssistant: boolean
}) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
}
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
return (
<p>
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
</p>
)
}
const engine = useChatEngine()
const [feedback, setFeedback] = useState<"good" | "bad" | null>(null)
const isRestored = engine.loadedMessageIdsRef.current.has(message.id)
const parts = message.parts ?? []
const isCachedExample = parts.some((p: any) =>
p.toolCallId?.startsWith("cached-"),
)
const text = getMessageTextContent(message)
const submitFeedback = async (value: "good" | "bad") => {
if (feedback === value) {
setFeedback(null)
return
}
setFeedback(value)
try {
await fetch(getApiEndpoint("/api/log-feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messageId: message.id,
feedback: value,
sessionId: engine.langfuseSessionId,
}),
})
} catch (error) {
console.error("Failed to log feedback:", error)
toast.error(dict.errors.failedToRecordFeedback)
setFeedback(null)
}
}
// Text parts with nothing shown between them render together (a step
// boundary of an automatic retry shows nothing); tools render in place
const blocks: {
kind: "text" | "tool" | "reasoning"
index: number
texts: string[]
}[] = []
parts.forEach((part, partIndex) => {
if (part.type === "reasoning")
blocks.push({ kind: "reasoning", index: partIndex, texts: [] })
else if (isToolUIPart(part))
blocks.push({ kind: "tool", index: partIndex, texts: [] })
else if (part.type === "text") {
const text = (part as { text: string }).text
const last = blocks[blocks.length - 1]
if (last?.kind === "text") last.texts.push(text)
else blocks.push({ kind: "text", index: partIndex, texts: [text] })
}
})
return (
<div className="group/message space-y-2.5">
{blocks.map((block) => {
const part = parts[block.index]
if (block.kind === "reasoning") {
const isStreamingReasoning =
engine.status === "streaming" &&
isLastMessage &&
block.index === parts.length - 1
return (
<Reasoning
key={`r-${block.index}`}
className="w-full"
isStreaming={isStreamingReasoning}
defaultOpen={!isRestored}
getThinkingMessage={thinkingMessage}
>
{(part as { text: string }).text}
</Reasoning>
)
}
if (block.kind === "tool") {
const toolPart = part as unknown as ToolPartLike
const validationState =
engine.validationStates[toolPart.toolCallId]
return (
<div key={`t-${block.index}`} className="space-y-2">
<ToolActivity
part={toolPart}
isLastMessage={isLastMessage}
/>
{toolPart.type === "tool-display_diagram" &&
validationState && (
<ValidationCard
state={validationState}
onImproveWithSuggestions={
engine.improveWithSuggestions
}
/>
)}
</div>
)
}
const run = block.texts.join("\n")
if (!run.trim()) return null
return <MessageText key={`x-${block.index}`} text={run} />
})}
{!(engine.isBusy && isLastMessage) && (
<div className="-ml-1.5 flex items-center transition-opacity [@media(hover:hover)]:opacity-0 group-hover/message:opacity-100 focus-within:opacity-100">
{text && <CopyButton text={text} />}
{isLastAssistant && !isCachedExample && (
<ActionButton
label={dict.chat.regenerate}
onClick={() => engine.regenerate(index)}
>
<RotateCcw />
</ActionButton>
)}
<ActionButton
label={dict.chat.goodResponse}
active={feedback === "good"}
onClick={() => submitFeedback("good")}
>
<ThumbsUp
className={cn(
feedback === "good" && "fill-current",
)}
/>
</ActionButton>
<ActionButton
label={dict.chat.badResponse}
active={feedback === "bad"}
onClick={() => submitFeedback("bad")}
>
<ThumbsDown
className={cn(feedback === "bad" && "fill-current")}
/>
</ActionButton>
</div>
)}
</div>
)
}
/**
* A failed request: what to do in the text color, the provider's own words
* below it, and the action that can fix it first
*/
function ErrorNotice({
text,
openModelConfig,
onOpenModelConfig,
onRetry,
}: {
text: string
openModelConfig: boolean
onOpenModelConfig: () => void
onRetry?: () => void
}) {
const dict = useDictionary()
// Provider errors are "hint\n\nprovider message"
const split = text.indexOf("\n\n")
const headline = split >= 0 ? text.slice(0, split) : text
const detail = split >= 0 ? text.slice(split + 2).trim() : ""
const button =
"inline-flex h-7 items-center gap-1 rounded-lg px-2.5 text-xs font-medium"
return (
<div
role="alert"
className="rounded-xl border border-destructive/25 bg-destructive/5 px-3.5 py-3 text-[13px]"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-line break-words font-medium text-foreground">
{headline}
</p>
{detail && (
<p className="mt-1 whitespace-pre-line break-words text-xs text-muted-foreground">
{detail}
</p>
)}
{(openModelConfig || onRetry) && (
<div className="mt-2.5 flex flex-wrap gap-2">
{openModelConfig && (
<button
type="button"
onClick={onOpenModelConfig}
className={cn(
button,
"bg-primary text-primary-foreground hover:opacity-90",
)}
>
{dict.errors.llm.openModelSettings}
</button>
)}
{onRetry && (
<button
type="button"
onClick={onRetry}
className={cn(
button,
"border border-border bg-card text-foreground hover:bg-accent",
)}
data-testid="retry-button"
>
<RotateCcw className="size-3.5" />
{dict.chat.retry}
</button>
)}
</div>
)}
</div>
</div>
</div>
)
}
export function MessageList() {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
const engine = useChatEngine()
const { messages } = engine
const endRef = useRef<HTMLDivElement>(null)
const prevCountRef = useRef(0)
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep the newest message in view; jump instantly on bulk loads
useEffect(() => {
if (!endRef.current || messages.length === 0) return
const prev = prevCountRef.current
prevCountRef.current = messages.length
if (prev === 0 || messages.length - prev > 1) {
endRef.current.scrollIntoView({ behavior: "instant", block: "end" })
return
}
if (!throttleRef.current) {
endRef.current.scrollIntoView({ behavior: "smooth", block: "end" })
throttleRef.current = setTimeout(() => {
throttleRef.current = null
endRef.current?.scrollIntoView({
behavior: "smooth",
block: "end",
})
}, 150)
}
}, [messages])
// A notice (a failed request) is an assistant message without an answer
const lastAssistantIndex = messages.findLastIndex(
(m) => m.role === "assistant" && !noticeOf(m),
)
const lastUserIndex = messages.map((m) => m.role).lastIndexOf("user")
return (
<div className="space-y-4 px-4 pt-4 pb-2">
{messages.map((message, index) => {
const isRestored = engine.loadedMessageIdsRef.current.has(
message.id,
)
const notice = noticeOf(message)
return (
<div
key={message.id}
className={cn(!isRestored && "animate-message-in")}
>
{message.role === "user" ? (
<UserMessage
message={message}
index={index}
isLast={index === lastUserIndex}
/>
) : notice ? (
<ErrorNotice
text={notice.text}
openModelConfig={!!notice.openModelConfig}
onOpenModelConfig={() =>
openSettings(
"models",
notice.providerId
? { providerId: notice.providerId }
: "list",
)
}
onRetry={
index === messages.length - 1 &&
!engine.isBusy &&
lastUserIndex >= 0
? engine.retryLastMessage
: undefined
}
/>
) : (
<AssistantMessage
message={message}
index={index}
isLastMessage={index === messages.length - 1}
isLastAssistant={index === lastAssistantIndex}
/>
)}
</div>
)
})}
{engine.status === "submitted" &&
messages[messages.length - 1]?.role === "user" && (
<div className="px-1 text-[13px]" aria-live="polite">
<Shimmer as="span">{dict.reasoning.thinking}</Shimmer>
</div>
)}
<div ref={endRef} />
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { UIMessage } from "ai"
/** A run of plain text, or an attached file / URL whose text was inlined */
export interface TextSection {
type: "text" | "file" | "url"
content: string
filename?: string
charCount?: number
fileType?: "pdf" | "text" | "url"
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
export function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = []
const filePattern =
/\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = filePattern.exec(text)) !== null) {
const beforeText = text.slice(lastIndex, match.index).trim()
if (beforeText) sections.push({ type: "text", content: beforeText })
const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const content = match[3].trim()
sections.push({
type: sectionType === "url" ? "url" : "file",
content,
filename: match[2].trim(),
charCount: content.length,
fileType,
})
lastIndex = match.index + match[0].length
}
const remainingText = text.slice(lastIndex).trim()
if (remainingText) sections.push({ type: "text", content: remainingText })
if (sections.length === 0) sections.push({ type: "text", content: text })
return sections
}
export function getMessageTextContent(message: UIMessage): string {
if (!message.parts) return ""
return message.parts
.filter((part) => part.type === "text")
.map((part) => (part as { text: string }).text)
.join("\n")
}
/** Only the user's typed text, without appended file content */
export function getUserOriginalText(message: UIMessage): string {
return getMessageTextContent(message)
.replace(APPENDED_FILE_SECTIONS_PATTERN, "")
.trim()
}
+295
View File
@@ -0,0 +1,295 @@
import { FileUp, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { useParams } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatRelativeTime } from "@/lib/relative-time"
import { cn } from "@/lib/utils"
import { useChatEngine } from "./chat-engine"
/** Title shown for a session, with the default "New Chat" localized */
export function useSessionTitle(): string {
const dict = useDictionary()
const { currentTitle } = useChatEngine()
return !currentTitle || currentTitle === "New Chat"
? dict.workspace.untitled
: currentTitle
}
/**
* Popover listing saved diagrams: rename the current one, switch, delete,
* start a new one or open a file.
*/
export function SessionMenu({
children,
align = "start",
onOpenFile,
}: {
children: React.ReactNode
align?: "start" | "center" | "end"
onOpenFile: () => void
}) {
const dict = useDictionary()
const t = dict.sessionHistory
const params = useParams<{ lang: string }>()
const engine = useChatEngine()
const [open, setOpen] = useState(false)
const title = useSessionTitle()
const [query, setQuery] = useState("")
const [renaming, setRenaming] = useState(false)
const [draftTitle, setDraftTitle] = useState("")
const [toDelete, setToDelete] = useState<string | null>(null)
const renameRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) {
setRenaming(false)
setQuery("")
}
}, [open])
useEffect(() => {
if (renaming) renameRef.current?.select()
}, [renaming])
// The title as the list shows it
const shownTitle = (title: string) =>
title === "New Chat" ? dict.workspace.untitled : title
const sessions = engine.sessions.filter((s) =>
shownTitle(s.title).toLowerCase().includes(query.trim().toLowerCase()),
)
const commitRename = async () => {
const next = draftTitle.trim()
setRenaming(false)
if (next && next !== title) await engine.renameSession(next)
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
className="flex max-h-[min(560px,80vh)] w-[340px] flex-col overflow-hidden rounded-xl p-0 shadow-pop"
>
<div className="border-b border-border p-3">
{renaming ? (
<input
ref={renameRef}
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
// Enter that picks an IME candidate
// (Safari: keyCode 229)
if (
e.nativeEvent.isComposing ||
e.keyCode === 229
) {
return
}
if (e.key === "Enter") commitRename()
if (e.key === "Escape") setRenaming(false)
}}
maxLength={100}
aria-label={t.rename}
className="h-8 w-full rounded-lg border border-foreground/20 bg-card px-2.5 text-[13px] font-medium outline-none"
/>
) : (
<button
type="button"
onClick={() => {
setDraftTitle(title)
setRenaming(true)
}}
className="group/rename flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left hover:bg-accent"
data-testid="rename-session"
>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">
{title}
</span>
<Pencil className="size-3.5 shrink-0 text-muted-foreground" />
</button>
)}
<div className="mt-2 flex gap-1.5">
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
engine.newChat()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-40"
>
<Plus className="size-3.5" />
{dict.nav.newChat}
</button>
<button
type="button"
disabled={engine.isBusy}
onClick={() => {
setOpen(false)
onOpenFile()
}}
className="flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<FileUp className="size-3.5" />
{dict.workspace.openFile}
</button>
</div>
</div>
{engine.sessions.length > 0 && (
<div className="px-3 pt-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t.searchPlaceholder}
className="h-8 w-full rounded-lg bg-muted pr-2.5 pl-8 text-[13px] outline-none placeholder:text-faint"
/>
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-1.5 scrollbar-thin">
{engine.sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.empty}
</p>
) : sessions.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
{t.noResults}
</p>
) : (
sessions.map((session) => {
const isCurrent =
session.id === engine.currentSessionId
// The answer being written must stay in its
// own chat
const locked = engine.isBusy && !isCurrent
const choose = () => {
if (locked) return
setOpen(false)
if (!isCurrent) {
engine.selectSession(session.id)
}
}
return (
// biome-ignore lint/a11y/useSemanticElements: contains the delete button
<div
key={session.id}
role="button"
tabIndex={locked ? -1 : 0}
aria-disabled={locked || undefined}
title={locked ? t.busyHint : undefined}
onClick={choose}
onKeyDown={(e) => {
if (e.target !== e.currentTarget)
return
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault()
choose()
}
}}
className={cn(
"group/session flex cursor-pointer items-center gap-2.5 rounded-lg p-1.5 hover:bg-accent",
isCurrent && "bg-accent",
locked &&
"cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span className="sheet-light flex h-9 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border bg-white">
{session.thumbnailDataUrl && (
// biome-ignore lint/performance/noImgElement: data URL thumbnail
<img
src={
session.thumbnailDataUrl
}
alt=""
className="max-h-8 max-w-11 object-contain"
/>
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">
{shownTitle(session.title)}
</span>
<span className="block text-[11px] text-faint">
{formatRelativeTime(
session.updatedAt,
params.lang,
t.justNow,
)}
</span>
</span>
<button
type="button"
disabled={
isCurrent && engine.isBusy
}
onClick={(e) => {
e.stopPropagation()
setToDelete(session.id)
}}
aria-label={t.deleteTitle}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-faint [@media(hover:hover)]:opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover/session:opacity-100 focus-visible:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</div>
)
})
)}
</div>
</PopoverContent>
</Popover>
<AlertDialog
open={!!toDelete}
onOpenChange={(o) => !o && setToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{t.deleteDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.common.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => {
if (toDelete) engine.deleteSession(toDelete)
setToDelete(null)
}}
>
{dict.common.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+365
View File
@@ -0,0 +1,365 @@
import { getToolName } from "ai"
import {
AlertCircle,
Check,
ChevronDown,
Copy,
Library,
PenTool,
} from "lucide-react"
import { useState } from "react"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { VersionCard as VersionCardView } from "@/components/canvas/version-card"
import { CodeBlock } from "@/components/code-block"
import { useDictionary } from "@/hooks/use-dictionary"
import { useCopy } from "@/lib/clipboard"
import { cn, isMxCellXmlComplete } from "@/lib/utils"
import { type DiagramVersion, useVersionsStore } from "@/stores/versions-store"
import { useChatEngine } from "./chat-engine"
import type { DiagramOperation, ToolPartLike } from "./types"
export function ToolInputDetails({ part }: { part: ToolPartLike }) {
const { input, state } = part
if (!input || typeof input !== "object") return null
if (typeof input.xml === "string" && input.xml) {
// Plain text while streaming; highlighting re-runs on every chunk
return state === "input-streaming" || state === "input-available" ? (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground scrollbar-thin">
{input.xml}
</pre>
) : (
<CodeBlock code={input.xml} language="xml" />
)
}
if (Array.isArray(input.operations)) {
// Streamed or invalid input can hold anything: show only what React
// can render (an object in place of a string would crash the chat)
const shown = (input.operations as DiagramOperation[]).filter(
(op) =>
typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) =>
typeof value === "string" ? value : ""
return (
<div className="space-y-2">
{shown.map((op, index) => (
<div
key={`${op.operation}-${text(op.cell_id)}-${index}`}
className="overflow-hidden rounded-lg border border-border bg-card"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1 text-[11px]">
<span className="font-medium text-foreground">
{op.operation}
</span>
<span className="text-muted-foreground">
{text(op.cell_id)}
</span>
</div>
{text(op.new_xml) && (
<pre className="overflow-x-auto whitespace-pre-wrap break-all px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground">
{text(op.new_xml)}
</pre>
)}
</div>
))}
</div>
)
}
if (Object.keys(input).length === 0) return null
return <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
}
function toolText(part: ToolPartLike): string {
const { input } = part
if (!input || typeof input !== "object") return ""
if (typeof input.xml === "string" && input.xml) return input.xml
if (Array.isArray(input.operations)) {
return JSON.stringify(input.operations, null, 2)
}
return JSON.stringify(input, null, 2)
}
/**
* Card for one diagram version produced by a tool call: the shared card
* with a link to what the AI wrote (shown in a panel under the card)
*/
function VersionCard({
part,
version,
isLatest,
}: {
part: ToolPartLike
version: DiagramVersion
isLatest: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const [showCode, setShowCode] = useState(false)
const { copied, copy } = useCopy()
const isEdit = part.type === "tool-edit_diagram"
return (
<VersionCardView
version={version}
isLatest={isLatest}
label={isEdit ? t.edited : t.created}
data-tool-state={part.state}
// A text link, so the card's only button is the action (undo
// or restore); it wraps as one unit with its separator
afterSummary={
<span className="whitespace-nowrap">
<span aria-hidden> · </span>
<button
type="button"
onClick={() => setShowCode((v) => !v)}
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground hover:decoration-foreground"
>
{showCode ? t.hideCode : t.showCode}
</button>
</span>
}
>
{showCode && (
<div className="relative border-t border-border bg-surface-1 px-3 py-2.5">
<button
type="button"
onClick={() => copy(toolText(part))}
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
aria-label={dict.chat.copyResponse}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
<ToolInputDetails part={part} />
</div>
)}
</VersionCardView>
)
}
/** Compact row for tool calls that are running, failed or produced no version */
function ToolRow({
part,
icon,
label,
tone = "default",
detail,
}: {
part: ToolPartLike
icon: React.ReactNode
label: React.ReactNode
tone?: "default" | "error" | "warning"
detail?: string
}) {
const dict = useDictionary()
const [open, setOpen] = useState(false)
const { copied, copy } = useCopy()
const hasInput = !!part.input && Object.keys(part.input).length > 0
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card",
tone === "error" && "border-destructive/25",
)}
data-testid="tool-row"
data-tool-name={getToolName(part)}
data-tool-state={part.state}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={!hasInput && !detail}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left"
>
<span
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4",
tone === "error" &&
"bg-destructive/10 text-destructive",
tone === "warning" && "bg-marker-soft text-marker-ink",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{label}
</span>
{(hasInput || detail) && (
<ChevronDown
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
)}
</button>
{open && (
<div
className={cn(
"relative space-y-2 border-t border-border bg-surface-1 px-3 py-2.5",
hasInput && "pr-11",
)}
>
{hasInput && (
<button
type="button"
onClick={() => copy(toolText(part))}
className="absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg bg-card text-muted-foreground shadow-float hover:text-foreground"
aria-label={dict.chat.copyResponse}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</button>
)}
{detail && (
<p
className={cn(
"whitespace-pre-wrap break-words text-xs",
tone === "error"
? "text-destructive"
: "text-muted-foreground",
)}
>
{detail}
</p>
)}
{hasInput && <ToolInputDetails part={part} />}
</div>
)}
</div>
)
}
export function ToolActivity({
part,
isLastMessage,
}: {
part: ToolPartLike
isLastMessage: boolean
}) {
const dict = useDictionary()
const t = dict.versions
const engine = useChatEngine()
const versions = useVersionsStore((s) => s.versions)
const toolName = getToolName(part)
const { state, errorText, output } = part
const versionIndex = versions.findIndex(
(v) => v.toolCallId === part.toolCallId,
)
if (state === "output-available" && versionIndex >= 0) {
return (
<VersionCard
part={part}
version={versions[versionIndex]}
isLatest={versionIndex === versions.length - 1}
/>
)
}
// The answer ended before the call did (stopped, network error)
const running = isLastMessage && engine.isBusy
if (toolName === "get_shape_library" && state !== "output-error") {
const isDone = state === "output-available"
return (
<ToolRow
part={part}
icon={<Library />}
label={
isDone ? (
t.libraryLoaded
) : running ? (
<Shimmer as="span">{t.loadingLibrary}</Shimmer>
) : (
t.stopped
)
}
detail={
isDone && typeof output === "string"
? `${output.slice(0, 600)}${output.length > 600 ? "…" : ""}`
: undefined
}
/>
)
}
// Stop turns a call still streaming into "Stopped by user" (right
// away, or when the answer has ended for one not on screen yet): one
// left like this was cut off (a closed connection, an error, a closed
// tab)
if (state === "input-streaming" || state === "input-available") {
const failed = !running && !(isLastMessage && engine.stoppedRef.current)
return (
<ToolRow
part={part}
icon={running ? <PenTool /> : <AlertCircle />}
tone={failed ? "error" : "default"}
label={
running ? (
<Shimmer as="span">
{toolName === "edit_diagram"
? t.editing
: t.drawing}
</Shimmer>
) : failed ? (
t.failed
) : (
t.stopped
)
}
/>
)
}
if (state === "output-error") {
const stopped = errorText === "Stopped by user"
// Incomplete XML means the output hit the length limit. Without an
// input the JSON was broken (the server repairs JSON cut short by
// the limit).
const truncated =
!stopped &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof part.input?.xml === "string" &&
!isMxCellXmlComplete(part.input.xml)
return (
<ToolRow
part={part}
icon={<AlertCircle />}
tone={stopped ? "default" : truncated ? "warning" : "error"}
label={
stopped
? t.stopped
: truncated
? dict.tools.truncated
: isLastMessage && engine.isBusy
? t.failedRetrying
: t.failed
}
detail={
stopped
? undefined
: truncated
? t.truncatedHint
: (errorText ??
(typeof output === "string" ? output : ""))
}
/>
)
}
// Finished without a version (sessions saved before versions existed)
return (
<ToolRow
part={part}
icon={<PenTool />}
label={toolName === "edit_diagram" ? t.edited : t.created}
/>
)
}
+10 -6
View File
@@ -1,15 +1,19 @@
import type { DynamicToolUIPart, ToolUIPart, UITools } from "ai"
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts" import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation } export type { DiagramOperation }
export interface ToolPartLike { /**
type: string * A tool part as the UI reads it, in any state: input, output and errorText
toolCallId: string * are each there or not, instead of one shape per state.
state?: string */
input?: { export type ToolPartLike = (ToolUIPart<UITools> | DynamicToolUIPart) & {
input?:
| ({
xml?: string xml?: string
operations?: DiagramOperation[] operations?: DiagramOperation[]
} & Record<string, unknown> } & Record<string, unknown>)
| undefined
output?: string output?: string
errorText?: string errorText?: string
} }
+278
View File
@@ -0,0 +1,278 @@
"use client"
import type { UIMessage } from "ai"
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 {
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 {
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(
operations: DiagramOperation[] | undefined,
): DiagramOperation[] {
if (!operations || !Array.isArray(operations)) return []
return operations.filter(
(op) =>
op &&
typeof op.operation === "string" &&
["update", "add", "delete"].includes(op.operation) &&
typeof op.cell_id === "string" &&
op.cell_id.length > 0 &&
(op.operation === "delete" || typeof op.new_xml === "string"),
)
}
interface UseDiagramStreamingParams {
messages: UIMessage[]
processedToolCallsRef: RefObject<Set<string>>
editDiagramOriginalXmlRef: RefObject<Map<string, string>>
loadedMessageIdsRef: RefObject<Set<string>>
// The page the model draws on in this turn; null: the first page
turnPageIdRef?: RefObject<string | null>
}
/**
* Draws AI tool output onto the canvas while it streams, as a preview that
* is not recorded in undo history.
*
* display_diagram: the complete mxCells written so far. edit_diagram: the
* complete operations applied to the diagram from before the call. The tool
* handler validates and commits the final result. Runs in the chat engine
* so streaming continues while the chat panel is collapsed.
*/
export function useDiagramStreaming({
messages,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
turnPageIdRef,
}: UseDiagramStreamingParams) {
const { chartXML, chartXMLRef, loadDiagram } = useDiagram()
const previousXML = useRef<string>("")
// Last processed XML per toolCallId, to skip redundant work while streaming
const lastProcessedXmlRef = useRef<Map<string, string>>(new Map())
// Reset when messages become empty (new chat or session switch), so
// cached examples work again after starting a new session
useEffect(() => {
if (messages.length === 0) {
previousXML.current = ""
lastProcessedXmlRef.current.clear()
processedToolCallsRef.current.clear()
editDiagramOriginalXmlRef.current.clear()
}
}, [messages.length, processedToolCallsRef, editDiagramOriginalXmlRef])
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string) => {
// Named styles come before the cells, so the ones written so
// far are known by the time their cells stream in
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(
applyStyleClasses(
expandCompactCells(
autoFixXml(wrapCellsInModel(completeCells)).fixed,
),
classes,
),
)
if (model === previousXML.current) return
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(model, "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,
turnPageIdRef?.current,
model,
)
previousXML.current = model
loadDiagram(replacedXML, true, "preview")
} catch (error) {
console.error("Error processing XML:", error)
}
},
[chartXML, loadDiagram],
)
useEffect(() => {
// Only the last message can still be streaming
const message = messages[messages.length - 1]
if (!message?.parts) return
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
if (loadedMessageIdsRef.current.has(message.id)) return
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
for (const part of message.parts) {
if (
part.type !== "tool-display_diagram" &&
part.type !== "tool-edit_diagram"
) {
continue
}
const { toolCallId, state, input } = part as ToolPartLike
// Failed or stopped: if the original XML is still stored, the
// tool handler never ran (invalid JSON, or the user pressed
// stop), so undo the streamed preview here. Invalid JSON leaves
// no input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(toolCallId)
loadDiagram(originalXml, true, "revert")
baseXml = originalXml
}
continue
}
// Input complete, or the tool handler, a stop or an error took
// the call already: the tool handler loads the checked diagram.
// The messages update at most every 150 ms (useChat throttle in
// the chat engine), so they can still show the call streaming
// after that.
if (
state !== "input-streaming" ||
processedToolCallsRef.current.has(toolCallId)
) {
processedToolCallsRef.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(`${toolCallId}-result`)
lastProcessedXmlRef.current.delete(`${toolCallId}-ops`)
continue
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing
if (!xml || lastProcessedXmlRef.current.get(toolCallId) === xml)
continue
// Keep the diagram from before the preview, to undo it on a
// stop or an error
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml || BLANK_MXFILE,
)
}
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
continue
}
// edit_diagram: apply operations incrementally for a preview.
// editDiagramOriginalXmlRef is shared with the tool handler.
if (!input?.operations) continue
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) continue
// Capture the original XML when streaming starts
if (!editDiagramOriginalXmlRef.current.has(toolCallId)) {
if (!baseXml) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
continue
}
editDiagramOriginalXmlRef.current.set(toolCallId, baseXml)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(toolCallId)
if (!originalXml) continue
// The last operation's XML may still be streaming: it applies
// once it parses, so compare results, not the number of
// operations. Skip if nothing changed since the last preview.
const resultKey = `${toolCallId}-result`
const opsKey = `${toolCallId}-ops`
const ops = JSON.stringify(completeOps)
if (lastProcessedXmlRef.current.get(opsKey) === ops) continue
lastProcessedXmlRef.current.set(opsKey, ops)
try {
// The model's page: the edges on it and its first layer,
// as editDiagram reads them
const selector = pageSelectorFor(
originalXml,
turnPageIdRef?.current,
)
const page = targetPageXml(originalXml, selector)
const edges = edgeIdsOf(page)
const layer = defaultLayerOf(page)
const { result } = applyDiagramOperations(
originalXml,
completeOps.map((op) =>
op.new_xml
? {
...op,
new_xml: addDefaultStyles(
expandCompactCells(op.new_xml, layer),
edges,
),
}
: op,
),
selector,
)
if (lastProcessedXmlRef.current.get(resultKey) === result) {
continue
}
// Load the full document so other pages stay intact
loadDiagram(result, true, "preview")
lastProcessedXmlRef.current.set(resultKey, result)
} catch (e) {
console.warn(
"[edit_diagram streaming] Operation failed:",
e instanceof Error ? e.message : e,
)
}
}
}, [
messages,
handleDisplayChart,
chartXMLRef,
loadDiagram,
processedToolCallsRef,
editDiagramOriginalXmlRef,
loadedMessageIdsRef,
])
}
+181
View File
@@ -0,0 +1,181 @@
import type { RefObject } from "react"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { diffDiagrams, isSameDocument } from "@/lib/diagram-diff"
import {
clearHighlights,
highlightChangedCells,
} from "@/lib/drawio/editor-bridge"
import { formatMessage } from "@/lib/i18n/utils"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
import { useVersionsStore } from "@/stores/versions-store"
interface UseVersionsParams {
/** Index of the user message whose turn is running */
currentTurnRef: RefObject<number>
isBusy: boolean
/** Live busy state, for the restore toast's Undo that runs later */
isTurnActive: () => boolean
/** Changes when another chat comes on screen */
getChatGeneration: () => number
}
/**
* Turns every committed AI change into a version (card in the chat), marks
* the changed shapes on the canvas, and restores or undoes versions.
*/
export function useVersions({
currentTurnRef,
isBusy,
isTurnActive,
getChatGeneration,
}: UseVersionsParams) {
const dict = useDictionary()
// chartXML changes with hand edits, undo and loads; the ref is the
// document on the canvas now
const {
loadDiagram,
getVersionSvg,
setCommitHandler,
chartXML,
chartXMLRef,
} = useDiagram()
// A restore commits too; that path must not add a version
const restoringRef = useRef(false)
// Registered once: re-registering on every render would leave a gap
// (effect cleanups run before the streaming hook commits). The handler
// only reads refs and stable functions.
useEffect(() => {
setCommitHandler(({ beforeXml, afterXml, toolCallId, pageId }) => {
if (restoringRef.current) return
const { summary, touchedIds, fromScratch } = diffDiagrams(
beforeXml,
afterXml,
pageId,
)
const id = useVersionsStore.getState().addVersion({
xml: afterXml,
beforeXml,
...(pageId && { pageId }),
turnIndex: currentTurnRef.current,
toolCallId,
summary,
fromScratch,
})
// Mark what changed, unless the whole diagram is new
if (!fromScratch) highlightChangedCells(touchedIds)
// The picture is of the canvas: take it right away, before a
// newer version can be on it; once one is, this one gets none
// rather than the newer one's
const isNewest = () =>
useVersionsStore.getState().versions.at(-1)?.id === id
setTimeout(async () => {
const svg = isNewest() ? await getVersionSvg() : null
useVersionsStore
.getState()
.updateVersion(id, { svg: svg && isNewest() ? svg : "" })
}, 0)
})
return () => setCommitHandler(null)
}, [])
// A version is on the canvas (or its change undone) while the canvas
// holds that XML. Hand edits, Ctrl+Z or a new page end it, with an
// external draw.io too; Ctrl+Z or redo can also bring the latest version
// (or the diagram before it) back. Checked once an answer is done: its
// previews change the canvas on the way.
useEffect(() => {
if (isBusy || isTurnActive() || !chartXML) return
const store = useVersionsStore.getState()
const id = store.onCanvasVersionId ?? store.undoneVersionId
const version = store.versions.find((v) => v.id === id)
if (version) {
const shown =
id === store.onCanvasVersionId
? version.xml
: version.beforeXml || BLANK_MXFILE
if (isSameDocument(shown, chartXML)) return
// Its Undo would also take the changes made since away
toast.dismiss("version-restored")
}
const latest = store.versions.at(-1)
if (latest && isSameDocument(latest.xml, chartXML)) {
store.setOnCanvas(latest.id)
} else if (
latest?.beforeXml !== undefined &&
isSameDocument(latest.beforeXml || BLANK_MXFILE, chartXML)
) {
store.setUndone(latest.id)
} else {
store.clearCanvasFlags()
}
}, [chartXML, isBusy])
const apply = (xml: string) => {
restoringRef.current = true
try {
loadDiagram(xml, true, "commit")
} finally {
restoringRef.current = false
}
clearHighlights()
}
const restoreVersion = (versionId: string) => {
if (isTurnActive()) return
const store = useVersionsStore.getState()
const index = store.versions.findIndex((v) => v.id === versionId)
if (index < 0) return
// The canvas before the restore, to go back to it: a multi-page
// document is loaded in full, which Ctrl+Z cannot undo
const before = chartXMLRef.current
const wasOnCanvas = store.onCanvasVersionId
const wasUndone = store.undoneVersionId
const generation = getChatGeneration()
apply(store.versions[index].xml)
store.setOnCanvas(versionId)
toast.success(
formatMessage(dict.versions.restoredVersion, {
n: store.versions[index].number,
}),
{
id: "version-restored",
duration: 8000,
action: {
label: dict.versions.undoRestore,
onClick: () => {
// Another chat is on screen, or an answer is
// changing the canvas: the old canvas is not theirs
if (!before || isTurnActive()) return
if (getChatGeneration() !== generation) return
apply(before)
const now = useVersionsStore.getState()
if (wasUndone) now.setUndone(wasUndone)
else if (wasOnCanvas) now.setOnCanvas(wasOnCanvas)
},
},
},
)
}
/** Undo the change of the latest version, or redo it when undone */
const undoVersion = (versionId: string) => {
if (isTurnActive()) return
const store = useVersionsStore.getState()
const version = store.versions.find((v) => v.id === versionId)
if (!version) return
if (store.undoneVersionId === versionId) {
apply(version.xml)
store.setOnCanvas(versionId)
return
}
if (version.beforeXml === undefined) return
apply(version.beforeXml || BLANK_MXFILE)
store.setUndone(versionId)
}
return { restoreVersion, undoVersion }
}
+12 -25
View File
@@ -1,7 +1,6 @@
"use client" "use client"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts" import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
// Dev XML presets for streaming simulator // Dev XML presets for streaming simulator
@@ -135,15 +134,12 @@ const DEV_XML_PRESETS: Record<string, string> = {
interface DevXmlSimulatorProps { interface DevXmlSimulatorProps {
setMessages: React.Dispatch<React.SetStateAction<any[]>> setMessages: React.Dispatch<React.SetStateAction<any[]>>
onDisplayChart: (xml: string) => void onDisplayChart: (xml: string) => void
onShowQuotaToast?: () => void
} }
export function DevXmlSimulator({ export function DevXmlSimulator({
setMessages, setMessages,
onDisplayChart, onDisplayChart,
onShowQuotaToast,
}: DevXmlSimulatorProps) { }: DevXmlSimulatorProps) {
const dict = useDictionary()
const [devXml, setDevXml] = useState("") const [devXml, setDevXml] = useState("")
const [isSimulating, setIsSimulating] = useState(false) const [isSimulating, setIsSimulating] = useState(false)
const [devIntervalMs, setDevIntervalMs] = useState(1) const [devIntervalMs, setDevIntervalMs] = useState(1)
@@ -180,7 +176,7 @@ export function DevXmlSimulator({
parts: [ parts: [
{ {
type: "text" as const, type: "text" as const,
text: dict.dev.simulatingMessage, text: "[Dev] Simulating XML streaming",
}, },
], ],
} }
@@ -230,7 +226,7 @@ export function DevXmlSimulator({
const lastMsg = updated[updated.length - 1] as any const lastMsg = updated[updated.length - 1] as any
if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) { if (lastMsg?.role === "assistant" && lastMsg.parts?.[0]) {
lastMsg.parts[0].state = "output-available" lastMsg.parts[0].state = "output-available"
lastMsg.parts[0].output = dict.dev.successMessage lastMsg.parts[0].output = "Successfully displayed the diagram."
lastMsg.parts[0].input = { xml } lastMsg.parts[0].input = { xml }
} }
return updated return updated
@@ -247,12 +243,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"> <div className="border-t border-dashed border-orange-500/50 px-4 py-2 bg-orange-50/50 dark:bg-orange-950/30">
<details> <details>
<summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium"> <summary className="text-xs text-orange-600 dark:text-orange-400 cursor-pointer font-medium">
{dict.dev.title} Dev: XML Streaming Simulator
</summary> </summary>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.preset} Preset:
</label> </label>
<select <select
onChange={(e) => { onChange={(e) => {
@@ -264,7 +260,7 @@ export function DevXmlSimulator({
defaultValue="" defaultValue=""
> >
<option value="" disabled> <option value="" disabled>
{dict.dev.selectPreset} Select a preset...
</option> </option>
{Object.keys(DEV_XML_PRESETS).map((name) => ( {Object.keys(DEV_XML_PRESETS).map((name) => (
<option key={name} value={name}> <option key={name} value={name}>
@@ -277,19 +273,19 @@ export function DevXmlSimulator({
onClick={() => setDevXml("")} onClick={() => setDevXml("")}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded" className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground border rounded"
> >
{dict.dev.clear} Clear
</button> </button>
</div> </div>
<textarea <textarea
value={devXml} value={devXml}
onChange={(e) => setDevXml(e.target.value)} onChange={(e) => setDevXml(e.target.value)}
placeholder={dict.dev.placeholder} placeholder="Paste mxCell XML here or select a preset..."
className="w-full h-24 text-xs font-mono p-2 border rounded bg-background" 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-4">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.interval} Interval:
</label> </label>
<input <input
type="range" type="range"
@@ -308,7 +304,7 @@ export function DevXmlSimulator({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground whitespace-nowrap"> <label className="text-xs text-muted-foreground whitespace-nowrap">
{dict.dev.chars} Chars:
</label> </label>
<input <input
type="number" type="number"
@@ -332,8 +328,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" 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 {isSimulating
? dict.dev.streaming ? "Streaming..."
: `${dict.dev.simulate} (${devChunkSize} chars/${devIntervalMs}ms)`} : `Simulate (${devChunkSize} chars/${devIntervalMs}ms)`}
</button> </button>
{isSimulating && ( {isSimulating && (
<button <button
@@ -343,16 +339,7 @@ export function DevXmlSimulator({
}} }}
className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600" className="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
> >
{dict.dev.stop} 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> </button>
)} )}
</div> </div>
-44
View File
@@ -1,44 +0,0 @@
"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>
)
}
-114
View File
@@ -1,114 +0,0 @@
"use client"
import { useState } from "react"
import Image from "@/components/image-with-basepath"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
interface HistoryDialogProps {
showHistory: boolean
onToggleHistory: (show: boolean) => void
}
export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const dict = useDictionary()
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => {
setSelectedIndex(null)
onToggleHistory(false)
}
const handleConfirmRestore = () => {
if (selectedIndex !== null) {
// Skip validation for trusted history snapshots
onDisplayChart(diagramHistory[selectedIndex].xml, true)
handleClose()
}
}
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-thin">
<DialogHeader>
<DialogTitle>{dict.history.title}</DialogTitle>
<DialogDescription>
{dict.history.description}
</DialogDescription>
</DialogHeader>
{diagramHistory.length === 0 ? (
<div className="text-center p-4 text-gray-500">
{dict.history.noHistory}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 py-4">
{diagramHistory.map((item, index) => (
<div
key={index}
className={`border rounded-md p-2 cursor-pointer hover:border-primary transition-colors ${
selectedIndex === index
? "border-primary ring-2 ring-primary"
: ""
}`}
onClick={() => setSelectedIndex(index)}
>
<div className="aspect-video bg-white rounded overflow-hidden flex items-center justify-center">
<Image
src={item.svg}
alt={`${dict.history.version} ${index + 1}`}
width={200}
height={100}
className="object-contain w-full h-full p-1"
/>
</div>
<div className="text-xs text-center mt-1 text-gray-500">
{dict.history.version} {index + 1}
</div>
</div>
))}
</div>
)}
<DialogFooter>
{selectedIndex !== null ? (
<>
<div className="flex-1 text-sm text-muted-foreground">
{formatMessage(dict.history.restoreTo, {
version: selectedIndex + 1,
})}
</div>
<Button
variant="outline"
onClick={() => setSelectedIndex(null)}
>
{dict.common.cancel}
</Button>
<Button onClick={handleConfirmRestore}>
{dict.common.confirm}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
{dict.common.close}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+13
View File
@@ -0,0 +1,13 @@
// 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>
)
}
File diff suppressed because it is too large Load Diff
+337 -234
View File
@@ -1,36 +1,47 @@
"use client" "use client"
import { import {
AlertTriangle,
Bot, Bot,
Check, Check,
ChevronDown, ChevronDown,
Monitor, Monitor,
Plus,
Server, Server,
Settings2, Settings2,
User, User,
} from "lucide-react" } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { import {
ModelSelectorContent, type ComponentProps,
ModelSelectorEmpty, type ReactNode,
ModelSelectorGroup, useEffect,
ModelSelectorInput, useMemo,
ModelSelectorItem, useRef,
ModelSelectorList, useState,
ModelSelectorLogo, } from "react"
ModelSelectorName, import { ProviderLogo } from "@/components/provider-logo"
ModelSelector as ModelSelectorRoot, import { Button } from "@/components/ui/button"
ModelSelectorSectionHeader, import {
ModelSelectorSeparator, Command,
ModelSelectorTrigger, CommandEmpty,
} from "@/components/ai-elements/model-selector" CommandGroup,
import { ButtonWithTooltip } from "@/components/button-with-tooltip" 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 { useDictionary } from "@/hooks/use-dictionary"
import { import type { FlattenedModel, ProviderName } from "@/lib/types/model-config"
type FlattenedModel,
PROVIDER_LOGO_MAP,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface ModelSelectorProps { interface ModelSelectorProps {
@@ -38,60 +49,138 @@ interface ModelSelectorProps {
selectedModelId: string | undefined selectedModelId: string | undefined
onSelect: (modelId: string | undefined) => void onSelect: (modelId: string | undefined) => void
onConfigure?: () => void onConfigure?: () => void
/** Opens the settings on the list of providers to add */
onAddProvider?: () => void
disabled?: boolean disabled?: boolean
showUnvalidatedModels?: boolean
} }
type ProviderGroup = { provider: ProviderName; models: FlattenedModel[] }
// Group models by providerLabel (handles duplicate providers) // Group models by providerLabel (handles duplicate providers)
function groupModelsByProvider( function groupModelsByProvider(
models: FlattenedModel[], models: FlattenedModel[],
): Map<string, { provider: string; models: FlattenedModel[] }> { ): Map<string, ProviderGroup> {
const groups = new Map< const groups = new Map<string, ProviderGroup>()
string,
{ provider: string; models: FlattenedModel[] }
>()
for (const model of models) { for (const model of models) {
// For server models, strip "Server · " prefix for cleaner grouping const existing = groups.get(model.providerLabel)
const key =
model.source === "server"
? model.providerLabel.replace(/^Server · /, "")
: model.providerLabel
const existing = groups.get(key)
if (existing) { if (existing) {
existing.models.push(model) existing.models.push(model)
} else { } else {
groups.set(key, { provider: model.provider, models: [model] }) groups.set(model.providerLabel, {
provider: model.provider,
models: [model],
})
} }
} }
return groups return groups
} }
/**
* The model id without the provider or region prefix that the logo already
* shows: "nvidia/nemotron-3-ultra" and "global.anthropic.claude-opus-5-5"
* become "nemotron-3-ultra" and "claude-opus-5-5". The full id is in the
* tooltip and the list.
*/
export function shortModelName(id: string): string {
const name = id.slice(id.lastIndexOf("/") + 1)
const short = name.replace(
/^(?:(?:global|us|eu|apac|jp|au|ca|us-gov)\.)?[a-z][a-z0-9-]*\.(?=[a-z])/i,
"",
)
// Ids like "deepseek.r1-v1:0" name the vendor only in the prefix: drop
// just the region
if (!short || /^[a-z]\d/i.test(short)) {
return name.replace(/^(?:global|us|eu|apac|jp|au|ca|us-gov)\./i, "")
}
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({ export function ModelSelector({
models, models,
selectedModelId, selectedModelId,
onSelect, onSelect,
onConfigure, onConfigure,
onAddProvider,
disabled = false, disabled = false,
showUnvalidatedModels = false,
}: ModelSelectorProps) { }: ModelSelectorProps) {
const dict = useDictionary() const dict = useDictionary()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
// Filter models based on showUnvalidatedModels setting
const displayModels = useMemo(() => {
if (showUnvalidatedModels) {
return models
}
return models.filter((m) => m.validated === true)
}, [models, showUnvalidatedModels])
// Separate server and user models // Separate server and user models. Every user model is listed; one not
// tested yet, or that failed its test, says so.
const serverModels = useMemo( const serverModels = useMemo(
() => displayModels.filter((m) => m.source === "server"), () => models.filter((m) => m.source === "server"),
[displayModels], [models],
) )
const userModels = useMemo( const userModels = useMemo(
() => displayModels.filter((m) => m.source !== "server"), () => models.filter((m) => m.source !== "server"),
[displayModels], [models],
) )
// Group each category separately // Group each category separately
@@ -110,6 +199,17 @@ export function ModelSelector({
[models, selectedModelId], [models, selectedModelId],
) )
// Leaving for the settings: closing, the picker must not give the focus
// back to its button, behind the settings dialog
const toSettingsRef = useRef(false)
const goToSettings = (openSettings: () => void) => {
toSettingsRef.current = true
setOpen(false)
openSettings()
}
const footerButton =
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
const handleSelect = (value: string) => { const handleSelect = (value: string) => {
if (value === "__server_default__") { if (value === "__server_default__") {
onSelect(undefined) onSelect(undefined)
@@ -119,6 +219,52 @@ export function ModelSelector({
setOpen(false) 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 const tooltipContent = selectedModel
? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}` ? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}`
: `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}` : `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}`
@@ -158,66 +304,100 @@ export function ModelSelector({
}, []) }, [])
return ( return (
<div ref={wrapperRef} className="min-w-0 max-w-48"> <div ref={wrapperRef} className="min-w-0 max-w-44">
<ModelSelectorRoot open={open} onOpenChange={setOpen}> <Dialog
<ModelSelectorTrigger asChild> open={open}
<ButtonWithTooltip onOpenChange={(next) => {
tooltipContent={tooltipContent} 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" variant="ghost"
size="sm" size="sm"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent", "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", !showLabel && "px-1.5 justify-center",
)} )}
// accessibility: expose label to screen readers // accessibility: expose label to screen readers
aria-label={tooltipContent} aria-label={tooltipContent}
data-testid="model-selector"
> >
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> {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 */} {/* show/hide visible label based on measured width */}
{showLabel ? ( {showLabel ? (
<span className="min-w-0 truncate text-xs"> <span className="min-w-0 truncate text-xs">
{selectedModel {selectedModel
? selectedModel.modelId ? shortModelName(
: dict.modelConfig.default} selectedModel.modelId,
)
: dict.modelConfig.serverDefault}
</span> </span>
) : ( ) : (
// Keep an sr-only label for screen readers when hidden // Keep an sr-only label for screen readers when hidden
<span className="sr-only"> <span className="sr-only">
{selectedModel {selectedModel
? selectedModel.modelId ? selectedModel.modelId
: dict.modelConfig.default} : dict.modelConfig.serverDefault}
</span> </span>
)} )}
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
</ButtonWithTooltip> </Button>
</ModelSelectorTrigger> </DialogTrigger>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-wrap">
{tooltipContent}
</TooltipContent>
</Tooltip>
<ModelSelectorContent title={dict.modelConfig.selectModel}> <DialogContent
<ModelSelectorInput className="p-0"
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} placeholder={dict.modelConfig.searchModels}
/> />
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<ModelSelectorList className="overflow-y-auto scrollbar-thin"> <ModelList className="overflow-y-auto scrollbar-thin">
<ModelSelectorEmpty> <CommandEmpty>
{displayModels.length === 0 && {dict.modelConfig.noModelsFound}
models.length > 0 </CommandEmpty>
? dict.modelConfig.noVerifiedModels
: dict.modelConfig.noModelsFound}
</ModelSelectorEmpty>
{/* Server Default Option - only show when no server models are configured */} {/* Server Default Option - only show when no server models are configured */}
{serverModels.length === 0 && ( {serverModels.length === 0 && (
<ModelSelectorGroup <CommandGroup
heading={dict.modelConfig.default} heading={dict.modelConfig.default}
> >
<ModelSelectorItem <CommandItem
value="__server_default__" value="__server_default__"
onSelect={handleSelect} onSelect={handleSelect}
className={cn( className={cn(
"cursor-pointer", "cursor-pointer",
!selectedModelId && "bg-accent", !selectedModelId &&
"bg-accent",
)} )}
> >
<Check <Check
@@ -229,98 +409,43 @@ export function ModelSelector({
)} )}
/> />
<Server className="mr-2 h-4 w-4 text-muted-foreground" /> <Server className="mr-2 h-4 w-4 text-muted-foreground" />
<ModelSelectorName> <span className="flex-1 truncate text-left">
{dict.modelConfig.serverDefault} {
</ModelSelectorName> dict.modelConfig
</ModelSelectorItem> .serverDefault
</ModelSelectorGroup> }
</span>
</CommandItem>
</CommandGroup>
)} )}
{/* Server Models Section */} {/* Server Models Section */}
{serverModels.length > 0 && ( {serverModels.length > 0 && (
<> <>
<ModelSelectorSectionHeader <SectionHeader
icon={<Monitor />} icon={<Monitor />}
label={ label={
dict.modelConfig.serverModels dict.modelConfig
.serverModels
} }
/> />
{Array.from( {renderGroups(
groupedServerModels.entries(), groupedServerModels,
).map( "server",
([ (model) =>
providerLabel, model.isDefault && (
{
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 <span
title={ title={
dict dict.modelConfig
.modelConfig
.serverDefaultModel .serverDefaultModel
} }
className="ml-auto text-xs text-muted-foreground" className="ml-auto text-xs text-muted-foreground"
> >
{ {
dict dict.modelConfig
.modelConfig
.default .default
} }
</span> </span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
), ),
)} )}
</> </>
@@ -330,116 +455,94 @@ export function ModelSelector({
{userModels.length > 0 && ( {userModels.length > 0 && (
<> <>
{serverModels.length > 0 && ( {serverModels.length > 0 && (
<ModelSelectorSeparator /> <CommandSeparator />
)} )}
<ModelSelectorSectionHeader <SectionHeader
icon={<User />} icon={<User />}
label={dict.modelConfig.userModels} label={
/> dict.modelConfig.userModels
{Array.from(
groupedUserModels.entries(),
).map(
([
providerLabel,
{
provider,
models: providerModels,
},
]) => (
<ModelSelectorGroup
key={`user-${providerLabel}`}
heading={providerLabel}
className="[&>[cmdk-group-heading]]:pl-4"
>
{providerModels.map(
(model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() =>
handleSelect(
model.id,
)
} }
className="cursor-pointer"
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedModelId ===
model.id
? "opacity-100"
: "opacity-0",
)}
/> />
<ModelSelectorLogo {renderGroups(
provider={ groupedUserModels,
PROVIDER_LOGO_MAP[ "user",
provider (model) =>
] || model.validated !==
provider
}
className="mr-2"
/>
<ModelSelectorName>
{
model.modelId
}
</ModelSelectorName>
{model.validated !==
true && ( true && (
<span <span
title={ className={cn(
dict "ml-auto shrink-0 pl-2 text-xs",
.modelConfig model.validated ===
.unvalidatedModelWarning false
} ? "text-destructive"
: "text-muted-foreground",
)}
> >
<AlertTriangle className="ml-auto h-3 w-3 text-warning" /> {model.validated ===
false
? dict
.modelConfig
.modelFailed
: dict
.modelConfig
.modelUntested}
</span> </span>
)}
</ModelSelectorItem>
),
)}
</ModelSelectorGroup>
), ),
)} )}
</> </>
)} )}
</ModelSelectorList> </ModelList>
</div> </div>
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */} {/* Pinned footer: add a provider, configure models
<div className="relative z-10 shrink-0 border-t bg-background"> (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 && ( {onConfigure && (
<div className="px-3 py-2"> <button
<ModelSelectorItem type="button"
value="__configure_models__" onClick={() =>
onSelect={() => { goToSettings(onConfigure)
onConfigure() }
setOpen(false) className={footerButton}
}}
className="flex cursor-pointer items-center gap-2 rounded-sm"
> >
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" /> <Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<ModelSelectorName> <span className="truncate">
{dict.modelConfig.configureModels} {
</ModelSelectorName> dict.modelConfig
</ModelSelectorItem> .configureModels
}
</span>
</button>
)}
</div> </div>
)} )}
<div className="px-3 pb-2 text-xs text-muted-foreground">
{showUnvalidatedModels
? dict.modelConfig.allModelsShown
: dict.modelConfig.onlyVerifiedShown}
</div> </div>
</div> </Command>
</div> </DialogContent>
</ModelSelectorContent> </Dialog>
</ModelSelectorRoot>
</div> </div>
) )
} }
+360 -152
View File
@@ -1,7 +1,8 @@
"use client" "use client"
import { Key, Link2, Tag } from "lucide-react" import { ChevronRight, Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react" import { type ReactNode, useState } from "react"
import { useLocale } from "@/components/canvas/locale-context"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@@ -13,12 +14,9 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { import { PROVIDERS, type ProviderName } from "@/lib/providers"
chatRequestUrl, import { chatRequestUrl, normalizeBaseUrl } from "@/lib/types/model-config"
normalizeBaseUrl, import { cn } from "@/lib/utils"
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the // Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via // user dialog, write-only masked for the admin panel — supplied via
@@ -30,6 +28,9 @@ export type SecretField =
| "awsSecretAccessKey" | "awsSecretAccessKey"
| "vertexApiKey" | "vertexApiKey"
/** Bedrock signs in with an API key or with an access key pair */
export type BedrockAuth = "apiKey" | "accessKey"
// AWS regions offered for Bedrock (shared by both screens) // AWS regions offered for Bedrock (shared by both screens)
export const AWS_REGIONS: Array<[string, string]> = [ export const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"], ["us-east-1", "N. Virginia"],
@@ -60,9 +61,17 @@ interface ProviderCredentialsFieldsProps {
// UI (e.g. the user dialog's inline Test button + validation error); the // UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it. // shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the // The user's settings page: the secrets come first, the display name and
// EdgeOne test button, which aren't beside a credential input. // base URL fold under "More options", and Bedrock shows only the way of
footer?: ReactNode // signing in that is chosen. Switching it empties the other way's
// secrets (clearSecrets), so a request carries one kind of credential.
settingsLayout?: boolean
clearSecrets?: (fields: SecretField[]) => void
// Bedrock credentials filled in, to choose the way of signing in shown
bedrockFilled?: { apiKey: boolean; accessKey: boolean }
// Bedrock's optional session token input, for temporary access keys
// (the admin panel has none)
sessionTokenInput?: ReactNode
} }
// Display name + per-provider credential inputs, shared by the user // Display name + per-provider credential inputs, shared by the user
@@ -75,23 +84,45 @@ export function ProviderCredentialsFields({
disabled, disabled,
onChange, onChange,
renderSecret, renderSecret,
footer, settingsLayout,
clearSecrets,
bedrockFilled,
sessionTokenInput,
}: ProviderCredentialsFieldsProps) { }: ProviderCredentialsFieldsProps) {
const dict = useDictionary() const dict = useDictionary()
const info = PROVIDER_INFO[provider] const locale = useLocale()
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, { const info = PROVIDERS[provider]
// A key-only provider without a default URL (Azure, custom) needs one
const baseUrlLabel =
info.credential === "apiKey" && !info.defaultBaseUrl
? dict.modelConfig.baseUrlRequired
: formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1", example: info.defaultBaseUrl || "https://api.example.com/v1",
}) })
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// Open when it holds something the user set, or for a provider whose
// base URL usually needs changing or comes with a hint
const [optionsOpen, setOptionsOpen] = useState(
() =>
!!name ||
(!!baseUrl && baseUrl !== info.defaultBaseUrl) ||
!!info.optionsOpen,
)
// Bedrock: the way that is filled in (an API key is used first when both
// are, as the server does), else the one shown last, so emptying the
// fields to type new keys keeps them on screen
const filledAuth: BedrockAuth | null = bedrockFilled?.apiKey
? "apiKey"
: bedrockFilled?.accessKey
? "accessKey"
: null
const [chosenAuth, setChosenAuth] = useState<BedrockAuth>(
filledAuth ?? "apiKey",
)
if (filledAuth && filledAuth !== chosenAuth) setChosenAuth(filledAuth)
const bedrockAuth: BedrockAuth = filledAuth ?? chosenAuth
// EdgeOne needs no credentials — the caller supplies just a test button const nameField = (
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="provider-name" htmlFor="provider-name"
@@ -109,8 +140,309 @@ export function ProviderCredentialsFields({
className="h-9" className="h-9"
/> />
</div> </div>
)
const regionField = (
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue placeholder={dict.modelConfig.selectRegion} />
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
const vertexBaseUrlField = (
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) => onChange("baseUrl", e.target.value)}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
)
// API key, with a link to where the provider issues keys
const apiKeyField = (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{info.credential === "optionalKey" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
)
const baseUrlField = (
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) => onChange("baseUrl", e.target.value)}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(e.target.value)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl || dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{info.baseUrlHint && (
<p className="text-xs text-muted-foreground">
{info.baseUrlHint[locale]}
</p>
)}
</div>
)
{provider === "bedrock" ? ( // A provider without credentials (EdgeOne) has no fields
if (info.credential === "none") return null
if (settingsLayout) {
return (
<div className="space-y-4">
{info.credential === "aws" ? (
<>
{/* Buttons, not radios: arrow keys would empty
secrets without a deliberate press */}
<fieldset
aria-label={dict.modelConfig.awsCredentials}
className="inline-flex rounded-lg bg-muted p-0.5"
>
{(["apiKey", "accessKey"] as const).map((auth) => (
<button
key={auth}
type="button"
aria-pressed={bedrockAuth === auth}
disabled={disabled}
onClick={() => {
if (bedrockAuth === auth) return
setChosenAuth(auth)
clearSecrets?.(
auth === "apiKey"
? [
"awsAccessKeyId",
"awsSecretAccessKey",
]
: ["apiKey"],
)
}}
className={cn(
"inline-flex h-7 items-center rounded-md px-2.5 text-xs text-muted-foreground transition-colors",
bedrockAuth === auth &&
"bg-card font-medium text-foreground shadow-float",
)}
>
{auth === "apiKey"
? dict.modelConfig.bedrockAuthApiKey
: dict.modelConfig.bedrockAuthAccessKey}
</button>
))}
</fieldset>
{/* Both kinds kept (saved before the choice): the
access keys sit unused behind the API key */}
{bedrockFilled?.apiKey && bedrockFilled.accessKey && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.bedrockBothSaved}{" "}
<button
type="button"
disabled={disabled}
onClick={() => {
clearSecrets?.([
"awsAccessKeyId",
"awsSecretAccessKey",
])
// This note goes away with the keys
document
.getElementById("bedrock-api-key")
?.focus()
}}
className="font-medium text-foreground underline underline-offset-2"
>
{dict.modelConfig.removeAccessKeys}
</button>
</p>
)}
{bedrockAuth === "apiKey" ? (
<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",
})}
</div>
) : (
<>
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{sessionTokenInput && (
<div className="space-y-2">
<Label
htmlFor="aws-session-token"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSessionToken}
</Label>
{sessionTokenInput}
<p className="text-xs text-muted-foreground">
{
dict.modelConfig
.awsSessionTokenHint
}
</p>
</div>
)}
</>
)}
{regionField}
</>
) : info.credential === "vertexKey" ? (
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
) : (
apiKeyField
)}
<div className="space-y-4">
<button
type="button"
onClick={() => setOptionsOpen(!optionsOpen)}
aria-expanded={optionsOpen}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform",
optionsOpen && "rotate-90",
)}
/>
{info.credential === "aws"
? dict.modelConfig.moreOptionsName
: dict.modelConfig.moreOptions}
</button>
{optionsOpen && (
<>
{info.credential !== "aws" &&
(info.credential === "vertexKey"
? vertexBaseUrlField
: baseUrlField)}
{nameField}
</>
)}
</div>
</div>
)
}
return (
<div className="space-y-5">
{nameField}
{info.credential === "aws" ? (
<> <>
{/* AWS Access Key ID */} {/* AWS Access Key ID */}
<div className="space-y-2"> <div className="space-y-2">
@@ -142,39 +474,9 @@ export function ProviderCredentialsFields({
})} })}
</div> </div>
{/* AWS Region */} {regionField}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</> </>
) : provider === "vertexai" ? ( ) : info.credential === "vertexKey" ? (
<> <>
{/* Vertex AI API Key (Express Mode) */} {/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2"> <div className="space-y-2">
@@ -191,108 +493,14 @@ export function ProviderCredentialsFields({
})} })}
</div> </div>
{/* Base URL (optional) */} {vertexBaseUrlField}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
</> </>
) : ( ) : (
<> <>
{/* API Key, with a link to where the provider issues keys */} {apiKeyField}
<div className="space-y-2"> {baseUrlField}
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(
e.target.value,
)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
</> </>
)} )}
{footer}
</div> </div>
) )
} }
+9 -13
View File
@@ -1,8 +1,10 @@
import { Cloud, Server, Sparkles } from "lucide-react" import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config" import { PROVIDERS, type ProviderName } from "@/lib/providers"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers const ICONS = { cloud: Cloud, server: Server, sparkles: Sparkles }
// Provider logo from models.dev, or the record's Lucide icon for providers
// that have no logo there // that have no logo there
export function ProviderLogo({ export function ProviderLogo({
provider, provider,
@@ -11,17 +13,11 @@ export function ProviderLogo({
provider: ProviderName provider: ProviderName
className?: string className?: string
}) { }) {
if (provider === "bedrock") { const { icon, logo } = PROVIDERS[provider]
return <Cloud className={cn("size-4", className)} /> if (icon) {
const Icon = ICONS[icon]
return <Icon className={cn("size-4", className)} />
} }
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return ( return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev // biome-ignore lint/performance/noImgElement: External URL from models.dev
<img <img
@@ -29,7 +25,7 @@ export function ProviderLogo({
aria-hidden="true" aria-hidden="true"
className={cn("size-4 dark:invert", className)} className={cn("size-4 dark:invert", className)}
height={16} height={16}
src={`https://models.dev/logos/${logoName}.svg`} src={`https://models.dev/logos/${logo ?? provider}.svg`}
width={16} width={16}
/> />
) )
+5 -23
View File
@@ -2,7 +2,7 @@
import { Coffee, Settings, X } from "lucide-react" import { Coffee, Settings, X } from "lucide-react"
import type React from "react" import type React from "react"
import { FaGithub } from "react-icons/fa" import { GitHubIcon } from "@/components/icons/github"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
@@ -28,16 +28,10 @@ export function QuotaLimitToast({
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString() n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n.toString()
const quotaMessage = isTokenLimit const quotaMessage = isTokenLimit
? isSelfHosted ? dict.quota.messageToken
? (dict.quota.messageTokenSelfHosted ?? dict.quota.messageToken)
: dict.quota.messageToken
: isSelfHosted
? (dict.quota.messageApiSelfHosted ?? dict.quota.messageApi)
: dict.quota.messageApi : dict.quota.messageApi
const tipHtml = isSelfHosted const tipHtml = isSelfHosted ? dict.quota.tipSelfHosted : dict.quota.tip
? (dict.quota.tipSelfHosted ?? dict.quota.tip)
: dict.quota.tip
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
@@ -52,7 +46,7 @@ export function QuotaLimitToast({
aria-live="polite" aria-live="polite"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-soft animate-message-in" className="relative w-[400px] overflow-hidden rounded-xl border border-border/50 bg-card p-5 shadow-float animate-message-in"
> >
{/* Close button */} {/* Close button */}
<button <button
@@ -85,18 +79,6 @@ export function QuotaLimitToast({
{/* Message */} {/* Message */}
<div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2"> <div className="text-sm text-muted-foreground leading-relaxed mb-4 space-y-2">
<p>{quotaMessage}</p> <p>{quotaMessage}</p>
{!isSelfHosted && (
<p
dangerouslySetInnerHTML={{
__html: formatMessage(
dict.quota.doubaoSponsorship,
{
link: "https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio",
},
),
}}
/>
)}
<p <p
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: tipHtml, __html: tipHtml,
@@ -127,7 +109,7 @@ export function QuotaLimitToast({
rel="noopener noreferrer" 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" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-foreground hover:bg-muted transition-colors"
> >
<FaGithub className="w-3.5 h-3.5" /> <GitHubIcon className="w-3.5 h-3.5" />
{dict.quota.selfHost} {dict.quota.selfHost}
</a> </a>
<a <a
-50
View File
@@ -1,50 +0,0 @@
"use client"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useDictionary } from "@/hooks/use-dictionary"
interface ResetWarningModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onClear: () => void
}
export function ResetWarningModal({
open,
onOpenChange,
onClear,
}: ResetWarningModalProps) {
const dict = useDictionary()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dict.dialogs.clearTitle}</DialogTitle>
<DialogDescription>
{dict.dialogs.clearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{dict.common.cancel}
</Button>
<Button variant="destructive" onClick={onClear}>
{dict.dialogs.clearEverything}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+48 -35
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -10,15 +10,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { cn } from "@/lib/utils"
export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg" export type ExportFormat = "drawio" | "png" | "svg" | "xmlsvg"
@@ -39,10 +32,12 @@ export function SaveDialog({
const [filename, setFilename] = useState(defaultFilename) const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio") const [format, setFormat] = useState<ExportFormat>("drawio")
// The default name each time the dialog opens; a chat title that comes
// while it is open (the chat is saved meanwhile) keeps what was typed
const wasOpenRef = useRef(false)
useEffect(() => { useEffect(() => {
if (open) { if (open && !wasOpenRef.current) setFilename(defaultFilename)
setFilename(defaultFilename) wasOpenRef.current = open
}
}, [open, defaultFilename]) }, [open, defaultFilename])
const handleSave = () => { const handleSave = () => {
@@ -52,6 +47,8 @@ export function SaveDialog({
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that picks an IME candidate (Safari: keyCode 229)
if (e.nativeEvent.isComposing || e.keyCode === 229) return
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault() e.preventDefault()
handleSave() handleSave()
@@ -62,21 +59,25 @@ export function SaveDialog({
{ {
value: "drawio" as const, value: "drawio" as const,
label: dict.save.formats.drawio, label: dict.save.formats.drawio,
hint: dict.save.hints.drawio,
extension: ".drawio", extension: ".drawio",
}, },
{ {
value: "png" as const, value: "png" as const,
label: dict.save.formats.png, label: dict.save.formats.png,
hint: dict.save.hints.png,
extension: ".png", extension: ".png",
}, },
{ {
value: "svg" as const, value: "svg" as const,
label: dict.save.formats.svg, label: dict.save.formats.svg,
hint: dict.save.hints.svg,
extension: ".svg", extension: ".svg",
}, },
{ {
value: "xmlsvg" as const, value: "xmlsvg" as const,
label: dict.save.formats.xmlsvg, label: dict.save.formats.xmlsvg,
hint: dict.save.hints.xmlsvg,
extension: ".drawio.svg", extension: ".drawio.svg",
}, },
] ]
@@ -85,7 +86,7 @@ export function SaveDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{dict.save.title}</DialogTitle> <DialogTitle>{dict.save.title}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -93,44 +94,54 @@ export function SaveDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div
<label className="text-sm font-medium"> role="radiogroup"
{dict.save.format} aria-label={dict.save.format}
</label> className="grid grid-cols-2 gap-2"
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
> >
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => ( {FORMAT_OPTIONS.map((opt) => (
<SelectItem // biome-ignore lint/a11y/useSemanticElements: styled radio tile
<button
key={opt.value} key={opt.value}
value={opt.value} type="button"
role="radio"
aria-checked={format === opt.value}
onClick={() => setFormat(opt.value)}
className={cn(
"rounded-lg border border-border p-3 text-left transition-colors hover:border-foreground/30",
format === opt.value &&
"border-foreground bg-accent",
)}
> >
<span className="block text-[13px] font-medium text-foreground">
{opt.label} {opt.label}
</SelectItem> </span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{opt.hint}
</span>
</button>
))} ))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium"> <label
htmlFor="export-filename"
className="text-sm font-medium"
>
{dict.save.filename} {dict.save.filename}
</label> </label>
<div className="flex items-stretch"> {/* One field: the name, then its extension */}
<Input <div className="flex h-9 items-center overflow-hidden rounded-md border border-input focus-within:ring-2 focus-within:ring-ring/40">
<input
id="export-filename"
value={filename} value={filename}
onChange={(e) => setFilename(e.target.value)} onChange={(e) => setFilename(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={dict.save.filenamePlaceholder} placeholder={dict.save.filenamePlaceholder}
autoFocus autoFocus
onFocus={(e) => e.target.select()} onFocus={(e) => e.target.select()}
className="rounded-r-none border-r-0 focus-visible:z-10" className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
/> />
<span className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-sm text-muted-foreground font-mono"> <span className="shrink-0 pr-3 text-sm text-muted-foreground">
{currentFormat?.extension || ".drawio"} {currentFormat?.extension || ".drawio"}
</span> </span>
</div> </div>
@@ -143,7 +154,9 @@ export function SaveDialog({
> >
{dict.common.cancel} {dict.common.cancel}
</Button> </Button>
<Button onClick={handleSave}>{dict.common.save}</Button> <Button onClick={handleSave}>
{dict.workspace.export}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
import { ChevronRight, Search } from "lucide-react"
import { useState } from "react"
import { useLocale } from "@/components/canvas/locale-context"
import { ProviderLogo } from "@/components/provider-logo"
import { SettingsHeader } from "@/components/settings/settings-header"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { filterProviderGroups } from "@/lib/provider-setup"
import { PROVIDERS, type ProviderName } from "@/lib/providers"
/** Every provider in groups, with a search; picking one adds it */
export function ProviderPicker({
added,
onPick,
onBack,
}: {
/** Provider types the user has already added */
added: ProviderName[]
onPick: (provider: ProviderName) => void
onBack: () => void
}) {
const dict = useDictionary()
const t = dict.modelConfig
const locale = useLocale()
const [query, setQuery] = useState("")
const groups = filterProviderGroups(query)
const groupLabels: Record<string, string> = {
vendors: t.groupVendors,
aggregators: t.groupAggregators,
cloud: t.groupCloud,
selfHosted: t.groupSelfHosted,
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<SettingsHeader>
<button
type="button"
onClick={onBack}
className="rounded text-muted-foreground hover:text-foreground"
>
{t.models}
</button>
<ChevronRight className="size-3.5 text-muted-foreground/60" />
<h2
className="font-semibold outline-none"
tabIndex={-1}
data-page-title
>
{t.addProvider}
</h2>
</SettingsHeader>
<div className="px-6 pb-3">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<input
// The page is opened to search it
autoFocus
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
// Enter that confirms an IME candidate picks nothing
if (
e.nativeEvent.isComposing ||
e.keyCode === 229
) {
return
}
// Enter picks the first match
const first = groups[0]?.providers[0]
if (e.key === "Enter" && query.trim() && first) {
e.preventDefault()
onPick(first)
}
}}
placeholder={t.searchProviders}
aria-label={t.searchProviders}
className="h-10 w-full rounded-xl border border-border bg-background pr-3 pl-9 text-[13px] outline-none placeholder:text-muted-foreground focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/20"
/>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="px-6 pb-6">
{groups.length === 0 && (
<p className="py-8 text-center text-[13px] text-muted-foreground">
{t.noProvidersFound}
</p>
)}
{groups.map((group) => (
<section key={group.id} className="mb-3.5">
<h3 className="mb-1.5 text-[11px] font-medium text-muted-foreground">
{groupLabels[group.id]}
</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-2">
{group.providers.map((provider) => {
const note = [
PROVIDERS[provider].note?.[locale],
added.includes(provider) && t.added,
]
.filter(Boolean)
.join(" · ")
return (
<button
key={provider}
type="button"
onClick={() => onPick(provider)}
data-testid={`pick-provider-${provider}`}
className="flex h-[46px] min-w-0 items-center gap-2.5 rounded-xl border border-border px-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ProviderLogo
provider={provider}
className="shrink-0"
/>
<span className="min-w-0 leading-tight">
<span
className="block truncate text-[12.5px] font-medium"
title={
PROVIDERS[provider]
.label
}
>
{PROVIDERS[provider].label}
</span>
{note && (
<span className="block truncate text-[11px] text-muted-foreground">
{note}
</span>
)}
</span>
</button>
)
})}
</div>
</section>
))}
</div>
</ScrollArea>
</div>
)
}
+270
View File
@@ -0,0 +1,270 @@
import {
AlertCircle,
CheckCircle2,
ChevronRight,
CircleDashed,
CircleDotDashed,
Lock,
Plus,
Server,
} from "lucide-react"
import { shortModelName } from "@/components/model-selector"
import { ProviderLogo } from "@/components/provider-logo"
import { SettingsHeader } from "@/components/settings/settings-header"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { POPULAR_PROVIDERS, providerStatus } from "@/lib/provider-setup"
import { PROVIDERS } from "@/lib/providers"
import type {
FlattenedModel,
ProviderConfig,
ProviderName,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
function InUse() {
const dict = useDictionary()
return (
<span className="rounded bg-primary px-1.5 py-0.5 text-[11px] font-medium text-primary-foreground">
{dict.modelConfig.inUse}
</span>
)
}
/** The models tab's first page: the user's providers and the server's models */
export function ProvidersList({
providers,
serverModels,
selectedModelId,
onOpen,
onAdd,
onBrowse,
}: {
providers: ProviderConfig[]
serverModels: FlattenedModel[]
selectedModelId: string | undefined
onOpen: (providerId: string) => void
onAdd: (provider: ProviderName) => void
/** Shows every provider to pick from */
onBrowse: () => void
}) {
const dict = useDictionary()
const t = dict.modelConfig
const userProviderInUse = providers.find((p) =>
p.models.some((m) => m.id === selectedModelId),
)
// No model chosen is the server's default model
const serverInUse =
!selectedModelId || serverModels.some((m) => m.id === selectedModelId)
const names = serverModels
.slice(0, 3)
.map((m) => shortModelName(m.modelId))
.join(t.listSeparator)
const serverCard = (serverModels.length > 0 || serverInUse) && (
<div className="flex items-center gap-3 rounded-xl border border-border-subtle bg-surface-1 px-4 py-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border-subtle bg-surface-0">
<Server className="size-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[13px] font-medium">
{t.serverModelsTitle}
{serverInUse && <InUse />}
</div>
<p className="truncate text-xs text-muted-foreground">
{serverModels.length > 0
? formatMessage(
serverModels.length === 1
? t.serverModelsSummaryOne
: t.serverModelsSummaryOther,
{
count: serverModels.length,
names:
serverModels.length > 3
? `${names}…`
: names,
},
)
: t.serverDefaultSummary}
</p>
</div>
</div>
)
const statusText = (p: ProviderConfig) => {
const status = providerStatus(p)
switch (status.kind) {
case "error":
return (
<span className="flex items-center gap-1 text-destructive">
<AlertCircle className="size-3.5" />
{formatMessage(t.statusError, { count: status.count })}
</span>
)
case "incomplete":
return (
<span className="flex items-center gap-1 text-amber-700 dark:text-amber-400">
<CircleDotDashed className="size-3.5" />
{t.statusIncomplete}
</span>
)
case "untested":
return (
<span className="flex items-center gap-1 text-muted-foreground">
<CircleDashed className="size-3.5" />
{formatMessage(t.statusUntested, {
count: status.count,
})}
</span>
)
case "ok":
return (
<span className="flex items-center gap-1 text-success">
<CheckCircle2 className="size-3.5" />
{formatMessage(t.statusOk, { count: status.count })}
</span>
)
}
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<SettingsHeader>
<h2
className="font-semibold outline-none"
tabIndex={-1}
data-page-title
>
{t.models}
</h2>
</SettingsHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-6 px-6 pb-6">
{providers.length === 0 ? (
<>
{serverCard}
<section className="space-y-3">
<div>
<h3 className="text-[13px] font-semibold">
{t.useOwnKey}
</h3>
<p className="text-xs text-muted-foreground">
{t.useOwnKeyHint}
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-2.5">
{POPULAR_PROVIDERS.map((provider) => (
<button
key={provider}
type="button"
onClick={() => onAdd(provider)}
data-testid={`pick-provider-${provider}`}
className="flex h-14 items-center gap-3 rounded-xl border border-border px-4 text-left text-[13px] font-medium transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
<ProviderLogo
provider={provider}
/>
</span>
<span className="truncate">
{PROVIDERS[provider].label}
</span>
</button>
))}
</div>
<button
type="button"
onClick={onBrowse}
className="flex items-center gap-1 text-[13px] font-medium hover:underline"
>
{formatMessage(t.viewAllProviders, {
count: Object.keys(PROVIDERS).length,
})}
<ChevronRight className="size-3.5" />
</button>
</section>
</>
) : (
<>
<section className="space-y-2.5">
<div className="flex items-center justify-between">
<h3 className="text-[13px] font-semibold">
{t.myProviders}
</h3>
<Button
variant="outline"
size="sm"
onClick={onBrowse}
className="h-8 gap-1.5 rounded-lg text-xs"
>
<Plus className="size-3.5" />
{t.addProvider}
</Button>
</div>
<div className="divide-y divide-border-subtle rounded-xl border border-border">
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onOpen(p.id)}
data-testid={`provider-row-${p.id}`}
className={cn(
"flex min-h-[60px] w-full items-center gap-3 px-4 py-2.5 text-left transition-colors first:rounded-t-xl last:rounded-b-xl",
"hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
<ProviderLogo
provider={p.provider}
/>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-[13px] font-medium">
<span className="truncate">
{p.name ||
PROVIDERS[
p.provider
].label}
</span>
{userProviderInUse?.id ===
p.id && <InUse />}
</span>
<span className="block truncate font-mono text-xs text-muted-foreground">
{p.models.length > 0 ? (
p.models
.map(
(m) =>
m.modelId,
)
.join(
t.listSeparator,
)
) : (
<span className="font-sans">
{t.noModelsYet}
</span>
)}
</span>
</span>
<span className="shrink-0 text-xs">
{statusText(p)}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
</button>
))}
</div>
</section>
{serverCard}
</>
)}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="size-3.5 shrink-0" />
{t.keysStayLocal}
</p>
</div>
</ScrollArea>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
/** A settings page's title row; the dialog's close button sits at its end */
export function SettingsHeader({ children }: { children: React.ReactNode }) {
return (
<header className="flex h-14 shrink-0 items-center gap-1.5 pr-14 pl-6 text-[15px]">
{children}
</header>
)
}
-11
View File
@@ -12,14 +12,6 @@ function AlertDialog({
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> 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({ function AlertDialogPortal({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
@@ -144,9 +136,6 @@ function AlertDialogCancel({
export { export {
AlertDialog, AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent, AlertDialogContent,
AlertDialogHeader, AlertDialogHeader,
AlertDialogFooter, AlertDialogFooter,
-51
View File
@@ -5,13 +5,6 @@ import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({ function Command({
className, className,
@@ -29,32 +22,6 @@ 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({ function CommandInput({
className, className,
...props ...props
@@ -164,30 +131,12 @@ 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 { export {
Command, Command,
CommandDialog,
CommandInput, CommandInput,
CommandList, CommandList,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandItem, CommandItem,
CommandShortcut,
CommandSeparator, CommandSeparator,
} }
+1 -10
View File
@@ -24,12 +24,6 @@ function DialogPortal({
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> 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({ function DialogOverlay({
className, className,
...props ...props
@@ -65,7 +59,7 @@ function DialogContent({
"max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]", "max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
"grid gap-4 p-6", "grid gap-4 p-6",
// Refined visual treatment // Refined visual treatment
"bg-surface-0 rounded-2xl border border-border-subtle shadow-dialog", "bg-surface-0 rounded-2xl border border-border-subtle shadow-pop",
// Entry/exit animations // Entry/exit animations
"data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
@@ -151,13 +145,10 @@ function DialogDescription({
export { export {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} }
+1 -7
View File
@@ -39,10 +39,4 @@ function PopoverContent({
) )
} }
function PopoverAnchor({ export { Popover, PopoverTrigger, PopoverContent }
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
-56
View File
@@ -1,56 +0,0 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+1 -1
View File
@@ -55,4 +55,4 @@ function ScrollBar({
) )
} }
export { ScrollArea, ScrollBar } export { ScrollArea }
-37
View File
@@ -12,12 +12,6 @@ function Select({
return <SelectPrimitive.Root data-slot="select" {...props} /> 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({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
@@ -87,19 +81,6 @@ 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({ function SelectItem({
className, className,
children, children,
@@ -124,19 +105,6 @@ 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({ function SelectScrollUpButton({
className, className,
...props ...props
@@ -176,12 +144,7 @@ function SelectScrollDownButton({
export { export {
Select, Select,
SelectContent, SelectContent,
SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import { Toaster } from "sonner"
import { useSettingsStore } from "@/stores/settings-store"
/** The single toast container, mounted once at the root */
export function AppToaster() {
const isDark = useSettingsStore((s) => s.isDark)
return (
<Toaster
position="bottom-center"
// Show stacked toasts in full, so a later one never covers the
// button of an earlier one (e.g. "Continue without saving")
expand
theme={isDark ? "dark" : "light"}
offset={{ bottom: 20 }}
mobileOffset={{ bottom: 72 }}
toastOptions={{
duration: 2500,
style: { maxWidth: "480px" },
classNames: {
toast: "!rounded-xl !border-border !bg-card !text-foreground !shadow-pop",
description: "!text-muted-foreground",
},
}}
/>
)
}
+23
View File
@@ -0,0 +1,23 @@
"use client"
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
/** The app icon in a rounded tile; the icon has its own white tile, so dark mode uses it too */
export function BrandMark({ className }: { className?: string }) {
return (
<span
className={cn(
"relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-card",
className,
)}
>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-192x192.png")}
alt=""
className="size-full object-contain"
/>
</span>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import type React from "react"
import { forwardRef } from "react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface IconButtonProps extends React.ComponentProps<"button"> {
label: string
/** Shown next to the label in the tooltip, e.g. "⌘B" */
shortcut?: string
tooltipSide?: "top" | "bottom" | "left" | "right"
}
/** Square icon button with a tooltip; the tooltip text is also its label */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton(
{
label,
shortcut,
tooltipSide = "bottom",
className,
children,
...props
},
ref,
) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
type="button"
aria-label={label}
className={cn(
"inline-flex size-8 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]",
className,
)}
{...props}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent
side={tooltipSide}
className="flex items-center gap-2"
>
{label}
{shortcut && (
<kbd className="font-sans text-[11px] opacity-60">
{shortcut}
</kbd>
)}
</TooltipContent>
</Tooltip>
)
},
)
+413
View File
@@ -0,0 +1,413 @@
"use client"
import { MessageSquare, PanelRightOpen, Settings, Shapes } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { CanvasStage } from "@/components/canvas/canvas-stage"
import { CompareDialog } from "@/components/canvas/compare-dialog"
import { SelectionAsk } from "@/components/canvas/selection-ask"
import {
ChatEngineProvider,
useChatEngine,
} from "@/components/chat/chat-engine"
import { ChatPanel } from "@/components/chat/chat-panel"
import { ChatVersionsProvider } from "@/components/chat/chat-versions"
import { LobbyHero } from "@/components/chat/lobby-hero"
import { SaveDialog } from "@/components/save-dialog"
import { SettingsDialog } from "@/components/settings-dialog"
import { BrandMark } from "@/components/workspace/brand-mark"
import { IconButton } from "@/components/workspace/icon-button"
import { useDiagram } from "@/contexts/diagram-context"
import { useDictionary } from "@/hooks/use-dictionary"
import { usePanelWidth } from "@/hooks/use-panel-width"
import { setAppShortcutHandler } from "@/lib/drawio/editor-bridge"
import { i18n, type Locale } from "@/lib/i18n/config"
import { modKey } from "@/lib/platform"
import { STORAGE_KEYS } from "@/lib/storage"
import { cn, isRealDiagram } from "@/lib/utils"
import { useSettingsStore } from "@/stores/settings-store"
import { useUiStore } from "@/stores/ui-store"
import { useVersionsStore } from "@/stores/versions-store"
/** File types the "Open .drawio file" picker accepts */
const DIAGRAM_FILE_ACCEPT = ".drawio,.xml,.svg,application/xml,image/svg+xml"
const MOBILE_BREAKPOINT = 768
// Gap between the floating panel and the window edge
const PANEL_GAP = 12
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const query = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
)
const update = () => setIsMobile(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
return isMobile
}
/** Restore the language picked last time (the URL may say otherwise) */
function useSavedLocale() {
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEYS.locale)
if (!saved || !i18n.locales.includes(saved as Locale)) return
const parts = pathname.split("/").filter(Boolean)
if (parts[0] === saved) return
parts[0] = saved
const { search, hash } = window.location
router.replace(`/${parts.join("/")}${search}${hash}`)
}, [])
}
/** The diagram's title as a file name, or diagram-YYYY-MM-DD without one */
export function exportFilename(title: string | null): string {
const name = (title && title !== "New Chat" ? title : "")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80)
// Local date (toISOString would give UTC)
return name || `diagram-${new Date().toLocaleDateString("sv-SE")}`
}
/** App name and settings button above the start screen */
function StartBar({
className,
nameClassName,
}: {
className: string
nameClassName: string
}) {
const dict = useDictionary()
const openSettings = useUiStore((s) => s.openSettings)
return (
<div className={className}>
<BrandMark className="size-6" />
<span
className={cn(
"text-[13px] font-semibold tracking-[-0.01em]",
nameClassName,
)}
>
Next AI Draw.io
</span>
<IconButton
label={dict.nav.settings}
onClick={() => openSettings()}
data-testid="settings-button"
>
<Settings />
</IconButton>
</div>
)
}
/** The whole editor: canvas, chat panel, dialogs */
export function Workspace() {
const hydrate = useSettingsStore((s) => s.hydrate)
useEffect(() => {
hydrate()
}, [hydrate])
useSavedLocale()
return (
<ChatEngineProvider>
<ChatVersionsProvider>
<WorkspaceLayout />
</ChatVersionsProvider>
</ChatEngineProvider>
)
}
function WorkspaceLayout() {
const dict = useDictionary()
const engine = useChatEngine()
const { chartXML, saveDiagramToFile } = useDiagram()
const isMobile = useIsMobile()
const panelWidth = usePanelWidth()
const {
panelOpen,
togglePanel,
setPanelOpen,
mobileView,
setMobileView,
focusComposer,
saveDialogOpen,
setSaveDialogOpen,
} = useUiStore()
const heroDismissed = useUiStore((s) => s.heroDismissed)
const setHeroDismissed = useUiStore((s) => s.setHeroDismissed)
const fileInputRef = useRef<HTMLInputElement>(null)
// Start screen: nothing said and nothing drawn yet. A new chat brings
// it back, an opened file or saved chat puts it away (the chat engine).
const showHero =
engine.isRestored &&
engine.messages.length === 0 &&
!isRealDiagram(chartXML) &&
!heroDismissed
// Phones: show the canvas while the AI draws and when a new version
// lands, so the result is visible; the chat tab gets a dot meanwhile
const isBusy = engine.isBusy
const versionCount = useVersionsStore((s) => s.versions.length)
const [chatUnread, setChatUnread] = useState(false)
const lastVersionCountRef = useRef(versionCount)
useEffect(() => {
if (isMobile && isBusy) setMobileView("canvas")
}, [isMobile, isBusy, setMobileView])
useEffect(() => {
const grew = versionCount > lastVersionCountRef.current
lastVersionCountRef.current = versionCount
if (isMobile && grew && engine.isRestored) {
setMobileView("canvas")
setChatUnread(true)
}
}, [versionCount, isMobile, engine.isRestored, setMobileView])
useEffect(() => {
if (mobileView === "chat") setChatUnread(false)
}, [mobileView])
// ⌘B toggles the panel; ⌘/ asks the AI about the selection
useEffect(() => {
const handle = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return false
// ⌘⇧B stays with the browser (Chrome's bookmarks bar)
if (!event.shiftKey && (event.key === "b" || event.key === "B")) {
if (isMobile) return false
togglePanel()
return true
}
if (event.key === "/") {
setHeroDismissed(true)
focusComposer()
return true
}
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (handle(event)) event.preventDefault()
}
// draw.io takes focus when it (re)loads, e.g. after a language
// switch; Escape typed there must still close our dialogs.
// ⌘B stays with draw.io in the canvas (bold, and ⇧ for to back).
const handleInFrame = (event: KeyboardEvent) => {
if (event.key === "b" || event.key === "B") return false
if (event.key === "Escape") {
const ui = useUiStore.getState()
if (
ui.settingsOpen ||
ui.compareVersionId ||
ui.saveDialogOpen
) {
ui.setSettingsOpen(false)
ui.closeCompare()
ui.setSaveDialogOpen(false)
return true
}
}
return handle(event)
}
window.addEventListener("keydown", onKeyDown)
setAppShortcutHandler(handleInFrame)
return () => {
window.removeEventListener("keydown", onKeyDown)
setAppShortcutHandler(null)
}
}, [isMobile, togglePanel, focusComposer])
const openFile = () => fileInputRef.current?.click()
const dismissHero = () => {
setHeroDismissed(true)
setPanelOpen(true)
}
const panelVisible = !isMobile && !showHero && panelOpen
// The canvas edge slides with the panel as it opens or closes; a window
// resize or a dragged panel edge moves it at once. Set in the render
// that changes the edge, so the transition applies to that change.
const [sliding, setSliding] = useState(false)
const [lastPanelVisible, setLastPanelVisible] = useState(panelVisible)
if (lastPanelVisible !== panelVisible) {
setLastPanelVisible(panelVisible)
setSliding(true)
}
useEffect(() => {
if (!sliding) return
const timer = setTimeout(() => setSliding(false), 400)
return () => clearTimeout(timer)
}, [sliding])
// The panel floats on the right; the canvas ends where it begins
const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0
return (
<div
className="fixed inset-0 overflow-hidden bg-canvas"
data-testid="workspace"
>
{/* One canvas for both layouts: a new one would reload draw.io
and lose its undo history */}
<div className="absolute inset-0 flex flex-col">
<div className="relative min-h-0 flex-1">
<CanvasStage
className={
isMobile
? cn(
"inset-0",
mobileView !== "canvas" && "invisible",
)
: cn(
"inset-y-0 left-0",
sliding &&
"transition-[right] duration-300 ease-[var(--ease-out)]",
)
}
style={isMobile ? undefined : { right: canvasRight }}
hideOverlays={!isMobile && showHero}
overlay={(size) => (
<SelectionAsk
width={size.width}
height={size.height}
/>
)}
waitForSavedLocale
/>
{isMobile ? (
mobileView === "chat" &&
(showHero ? (
<div className="absolute inset-0 overflow-y-auto bg-canvas">
<StartBar
className="flex items-center gap-2 px-4 pt-3"
nameClassName="mr-auto"
/>
<LobbyHero
compact
onOpenFile={openFile}
onDrawYourself={() => {
setHeroDismissed(true)
setMobileView("canvas")
}}
/>
</div>
) : (
<ChatPanel mobile onOpenFile={openFile} />
))
) : (
<>
{panelVisible && (
<ChatPanel onOpenFile={openFile} />
)}
{/* Below draw.io's toolbar (50 px), whose
right-hand buttons it would cover */}
{!showHero && !panelOpen && (
<div className="absolute top-[60px] right-3 z-20 flex items-center gap-1 rounded-xl bg-card p-1 shadow-float animate-fade-in">
<BrandMark className="mx-0.5 size-6" />
<IconButton
label={dict.nav.showPanel}
shortcut={`${modKey}B`}
onClick={() => setPanelOpen(true)}
data-testid="show-panel"
>
<PanelRightOpen />
</IconButton>
</div>
)}
{showHero && (
<>
<div className="absolute inset-0 z-10 overflow-y-auto bg-canvas">
<div className="flex min-h-full items-center">
<LobbyHero
onOpenFile={openFile}
onDrawYourself={dismissHero}
/>
</div>
</div>
<StartBar
className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-xl bg-card py-1 pr-1 pl-1.5 shadow-float"
nameClassName="pr-1"
/>
</>
)}
</>
)}
</div>
{isMobile && (
<nav className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card px-3 pt-1.5 pb-[max(6px,env(safe-area-inset-bottom))]">
{(
[
{
view: "canvas",
label: dict.workspace.mobileCanvas,
icon: <Shapes />,
},
{
view: "chat",
label: dict.workspace.mobileChat,
icon: <MessageSquare />,
},
] as const
).map((item) => (
<button
key={item.view}
type="button"
onClick={() => setMobileView(item.view)}
aria-pressed={mobileView === item.view}
className={cn(
"flex h-10 flex-1 items-center justify-center gap-2 rounded-xl text-[13px] text-muted-foreground [&_svg]:size-4",
mobileView === item.view &&
"bg-accent font-medium text-foreground",
)}
>
{item.icon}
{item.label}
{item.view === "chat" &&
(isBusy || chatUnread) &&
mobileView !== "chat" && (
<span className="size-1.5 rounded-full bg-marker" />
)}
</button>
))}
</nav>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={DIAGRAM_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
// The opened diagram hides the start screen; a file
// that is not one leaves it
const file = event.target.files?.[0]
if (file) engine.openDiagramFile(file)
event.target.value = ""
}}
/>
<SettingsDialog />
<CompareDialog />
<SaveDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
engine.langfuseSessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={exportFilename(engine.currentTitle)}
/>
</div>
)
}
+308 -152
View File
@@ -1,36 +1,95 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { createContext, useCallback, useContext, useRef, useState } from "react" import {
import type { DrawIoEmbedRef, EventExport } from "react-drawio" createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react"
import { toast } from "sonner" import { toast } from "sonner"
import type {
DrawioExportEvent,
DrawioFrameHandle,
} from "@/components/canvas/drawio-frame"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { withPageDefaults } from "@/lib/drawio/drawio-config"
import {
canReplaceDiagram,
commitDiagram,
previewDiagram,
resetPreview,
revertPreview,
selectPage,
} from "@/lib/drawio/editor-bridge"
import { import {
BLANK_MXFILE, BLANK_MXFILE,
normalizeToMxfile, normalizeToMxfile,
} from "@/packages/mcp-server/src/pages.ts" } from "@/packages/mcp-server/src/pages.ts"
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts" import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
import { useCanvasStore } from "@/stores/canvas-store"
import { extractDiagramXML, isRealDiagram } from "../lib/utils" import { extractDiagramXML, isRealDiagram } from "../lib/utils"
/**
* How a diagram reaches the canvas:
* - load: replace the whole document and reset undo (sessions, new chat)
* - preview: streaming AI output, not recorded in undo history
* - commit: final AI result, one undo step; reported to commit listeners
* - revert: drop the streaming preview and go back to the given diagram
*
* preview/commit/revert go through the draw.io editor when it is reachable
* (same origin); otherwise they fall back to a full load.
*/
export type LoadMode = "load" | "preview" | "commit" | "revert"
/** The document without where its view was scrolled to (dx, dy) */
function withoutView(xml: string): string {
return xml.replace(/<mxGraphModel\b[^>]*>/g, (tag) =>
tag.replace(/\s(?:dx|dy)="[^"]*"/g, ""),
)
}
export interface DiagramCommit {
beforeXml: string
afterXml: string
toolCallId?: string
/** The page the change was made on; null or absent: the first page */
pageId?: string | null
}
/** An export as draw.io's export action takes it: the format, and for PNG
* its scale, width cap and the page to render (pageId) */
export interface ExportRequest {
format: string
[key: string]: unknown
}
/** What draw.io answered: the image (a data URL, or SVG text) in data; for
* format "xml" the document in xml instead */
export interface ExportResult {
data?: string
xml?: string
}
interface DiagramContextType { interface DiagramContextType {
chartXML: string chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both) // chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.MutableRefObject<string> chartXMLRef: React.MutableRefObject<string>
latestSvg: string // The diagram's latest SVG export, a fallback thumbnail for saves
diagramHistory: { svg: string; xml: string }[] latestSvgRef: React.MutableRefObject<string>
setDiagramHistory: (history: { svg: string; xml: string }[]) => void loadDiagram: (
loadDiagram: (chart: string, skipValidation?: boolean) => string | null chart: string,
// Both return the export's tag (empty when draw.io is not there yet) skipValidation?: boolean,
handleExport: () => string mode?: LoadMode,
handleExportWithoutHistory: () => string meta?: { toolCallId?: string; pageId?: string | null },
// Pending exports by tag; a history or plain export's resolver gets the ) => string | null
// first page's XML /** The XML of the page on screen, exported from draw.io */
exportResolversRef: React.MutableRefObject< fetchDiagramXml: () => Promise<string>
Record<string, (data: string, xml?: string) => void> drawioRef: React.MutableRefObject<DrawioFrameHandle | null>
> handleDiagramExport: (data: DrawioExportEvent) => void
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: EventExport) => void
handleDiagramAutoSave: (data: { xml?: string }) => void handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
@@ -38,14 +97,32 @@ interface DiagramContextType {
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
successMessage?: string, successMessage?: string,
) => void ) => Promise<void>
getThumbnailSvg: () => Promise<string | null> getThumbnailSvg: () => Promise<string | null>
getVersionSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null> captureValidationPng: () => Promise<string | null>
/** One export with its own result; null when draw.io does not answer
* in time (the MCP shell answers the server's export requests with it) */
requestExport: (
request: ExportRequest,
timeoutMs?: number,
) => Promise<ExportResult | null>
/** Show a document for an export only (the MCP's one-page projection of
* another page): not recorded as the diagram, its autosaves ignored,
* until the next loadDiagram puts the real document back */
showTransient: (xml: string) => void
/** A full load was sent and draw.io has not reported it yet: the
* editor still shows the previous document */
hasPendingLoad: () => boolean
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
showSaveDialog: boolean /** Register the handler told about every committed AI change */
setShowSaveDialog: (show: boolean) => void setCommitHandler: (
handler: ((commit: DiagramCommit) => void) | null,
) => void
/** A new user turn starts: forget the streaming base */
startTurn: () => void
} }
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
@@ -54,41 +131,78 @@ const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// echoes the request back in the export event, so each result reaches its // 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 // own caller. Tags end in a request number, so a late result never answers
// a newer request. // a newer request.
type ExportTag = "thumbnail" | "validation" type ExportTag = "thumbnail" | "validation" | "version" | "fetch" | "save"
export function DiagramProvider({ children }: { children: React.ReactNode }) { export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("") const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("") const latestSvgRef = useRef("")
const [diagramHistory, setDiagramHistory] = useState<
{ svg: string; xml: string }[]
>([])
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const drawioRef = useRef<DrawioFrameHandle | null>(null)
// Pending exports, keyed by their export tag // Pending exports, keyed by their export tag
const exportResolversRef = useRef< const exportResolversRef = useRef<
Record<string, (data: string, xml?: string) => void> Record<string, (result: ExportResult) => void>
>({}) >({})
// Pending history exports: the document each one was asked for
const historyXmlRef = useRef(new Map<string, string>())
const exportSeqRef = useRef(0) const exportSeqRef = useRef(0)
// Track latest chartXML for restoration after remount // Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("") const chartXMLRef = useRef<string>("")
// Diagram before the current AI change started streaming
const turnBaseRef = useRef<string | null>(null)
// Full loads sent that draw.io has not reported done yet
const pendingLoadsRef = useRef(0)
const commitHandlerRef = useRef<((commit: DiagramCommit) => void) | null>(
null,
)
// The page to show again once a full load is done: draw.io shows the
// first page of a loaded document
const pageAfterLoadRef = useRef<string | null>(null)
// A transient document (showTransient) is on the canvas, and the page
// the user was on before it
const transientRef = useRef(false)
const pageBeforeTransientRef = useRef<string | null>(null)
// Sends a full load. draw.io runs it when its message arrives, and then
// reports "load": until then the editor shows the diagram from before,
// so later changes go the same way and keep their order
const fullLoad = (
xml: string,
pageId: string | null = useCanvasStore.getState().currentPageId,
) => {
if (!drawioRef.current) return
pageAfterLoadRef.current =
pageId && xml.includes(`id="${pageId}"`) ? pageId : null
pendingLoadsRef.current++
drawioRef.current.load({ xml })
}
const onDrawioLoad = () => { const onDrawioLoad = () => {
pendingLoadsRef.current = Math.max(0, pendingLoadsRef.current - 1)
// Back to the page the user was on
const pageId = pageAfterLoadRef.current
pageAfterLoadRef.current = null
if (pageId) selectPage(pageId)
// Only set ready state once to prevent infinite loops // Only set ready state once to prevent infinite loops
if (hasCalledOnLoadRef.current) return if (hasCalledOnLoadRef.current) return
hasCalledOnLoadRef.current = true hasCalledOnLoadRef.current = true
setIsDrawioReady(true) setIsDrawioReady(true)
// Restore diagram after remount (e.g., theme/UI change) // draw.io's first load: loads sent before it went nowhere
if (drawioRef.current && isRealDiagram(chartXMLRef.current)) { pendingLoadsRef.current = 0
drawioRef.current.load({ xml: chartXMLRef.current }) // Restore diagram after remount (e.g., theme/UI change), or a file
} // opened while draw.io loaded (its page settings count too)
if (chartXMLRef.current) fullLoad(chartXMLRef.current)
} }
const resetDrawioReady = () => { const resetDrawioReady = () => {
hasCalledOnLoadRef.current = false hasCalledOnLoadRef.current = false
pendingLoadsRef.current = 0
setIsDrawioReady(false) setIsDrawioReady(false)
} }
@@ -99,39 +213,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setChartXML(xml) setChartXML(xml)
} }
const handleExport = () => {
if (!drawioRef.current) return ""
// Save this export to history, with the document shown now:
// chartXML can change before the result comes back
const tag = `history-${++exportSeqRef.current}`
historyXmlRef.current.set(tag, chartXMLRef.current)
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
const handleExportWithoutHistory = () => {
if (!drawioRef.current) return ""
// Export without saving to history (for edit_diagram fetching current state)
const tag = `fetch-${++exportSeqRef.current}`
drawioRef.current.exportDiagram({
format: "xmlsvg",
message: tag,
})
return tag
}
// Export with a tag in `message` (draw.io echoes it back in the export // 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 // event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally. // expected occasionally.
// (Reads refs only, so it keeps one identity) // (Reads refs only, so it keeps one identity)
const requestTaggedExport = useCallback( const requestTaggedExport = useCallback(
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) => (tag: ExportTag, format: "xmlsvg" | "svg" | "png", timeoutMs: number) =>
new Promise<string | null>((resolve) => { new Promise<ExportResult | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}` const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => { const finish = (value: ExportResult | null) => {
clearTimeout(timer) clearTimeout(timer)
delete exportResolversRef.current[id] delete exportResolversRef.current[id]
resolve(value) resolve(value)
@@ -144,39 +234,104 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
) )
// Get current diagram as SVG for thumbnail (used by session storage). // Get current diagram as SVG for thumbnail (used by session storage).
// One identity: the chat's auto-save depends on it, and each thumbnail // One identity: the chat's auto-save effect depends on it
// renders this provider again (latestSvg), which would otherwise start
// the next save
const getThumbnailSvg = useCallback(async (): Promise<string | null> => { const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
if (!drawioRef.current) return null if (!drawioRef.current) return null
// Don't export if diagram is empty // Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null if (!isRealDiagram(chartXMLRef.current)) return null
// xmlsvg exports return an SVG data URL // xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000) const result = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) { if (result?.data?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves // Kept for later saves that take no fresh thumbnail
setLatestSvg(svgData) latestSvgRef.current = result.data
return svgData return result.data
} }
return null return null
}, [requestTaggedExport]) }, [requestTaggedExport])
// 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
}
// Capture current diagram as PNG for VLM validation // Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => { const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null if (!drawioRef.current) return null
// Don't export if diagram is empty // Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null if (!isRealDiagram(chartXMLRef.current)) return null
const pngData = await requestTaggedExport("validation", "png", 5000) const result = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL // PNG data should be a base64 data URL
return pngData?.startsWith("data:image/png") ? pngData : null 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,
)
}
// Like requestTaggedExport, with the request's other parameters (a PNG's
// page and width) and both parts of the answer
const requestExport = useCallback(
(request: ExportRequest, timeoutMs = 10000) =>
new Promise<ExportResult | null>((resolve) => {
if (!drawioRef.current) {
resolve(null)
return
}
const id = `export-${++exportSeqRef.current}`
const finish = (value: ExportResult | null) => {
clearTimeout(timer)
delete exportResolversRef.current[id]
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
exportResolversRef.current[id] = finish
drawioRef.current.exportDiagram({ ...request, message: id })
}),
[],
)
const showTransient = (xml: string) => {
if (!transientRef.current) {
pageBeforeTransientRef.current =
useCanvasStore.getState().currentPageId
}
transientRef.current = true
fullLoad(xml)
} }
const loadDiagram = ( const loadDiagram = (
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
mode: LoadMode = "load",
meta?: { toolCallId?: string; pageId?: string | null },
): string | null => { ): string | null => {
// The editor bridge is shared: a page that is gone (another language
// mounted a new one) must not change the new page's canvas
if (!mountedRef.current) return null
// A real document ends a transient one; the user goes back to the
// page they were on before it
const pageBeforeTransient = transientRef.current
? pageBeforeTransientRef.current
: undefined
transientRef.current = false
let xmlToLoad = chart let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal // Validate XML structure before loading (unless skipped for internal
@@ -201,75 +356,79 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
xmlToLoad = withPageDefaults(xmlToLoad)
const previousXml = chartXMLRef.current
if (mode === "preview" && turnBaseRef.current === null) {
turnBaseRef.current = previousXml
}
const beforeXml = turnBaseRef.current ?? previousXml
if (mode !== "preview") turnBaseRef.current = null
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool) // Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
updateChartXML(xmlToLoad) updateChartXML(xmlToLoad)
if (drawioRef.current) { let applied = false
drawioRef.current.load({ if (
xml: xmlToLoad, mode !== "load" &&
pendingLoadsRef.current === 0 &&
canReplaceDiagram(xmlToLoad)
) {
try {
if (mode === "preview") previewDiagram(xmlToLoad)
else if (mode === "commit") commitDiagram(xmlToLoad)
else revertPreview(xmlToLoad)
applied = true
} catch (error) {
console.warn("[loadDiagram] Editor update failed:", error)
}
}
if (!applied) {
// A full load replaces any preview, and its base is stale now
resetPreview()
fullLoad(xmlToLoad, pageBeforeTransient)
}
if (mode === "commit") {
commitHandlerRef.current?.({
beforeXml,
afterXml: xmlToLoad,
toolCallId: meta?.toolCallId,
pageId: meta?.pageId,
}) })
} }
return null return null
} }
const handleDiagramExport = (data: EventExport) => { const startTurn = () => {
// Thumbnail, validation PNG and file save exports go only to their turnBaseRef.current = null
// own caller resetPreview()
}
// Each export result goes to the caller that asked for it, by its tag
const handleDiagramExport = (data: DrawioExportEvent) => {
const tag = data.message?.message const tag = data.message?.message
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) { if (!tag) return
exportResolversRef.current[tag as string]?.(data.data, data.xml) exportResolversRef.current[tag]?.({ data: data.data, xml: data.xml })
return
} }
// Don't write chartXML here: exports don't change the diagram, and // The frame registers this callback once per iframe mount, so it must
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
const extractedXML = extractDiagramXML(data.data)
setLatestSvg(data.data)
// Only add to history if this was a user-initiated export
// Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20
const askedXml =
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
if (askedXml !== undefined) {
historyXmlRef.current.delete(tag as string)
// Store the full multi-page document (extractedXML is only the
// first page), so restoring a version keeps every page
const historyXml = askedXml || extractedXML
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: historyXml,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
return newHistory.slice(-MAX_HISTORY_SIZE)
})
}
// The chat's own export (onFetchChart), not another one in flight
const resolve =
tag !== undefined ? exportResolversRef.current[tag] : undefined
if (resolve) {
delete exportResolversRef.current[tag as string]
resolve(extractedXML)
}
}
// react-drawio registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount // read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => { const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram but // An edit of a transient document is not the diagram's; nor is one
// of the canvas a full load still on its way replaces (draw.io
// reports each load, in order, so no autosave of the new document
// comes before that)
if (transientRef.current || pendingLoadsRef.current > 0) return
// Don't overwrite a pending restore - if we have a diagram but
// DrawIO hasn't loaded yet, it means we're waiting to restore // DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) { if (!hasCalledOnLoadRef.current && chartXMLRef.current) return
return // Only the view moved, or draw.io saved what it just loaded: nothing
} // changed, and a save would put a chat that was only opened first
// in the list
if (withoutView(data.xml) === withoutView(chartXMLRef.current)) return
updateChartXML(data.xml) updateChartXML(data.xml)
} }
@@ -277,11 +436,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const emptyDiagram = BLANK_MXFILE const emptyDiagram = BLANK_MXFILE
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true) loadDiagram(emptyDiagram, true)
setLatestSvg("") latestSvgRef.current = ""
setDiagramHistory([])
} }
const saveDiagramToFile = ( const saveDiagramToFile = async (
filename: string, filename: string,
format: ExportFormat, format: ExportFormat,
sessionId?: string, sessionId?: string,
@@ -296,13 +454,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioFormat = const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Each save has its own tag, so two at once never swap results // Each save has its own tag, so two at once never swap results. A
const tag = `save-${++exportSeqRef.current}` // large PNG takes a while to render.
exportResolversRef.current[tag] = ( const result = await requestTaggedExport("save", drawioFormat, 30000)
exportData: string, if (!result?.data) {
fullDiagramXML?: string, console.warn("Diagram export for saving timed out")
) => { return
delete exportResolversRef.current[tag] }
const exportData = result.data
const fullDiagramXML = result.xml
let fileContent: string | Blob let fileContent: string | Blob
let mimeType: string let mimeType: string
let extension: string let extension: string
@@ -361,10 +521,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Show success toast after download is initiated // Show success toast after download is initiated
if (successMessage) { if (successMessage) {
toast.success(successMessage, { toast.success(successMessage, { duration: 2500 })
position: "bottom-left",
duration: 2500,
})
} }
// Delay URL revocation to ensure download completes // Delay URL revocation to ensure download completes
@@ -373,13 +530,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// 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) // Log save event to Langfuse (just flags the trace, doesn't send content)
const logSaveToLangfuse = async ( const logSaveToLangfuse = async (
filename: string, filename: string,
@@ -397,30 +547,36 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
const setCommitHandler = (
handler: ((commit: DiagramCommit) => void) | null,
) => {
commitHandlerRef.current = handler
}
return ( return (
<DiagramContext.Provider <DiagramContext.Provider
value={{ value={{
chartXML, chartXML,
chartXMLRef, chartXMLRef,
latestSvg, latestSvgRef,
diagramHistory,
setDiagramHistory,
loadDiagram, loadDiagram,
handleExport, fetchDiagramXml,
handleExportWithoutHistory,
exportResolversRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave, handleDiagramAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,
getVersionSvg,
captureValidationPng, captureValidationPng,
requestExport,
showTransient,
hasPendingLoad: () => pendingLoadsRef.current > 0,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,
showSaveDialog, setCommitHandler,
setShowSaveDialog, startTurn,
}} }}
> >
{children} {children}
+2 -6
View File
@@ -1,13 +1,10 @@
services: services:
drawio:
image: jgraph/drawio:latest
ports: ["8080:8080"]
next-ai-draw-io: next-ai-draw-io:
build: build:
context: . context: .
args: # draw.io is bundled into the image (public/drawio), no separate container needed
- NEXT_PUBLIC_DRAWIO_BASE_URL=http://localhost:8080
# Uncomment below for subdirectory deployment # Uncomment below for subdirectory deployment
# args:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
@@ -17,4 +14,3 @@ services:
# environment: # environment:
# # For subdirectory deployment, uncomment and set your path: # # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio # NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio]
+4 -2
View File
@@ -18,9 +18,11 @@
**问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址" **问题**: 内网环境提示"找不到 embed.diagrams.net 的服务器 IP 地址"
**关键点**: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**! **关键点**: 应用现在自带一份 draw.io,从 `/drawio` 提供,浏览器不再需要访问 `embed.diagrams.net`。如果仍然看到这个错误,说明镜像是用 `NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net`(旧版 Dockerfile 的默认值)构建的,去掉这个构建参数重新构建即可。详见[离线部署](./offline-deployment.md)。
**解决方案**: 必须在构建时通过 `args` 传入: `NEXT_PUBLIC_*` 环境变量是**构建时**变量,会被打包到 JS 代码中,**运行时设置无效**!
**如果仍想使用单独的 draw.io 服务器**,在构建时通过 `args` 传入:
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
+82 -181
View File
@@ -1,106 +1,100 @@
# Next AI Draw.io
<div align="center"> <div align="center">
**AI驱动的图表创建工具 - 对话、绘制、可视化** # Next AI Draw.io
**和 AI 对话,画出并修改 draw.io 图表。**
[English](../../README.md) | 中文 | [日本語](../ja/README_JA.md) [English](../../README.md) | 中文 | [日本語](../ja/README_JA.md)
[![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/) [![TrendShift](https://trendshift.io/api/badge/repositories/15449)](https://next-ai-drawio.jiang.jp/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Next.js](https://img.shields.io/badge/Next.js-16.x-black)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.x-61dafb)](https://react.dev/)
[![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang) [![Sponsor](https://img.shields.io/badge/Sponsor-❤-ea4aaa)](https://github.com/sponsors/DayuanJiang)
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/) [**在线体验**](https://next-ai-drawio.jiang.jp/) · [**桌面应用**](https://github.com/DayuanJiang/next-ai-draw-io/releases) · [**MCP 服务器**](#在-ai-代理里使用mcp)
</div> </div>
一个集成了AI功能的Next.js网页应用,与draw.io图表无缝结合。通过自然语言命令和AI辅助可视化来创建、修改和增强图表。 https://github.com/user-attachments/assets/66b9f12f-219f-4d62-acc0-0725e6850eec
> 注:感谢 <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 的赞助支持,本项目的 Demo 现已接入强大的 glm-4.7 模型! 用一句话描述想要的图,AI 就在真正的 draw.io 画布上把它画出来。你可以像编辑任何 draw.io 文件一样手动修改,也可以选中几个图形、告诉 AI 要改什么。AI 的每次修改都是一个版本,可以对比、恢复或撤销;结果可以导出为 `.drawio`、`.png` 或 `.svg`。
<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio" target="_blank"><img src="../../public/volcengine-invite.png" alt="火山引擎方舟 Coding Plan" width="300" /></a> 提供网页版、Windows / macOS / Linux 桌面版,以及供 Claude Code、Cursor、VS Code 等 AI 代理调用的 MCP 服务器。
https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979 ## 亮点
## 目录 **画**
- [Next AI Draw.io](#next-ai-drawio)
- [目录](#目录) - 一句话生成架构图、流程图、时序图等,内置 AWS、Azure、GCP、Kubernetes 等图标库
- [示例](#示例) - 连接线可以带流动动画
- [功能特性](#功能特性) - 上传截图或手绘图,让 AI 照着画;上传 PDF、Markdown、代码等文本文件,从内容生成图
- [MCP服务器](#mcp服务器)
- [Claude Code CLI](#claude-code-cli) **改**
- [快速开始](#快速开始)
- [在线试用](#在线试用) - 用对话修改:改动流式呈现,刚改过的图形在画布上高亮
- [桌面应用](#桌面应用) - 选中图形提问:在画布上选中几个图形,AI 只改这几个
- [使用Docker运行](#使用docker运行) - 版本与撤销:每次 AI 修改都是一张带缩略图的版本卡片,可以和画布对比、恢复、撤销;画布上按 Ctrl+Z 也能一步撤回 AI 的修改
- [安装](#安装) - 它就是普通的 draw.io:双击改名、拖动、调样式、多页,随时导出 `.drawio`、`.png`、`.svg`、`.drawio.svg`
- [部署](#部署)
- [部署到腾讯云EdgeOne Pages](#部署到腾讯云edgeone-pages) **用**
- [部署到Vercel](#部署到vercel)
- [部署到Cloudflare Workers](#部署到cloudflare-workers) - 支持 24 家模型服务商,可以在浏览器里填自己的 API Key,Key 只保存在本地
- [多提供商支持](#多提供商支持) - 支持推理的模型会显示思考过程
- [工作原理](#工作原理) - 深色模式;界面有英文、简体中文、繁体中文、日文
- [支持与联系](#支持与联系)
- [常见问题](#常见问题)
- [Star历史](#star历史)
## 示例 ## 示例
以下是一些示例提示词及其生成的图表:
<div align="center"> <div align="center">
<table width="100%"> <table width="100%">
<tr> <tr>
<td colspan="2" valign="top" align="center"> <td colspan="2" valign="top" align="center">
<strong>动画Transformer连接器</strong><br /> <strong>带动画连接线的 Transformer 架构</strong><br />
<p><strong>Prompt:</strong> Give me a **animated connector** diagram of transformer's architecture.</p> <p><strong>Prompt:</strong> Give me a <strong>animated connector</strong> diagram of transformer's architecture.</p>
<img src="../../public/animated_connectors.svg" alt="带动画连接器的Transformer架构" width="480" /> <img src="../../public/animated_connectors.svg" alt="带动画连接线的 Transformer 架构" width="440" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>RAG技术图</strong><br /> <strong>RAG 架构图</strong><br />
<p><strong>Prompt:</strong> Generate a RAG architecture diagram for **chat application**. Use connected diagram for data ingestion</p> <p><strong>Prompt:</strong> Generate a RAG architecture diagram for <strong>chat application</strong>. Use connected diagram for data ingestion</p>
<img src="../../public/rag_prod.svg" alt="RAG架构图" width="480" /> <img src="../../public/rag_prod.svg" alt="RAG 架构图" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>React和AWS认证流程</strong><br /> <strong>React 加 AWS 的认证流程</strong><br />
<p><strong>Prompt:</strong> Generate authentication process using React with **AWS**. Use Serverless architecture.</p> <p><strong>Prompt:</strong> Generate authentication process using React with <strong>AWS</strong>. Use Serverless architecture.</p>
<img src="../../public/auth.svg" alt="认证架构图" width="480" /> <img src="../../public/auth.svg" alt="认证架构图" width="400" />
</td> </td>
</tr> </tr>
<tr> <tr>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>开放式创新</strong><br /> <strong>开放式创新模型</strong><br />
<p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p> <p><strong>Prompt:</strong> Create visualization of Henry Chesbrough's Open Innovation model.</p>
<img src="../../public/inno.svg" alt="开放式创新图" width="480" /> <img src="../../public/inno.svg" alt="开放式创新图" width="400" />
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
<strong>猫咪素描</strong><br /> <strong>猫咪素描</strong><br />
<p><strong>Prompt:</strong> Draw a cute cat for me.</p> <p><strong>Prompt:</strong> Draw a cute cat for me.</p>
<img src="../../public/cat_demo.svg" alt="猫咪绘图" width="240" /> <img src="../../public/cat_demo.svg" alt="猫咪绘图" width="200" />
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
## 功能特性 ## 使用方式
- **LLM驱动的图表创建**:利用大语言模型通过自然语言命令直接创建和操作draw.io图表 ### 在线体验
- **基于图像的图表复制**:上传现有图表或图像,让AI自动复制和增强
- **PDF和文本文件上传**:上传PDF文档和文本文件,提取内容并从现有文档生成图表
- **AI推理过程显示**:查看支持模型的AI思考过程(OpenAI o1/o3、Gemini、Claude等)
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
- **交互式聊天界面**:与AI实时对话来完善您的图表
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
## MCP服务器 打开 [next-ai-drawio.jiang.jp](https://next-ai-drawio.jiang.jp/) 即可使用,无需安装。演示站有用量限制;点击聊天面板里的设置图标,填入自己的服务商和 API Key 就不受限制。Key 只保存在浏览器本地,不会上传到服务器。
通过MCP(模型上下文协议)在Claude Desktop、Cursor和VS Code等AI代理中使用Next AI Draw.io。 ### 桌面应用
在 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载 Windows、macOS 或 Linux 安装包。
### 在 AI 代理里使用(MCP)
https://github.com/user-attachments/assets/6f53a67b-ab21-4113-9fee-7fb32f9de678
通过 MCP(Model Context Protocol,让 AI 代理调用外部工具的协议),Claude Desktop、Cursor、VS Code 等都能直接画 draw.io 图。在客户端的 MCP 配置里加上:
```json ```json
{ {
@@ -113,157 +107,64 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
} }
``` ```
### Claude Code CLI Claude Code 用一行命令:
```bash ```bash
claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
``` ```
然后让Claude创建图表: 然后对 AI 说"画一个用户认证流程图,包含登录、MFA 和会话管理",图会实时出现在浏览器里。MCP 服务器包含网页版的大部分画图能力:
> "创建一个展示用户认证流程的流程图,包含登录、MFA和会话管理"
图表会实时显示在浏览器中! - 同一套画图规则和图标库(AWS、Azure、GCP、Kubernetes 等)
- 预览页就是网页版的画布:AI 的改动会被标出,按一次 Ctrl+Z 就能撤销,AI 还能读到你选中的图形
- draw.io 编辑器随包一起安装,装好后预览不需要联网(也可以用 `DRAWIO_BASE_URL` 改用外部的 draw.io)
- 截图工具,AI 可以看一眼画好的图并自行修正
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
- 可以让 AI 撤销修改,也可以在 `~/.next-ai-drawio/instructions.md` 里写下自己的画图规则
MCP服务器包含网页版的大部分画图功能: VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等) ## 自己部署
- 截图工具,AI可以查看画好的图并自行修正
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。 ### 本地运行
## 快速开始
### 在线试用
无需安装!直接在我们的演示站点试用:
[![Live Demo](../../public/live-demo-button.svg)](https://next-ai-drawio.jiang.jp/)
> **使用自己的 API Key**:您可以使用自己的 API Key 来绕过演示站点的用量限制。点击聊天面板中的设置图标即可配置您的 Provider 和 API Key。您的 Key 仅保存在浏览器本地,不会被存储在服务器上。
### 桌面应用
从 [Releases 页面](https://github.com/DayuanJiang/next-ai-draw-io/releases) 下载适用于您平台的原生桌面应用:
支持的平台:Windows、macOS、Linux。
### 使用Docker运行
[查看 Docker 指南](./docker.md)
### 安装
1. 克隆仓库:
```bash ```bash
git clone https://github.com/DayuanJiang/next-ai-draw-io git clone https://github.com/DayuanJiang/next-ai-draw-io
cd next-ai-draw-io cd next-ai-draw-io
npm install npm install
cp env.example .env.local cp env.example .env.local # 填入服务商和 API Key,见下文"模型与服务商"
```
详细设置说明请参阅[提供商配置指南](./ai-providers.md)。
2. 运行开发服务器:
```bash
npm run dev npm run dev
``` ```
3. 在浏览器中打开 [http://localhost:6002](http://localhost:6002) 查看应用。 打开 [http://localhost:6002](http://localhost:6002)。
## 部署 ### 一键部署
### 部署到腾讯云EdgeOne Pages | 平台 | 方式 |
| --- | --- |
| 腾讯云 EdgeOne Pages | [![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) 部署后还能获得[每日免费的 DeepSeek 模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888) |
| Vercel | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) 记得在 Vercel 控制台里设置和 `.env.local` 相同的环境变量 |
| Cloudflare Workers | [Cloudflare 部署指南](./cloudflare-deploy.md) |
| Docker | [Docker 指南](./docker.md) |
| 离线或内网 | [离线部署说明](./offline-deployment.md) |
您可以通过[腾讯云EdgeOne Pages](https://pages.edgeone.ai/zh)一键部署。 ### 模型与服务商
直接点击此按钮一键部署: 支持 AWS Bedrock(默认)、OpenAI、Anthropic、Google AI、Google Vertex AI、Azure OpenAI、Ollama、OpenRouter、AIHubMix、DeepSeek、SiliconFlow、SGLang、Vercel AI Gateway、腾讯云 EdgeOne、字节跳动豆包、ModelScope、智谱 GLM、通义千问、七牛云、Kimi、MiniMax、Novita、小米 MiMo、Atlas Cloud,以及一个可填任意地址的 OpenAI 兼容预设(vLLM、LM Studio、网关),共 25 家。各家的环境变量和注意事项见[服务商配置指南](./ai-providers.md)。
[![使用 EdgeOne Pages 部署](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://console.cloud.tencent.com/edgeone/pages/new?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io)
查看[腾讯云EdgeOne Pages文档](https://pages.edgeone.ai/zh/document/product-introduction)了解更多详情。 **选哪个模型**:这项任务要生成格式严格的长文本(draw.io XML),请选能力较强的模型,小模型容易画出错误的图。
同时,通过腾讯云EdgeOne Pages部署,也会获得[每日免费的DeepSeek模型额度](https://edgeone.cloud.tencent.com/pages/document/169925463311781888)。 **多模型与管理面板**:在 `AI_MODEL` 里用逗号列出多个模型 ID,或用 `AI_MODELS_CONFIG` 环境变量 / `ai-models.json` 文件配置多家服务商的模型,所有用户无需自带 Key 即可使用。设置 `ADMIN_PASSWORD` 后访问 `/admin`,可以在网页里管理模型、访问码、功能开关、可观测性和配额,见[管理面板指南](./admin-panel.md)。
### 部署到Vercel ## 支持
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FDayuanJiang%2Fnext-ai-draw-io) - 问题和建议:提交 [GitHub Issue](https://github.com/DayuanJiang/next-ai-draw-io/issues),或发邮件到 me[at]jiang.jp
- 常见问题:[FAQ](./FAQ.md)
- 如果这个项目对你有用,欢迎[赞助](https://github.com/sponsors/DayuanJiang),帮助我维持演示站点的运行
部署Next.js应用最简单的方式是使用Next.js创建者提供的[Vercel平台](https://vercel.com/new)。请确保在Vercel控制台中**设置环境变量**,就像您在本地 `.env.local` 文件中所做的那样。 <div align="center">
查看[Next.js部署文档](https://nextjs.org/docs/app/building-your-application/deploying)了解更多详情。
### 部署到Cloudflare Workers
[查看 Cloudflare 部署指南](./cloudflare-deploy.md)
## 多提供商支持
- [字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)
- AWS Bedrock(默认)
- OpenAI
- Anthropic
- Google AI
- Google Vertex AI
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
除AWS Bedrock和OpenRouter外,所有提供商都支持自定义端点。
📖 **[详细的提供商配置指南](./ai-providers.md)** - 查看各提供商的设置说明。
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理
本应用使用以下技术:
- **Next.js**:用于前端框架和路由
- **Vercel AI SDK**(`ai` + `@ai-sdk/*`):用于流式AI响应和多提供商支持
- **react-drawio**:用于图表表示和操作
图表以XML格式表示,可在draw.io中渲染。AI处理您的命令并相应地生成或修改此XML。
## 支持与联系
**特别感谢[字节跳动豆包](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio)赞助演示站点的 API Token 使用!** 注册火山引擎 ARK 平台即可获得50万免费Token!
如果您觉得这个项目有用,请考虑[赞助](https://github.com/sponsors/DayuanJiang)来帮助我托管在线演示站点!
如需支持或咨询,请在GitHub仓库上提交issue或联系维护者:
- 邮箱:me[at]jiang.jp
## 常见问题
请参阅 [FAQ](./FAQ.md) 了解常见问题和解决方案。
## Star历史
[![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=DayuanJiang/next-ai-draw-io&type=date&legend=top-left)](https://www.star-history.com/#DayuanJiang/next-ai-draw-io&type=date&legend=top-left)
--- </div>
+1 -1
View File
@@ -9,7 +9,7 @@
## 可配置内容 ## 可配置内容
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。 1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,凭据输入框与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。 2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级 ## 优先级
+42 -6
View File
@@ -13,8 +13,6 @@
### 豆包 (字节跳动火山引擎) ### 豆包 (字节跳动火山引擎)
> **免费 Token**:在 [火山引擎 ARK 平台](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) 注册,即可获得所有模型 50 万免费 Token!
```bash ```bash
DOUBAO_API_KEY=your_api_key DOUBAO_API_KEY=your_api_key
AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型 AI_MODEL=doubao-seed-1-8-251215 # 或其他豆包模型
@@ -46,6 +44,8 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1 OPENAI_BASE_URL=https://your-custom-endpoint/v1
``` ```
LM Studio 等本地 OpenAI 兼容服务也用同样的方式:把 base URL 设为 `http://localhost:1234/v1`(LM Studio 的默认端口),API 密钥填任意非空值即可,例如 `lm-studio`。在上面的环境变量或模型设置里配置都可以。
### AIHubMix ### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。 AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
@@ -132,7 +132,7 @@ AI_MODEL=your-deployment-name
```bash ```bash
AZURE_API_KEY=your_api_key AZURE_API_KEY=your_api_key
AZURE_BASE_URL=https://your-resource.openai.azure.com # AZURE_RESOURCE_NAME 的替代方案 AZURE_BASE_URL=https://your-resource.openai.azure.com/openai # AZURE_RESOURCE_NAME 的替代方案
AI_MODEL=your-deployment-name AI_MODEL=your-deployment-name
``` ```
@@ -192,7 +192,7 @@ MODELSCOPE_BASE_URL=https://your-custom-endpoint
可选的自定义 URL: 可选的自定义 URL:
```bash ```bash
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_BASE_URL=http://localhost:11434/api
``` ```
### Vercel AI Gateway ### Vercel AI Gateway
@@ -323,6 +323,18 @@ AI_MODEL=mimo-v2.5-pro
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
``` ```
### 任何 OpenAI 兼容接口(custom)
上面没有列出的服务(vLLM、LM Studio、LiteLLM、API 网关),在设置里选择 **OpenAI-compatible**,填入它的地址和 Key;服务端不校验 key 时填任意非空内容即可。多条 system 消息会合并成一条(开源模型的对话模板通常要求这样),`<think>` 里的推理内容会从回复里分离出来。
```bash
CUSTOM_API_KEY=your_api_key
CUSTOM_BASE_URL=http://127.0.0.1:8000/v1
AI_MODEL=your_model_id
```
---
## 自动检测 ## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。 如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
@@ -330,7 +342,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`: 如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo AI_PROVIDER=google # 或:openai, anthropic, google, vertexai, azure, bedrock, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud, custom
``` ```
## 服务端多模型配置 ## 服务端多模型配置
@@ -392,7 +404,7 @@ AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
| `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) | | `provider` | 是 | 提供商类型(`openai`, `anthropic`, `google`, `bedrock` 等) |
| `models` | 是 | 模型 ID 列表 | | `models` | 是 | 模型 ID 列表 |
| `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 | | `default` | 否 | 设为 `true` 表示默认选中该提供商的第一个模型 |
| `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`) | | `apiKeyEnv` | 否 | 自定义 API Key 环境变量名(默认使用提供商标准变量如 `OPENAI_API_KEY`)。也可以写成环境变量名数组,每次请求从有值的变量中随机选一个,把负载分摊到多个 Key 上 |
| `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 | | `baseUrlEnv` | 否 | 自定义 Base URL 环境变量名 |
### 说明 ### 说明
@@ -431,3 +443,27 @@ TEMPERATURE=0 # 输出更具确定性(推荐用于图表)
- **经济实惠**:DeepSeek 提供具有竞争力的价格 - **经济实惠**:DeepSeek 提供具有竞争力的价格
- **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持) - **隐私保护**:使用 Ollama 进行完全本地、离线的操作(需要强大的硬件支持)
- **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问 - **灵活性**:OpenRouter 通过单一 API 提供对众多模型的访问
## 新增一个服务商
一个服务商的全部事实都在 `lib/providers.ts` 的一条记录里:用哪个 AI SDK(`sdk`)、用什么凭据登录(`credential`)、环境变量 `<PREFIX>_API_KEY` / `<PREFIX>_BASE_URL` 的前缀(`envPrefix`)、官方地址、选择器里的分组,以及可选的提示文案。设置界面、连接测试、聊天路由和管理面板都读这条记录,所以新增一个 OpenAI 兼容服务商只需:
1. 在 `lib/providers.ts` 里加一条记录,例如:
```ts
acme: {
label: "Acme AI",
sdk: "openai-compatible",
credential: "apiKey",
group: "aggregators",
envPrefix: "ACME",
defaultBaseUrl: "https://api.acme.example/v1",
apiKeyUrl: "https://acme.example/keys",
modelList: "openai",
},
```
需要在地址输入框下方显示提示,或在选择器里显示说明的服务商,在记录里加 `baseUrlHint` 或 `note` 对象,四种界面语言(`en`、`zh`、`ja`、`zh-Hant`)都要写。不能在线列出模型的服务商不写 `modelList`,改写一个短的 `models` 列表。
2. 在 `env.example` 里加上 `ACME_API_KEY` 和 `ACME_BASE_URL`(有一个单元测试检查每条记录的变量名都出现在里面)。
3. 在 `docs/en`、`docs/cn`、`docs/ja` 的本文档里各加一节说明。
4. 把它加进 `tests/unit/provider-matrix.test.ts` 开头的两个清单(`MATRIX_PROVIDERS` 和 `MATRIX_MODEL_IDS`),用 `npx vitest run tests/unit/provider-matrix.test.ts -u` 重新生成行为快照;新增的行都属于这家服务商,已有的行不能变。`tests/unit/providers.test.ts` 写明了选择器的分组和会改变服务端行为的服务商集合(自动检测、只接受一条 system 消息),也要加进去。三份 README 里的服务商数量和本文档的 `AI_PROVIDER` 可选值清单同样要改。

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