Compare commits

..

23 Commits

Author SHA1 Message Date
dayuan.jiang
5a4c0cfcc3 fix: always send maxOutputTokens; default 16000
Unset does not mean the model's maximum — the provider fills in its own, and
Bedrock's is 4096 (measured: converse with no inferenceConfig on
us.anthropic.claude-opus-5 returns stopReason=max_tokens at exactly 4096).

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

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

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

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

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

---------

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

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

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

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

* fix: restore files removed by Atlas provider PR

---------

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

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

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

* chore(mcp): version 0.2.3

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

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

Closes #885

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

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

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

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

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

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

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

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

* chore(mcp): bump version to 0.2.2

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

This patch closes the gap end to end:

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

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

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

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

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

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

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

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

---------

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

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

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

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

* feat: load AIHubMix models dynamically

* feat: polish AIHubMix model setup

* feat: send AIHubMix app code

* docs: remove redundant AIHubMix recommendation

---------

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

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

* polish: admin panel UI improvements

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

* polish: admin panel section toggles and reorder

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

* polish: make section enable switch more visible

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

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

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

* feat: graphical model management in admin panel

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

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

* fix: allow testing unsaved providers in admin panel

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

* fix: merge env AI_MODELS_CONFIG with admin panel providers

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

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

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

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

* fix: address admin panel review findings

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

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

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

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

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

* fix(admin): address Copilot review findings

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

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

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

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

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

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

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

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

---------

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

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

Closes #859
2026-06-06 00:15:45 +09:00
72 changed files with 11648 additions and 3399 deletions

View File

@@ -33,6 +33,11 @@
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"], "matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages", "groupName": "Core framework packages",
"automerge": false "automerge": false
},
{
"matchPackageNames": ["@biomejs/biome"],
"groupName": "Biome",
"automerge": false
} }
], ],
"vulnerabilityAlerts": { "vulnerabilityAlerts": {

View File

@@ -23,7 +23,9 @@ jobs:
node-version: '24' node-version: '24'
- name: Run Biome format - name: Run Biome format
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched . # Pin to the version in package.json so CI matches local/pre-commit
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
- name: Check for changes - name: Check for changes
id: changes id: changes

View File

@@ -58,6 +58,8 @@ jobs:
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
@@ -89,4 +91,3 @@ jobs:
docker pull ghcr.io/${REPO_LOWER}:latest docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest

67
.github/workflows/publish-mcp.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Publish MCP Server
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
# skips silently if the package.json version is already on npm — so a
# release is just "bump the version in a PR and merge".
on:
push:
branches:
- main
paths:
- "packages/mcp-server/**"
workflow_dispatch:
permissions:
contents: read
id-token: write # OIDC token for npm trusted publishing
concurrency:
group: publish-mcp
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/mcp-server
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "npm"
cache-dependency-path: packages/mcp-server/package-lock.json
registry-url: "https://registry.npmjs.org"
# Trusted publishing requires npm >= 11.5.1
- name: Update npm
run: npm install -g npm@latest
- name: Check if version is already published
id: version
run: |
LOCAL=$(node -p "require('./package.json').version")
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
echo "Version ${LOCAL} already on npm - nothing to publish"
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "Version ${LOCAL} not on npm - publishing"
echo "publish=true" >> "$GITHUB_OUTPUT"
fi
- name: Install dependencies
if: steps.version.outputs.publish == 'true'
run: npm ci
- name: Test
if: steps.version.outputs.publish == 'true'
run: npm test
- name: Publish to npm
if: steps.version.outputs.publish == 'true'
run: npm publish

View File

@@ -28,6 +28,16 @@ jobs:
- 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
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
# for the Next.js app. Install + run its tests separately so CI catches
# multi-page mxfile regressions.
- name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
e2e: e2e:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest

3
.gitignore vendored
View File

@@ -76,3 +76,6 @@ ai-models.json
# local backups # local backups
*.bak *.bak
.gstack/ .gstack/
# admin panel settings (contains secrets)
data/

View File

@@ -61,6 +61,9 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Writable dir for admin panel settings (data/settings.json)
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000

View File

@@ -43,6 +43,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Deploy on Vercel](#deploy-on-vercel) - [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support) - [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) - [How It Works](#how-it-works)
- [Support \& Contact](#support--contact) - [Support \& Contact](#support--contact)
- [FAQ](#faq) - [FAQ](#faq)
@@ -209,6 +211,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
- AIHubMix
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope - ModelScope
@@ -222,7 +225,13 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
### Server-Side Multi-Model Configuration ### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
**Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. **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.

View File

@@ -0,0 +1,65 @@
import { getApiEndpoint } from "@/lib/base-path"
import type { ProviderName } from "@/lib/types/model-config"
export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
// ── Shared types ─────────────────────────────────────────────────────
export type SecretValue = { isSet: true; hint: string }
export function isSecretValue(v: unknown): v is SecretValue {
return typeof v === "object" && v !== null && "isSet" in v
}
export interface SettingState {
key: string
source: "file" | "env" | "default"
value: string | SecretValue | null
}
export type SettingsMap = Record<string, SettingState>
// Editable text of a saved setting; secrets have none (write-only)
export function savedTextOf(state: SettingState | undefined): string {
return state && !isSecretValue(state.value) ? (state.value ?? "") : ""
}
// Admin provider in client state. Secret fields hold either a masked
// marker (unchanged) or a plaintext string (new value).
export interface AdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string | SecretValue
baseUrl?: string
awsAccessKeyId?: string | SecretValue
awsSecretAccessKey?: string | SecretValue
awsRegion?: string
vertexApiKey?: string | SecretValue
models: string[]
isDefault?: boolean
}
// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only
export interface EnvProvider {
name: string
provider: ProviderName
models: string[]
isDefault: boolean
}
export async function adminFetch(path: string, pw: string, init?: RequestInit) {
const res = await fetch(getApiEndpoint(path), {
...init,
headers: {
...init?.headers,
"x-admin-password": pw,
...(init?.body ? { "Content-Type": "application/json" } : {}),
},
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Request failed (${res.status})`)
}
return data
}

View File

@@ -0,0 +1,609 @@
import {
AlertCircle,
Check,
Loader2,
Plus,
Star,
Trash2,
X,
Zap,
} from "lucide-react"
import { useState } from "react"
import { ProviderCredentialsFields } from "@/components/provider-credentials-fields"
import { ProviderLogo } from "@/components/provider-logo"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
SUGGESTED_MODELS,
} from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
} from "./admin-shared"
import { SecretInput } from "./setting-field"
// ── Models section (mirrors the user ModelConfigDialog) ──────────────
function ProviderDetail({
provider,
disabled,
password,
onUpdate,
onDelete,
}: {
provider: AdminProvider
disabled: boolean
password: string
onUpdate: (patch: Partial<AdminProvider>) => void
onDelete: () => void
}) {
const dict = useDictionary()
const [modelInput, setModelInput] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResults, setTestResults] = useState<
Record<string, { ok: boolean; message: string }>
>({})
const info = PROVIDER_INFO[provider.provider]
const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter(
(m) => !provider.models.includes(m),
)
const addModel = (modelId: string) => {
const trimmed = modelId.trim()
if (!trimmed || provider.models.includes(trimmed)) return
onUpdate({ models: [...provider.models, trimmed] })
setModelInput("")
}
const testModel = async (modelId: string) => {
setTesting(modelId)
try {
const data = await adminFetch("/api/admin/test-model", password, {
method: "POST",
body: JSON.stringify({ provider, modelId }),
})
setTestResults((prev) => ({
...prev,
[modelId]: data.valid
? {
ok: true,
message: formatMessage(dict.admin.testOk, {
ms: data.responseTime,
}),
}
: {
ok: false,
message: data.error || dict.admin.testFailed,
},
}))
} catch (err) {
setTestResults((prev) => ({
...prev,
[modelId]: {
ok: false,
message:
err instanceof Error
? err.message
: dict.admin.testFailed,
},
}))
} finally {
setTesting(null)
}
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={provider.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">{info.label}</h3>
<p className="text-xs text-muted-foreground">
{provider.models.length === 0
? dict.admin.noModelsConfigured
: formatMessage(
provider.models.length === 1
? dict.admin.modelCount
: dict.admin.modelCountPlural,
{ count: provider.models.length },
)}
</p>
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Star
className={cn(
"h-3.5 w-3.5",
provider.isDefault &&
"fill-amber-400 text-amber-400",
)}
aria-hidden="true"
/>
{dict.admin.default}
<Switch
checked={!!provider.isDefault}
disabled={disabled}
aria-label={dict.admin.setAsDefault}
onCheckedChange={(checked) =>
onUpdate({ isDefault: checked })
}
/>
</label>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
{dict.admin.delete}
</Button>
</div>
{/* Credentials (shared with the user ModelConfigDialog) */}
<ProviderCredentialsFields
provider={provider.provider}
name={provider.name}
baseUrl={provider.baseUrl}
awsRegion={provider.awsRegion}
disabled={disabled}
onChange={(field, value) => onUpdate({ [field]: value })}
renderSecret={({ field, id }) => (
// Bare id keeps the shared component's <Label htmlFor={id}>
// associated; only one ProviderDetail is mounted at a time.
<SecretInput
id={id}
keepOnEmpty
value={provider[field]}
disabled={disabled}
onChange={(v) => onUpdate({ [field]: v })}
/>
)}
/>
{/* Models */}
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{dict.admin.models}
</Label>
<div className="flex items-center gap-1.5">
<Input
value={modelInput}
disabled={disabled}
placeholder={dict.admin.modelIdPlaceholder}
spellCheck={false}
className="h-8 w-48 font-mono text-xs"
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") addModel(modelInput)
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={disabled || !modelInput.trim()}
aria-label={dict.admin.addModel}
onClick={() => addModel(modelInput)}
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
{suggestions.length > 0 && (
<Select
disabled={disabled}
onValueChange={(v) => addModel(v)}
>
<SelectTrigger className="h-8 w-28 text-xs">
{dict.admin.suggested}
</SelectTrigger>
<SelectContent className="max-h-72">
{suggestions.map((m) => (
<SelectItem
key={m}
value={m}
className="font-mono text-xs"
>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="overflow-hidden rounded-lg border">
{provider.models.length === 0 ? (
<p className="p-5 text-center text-sm text-muted-foreground">
{dict.admin.addProviderToOfferModels}
</p>
) : (
<ul className="divide-y">
{provider.models.map((modelId, index) => {
const result = testResults[modelId]
return (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{provider.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
{result && (
<span
className={cn(
"flex items-center gap-1 text-xs",
result.ok
? "text-green-600 dark:text-green-400"
: "text-destructive",
)}
>
{result.ok ? (
<Check
className="h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<AlertCircle
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="max-w-48 truncate">
{result.message}
</span>
</span>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={
disabled || testing !== null
}
onClick={() =>
void testModel(modelId)
}
>
{testing === modelId ? (
<Loader2
className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Zap
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
<span className="ml-1">
{dict.admin.test}
</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={disabled}
aria-label={formatMessage(
dict.admin.removeModel,
{ model: modelId },
)}
onClick={() =>
onUpdate({
models: provider.models.filter(
(m) => m !== modelId,
),
})
}
>
<X
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</Button>
</li>
)
})}
</ul>
)}
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage(dict.admin.deleteProviderTitle, {
name: provider.name || info.label,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{dict.admin.deleteProviderDesc}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{dict.admin.cancel}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
setDeleteOpen(false)
onDelete()
}}
>
{dict.admin.delete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
export function ModelsSection({
providers,
envProviders,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
disabled: boolean
password: string
onChange: (providers: AdminProvider[]) => void
}) {
const dict = useDictionary()
const [selectedId, setSelectedId] = useState<string | null>(
providers[0]?.id ?? null,
)
const selected = providers.find((p) => p.id === selectedId)
const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId)
const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = {
id: crypto.randomUUID(),
provider,
models: [],
isDefault: providers.length === 0,
}
onChange([...providers, newProvider])
setSelectedId(newProvider.id)
}
const updateProvider = (id: string, patch: Partial<AdminProvider>) => {
onChange(
providers.map((p) => {
if (p.id !== id) {
// Only one default at a time
return patch.isDefault ? { ...p, isDefault: false } : p
}
return { ...p, ...patch }
}),
)
}
const deleteProvider = (id: string) => {
const next = providers.filter((p) => p.id !== id)
onChange(next)
setSelectedId(next[0]?.id ?? null)
}
return (
<div className="flex min-h-72 flex-col sm:flex-row">
{/* Provider list */}
<div className="flex w-full shrink-0 flex-col border-b sm:w-52 sm:border-b-0 sm:border-r">
<div className="flex-1 space-y-1 p-2">
{providers.length === 0 && envProviders.length === 0 && (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{dict.admin.addProviderHint}
</p>
)}
{envProviders.map((p) => (
<button
key={`env:${p.name}`}
type="button"
onClick={() => setSelectedId(`env:${p.name}`)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === `env:${p.name}` &&
"bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name}
</span>
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-medium uppercase text-muted-foreground">
{dict.admin.sourceEnv}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
{providers.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selectedId === p.id && "bg-muted font-medium",
)}
>
<ProviderLogo provider={p.provider} />
<span className="min-w-0 flex-1 truncate">
{p.name || PROVIDER_INFO[p.provider].label}
</span>
{p.isDefault && (
<Star
className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400"
aria-label={dict.admin.defaultProvider}
/>
)}
</button>
))}
</div>
<div className="border-t p-2">
<Select
disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)}
>
<SelectTrigger className="w-full">
<Plus
className="mr-1 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
{dict.modelConfig.addProvider}
</SelectTrigger>
<SelectContent className="max-h-72">
{(Object.keys(PROVIDER_INFO) as ProviderName[]).map(
(p) => {
// Global-credential providers already in
// the env config can't be added here —
// panel credentials would override theirs
const envBlocked =
FIXED_CRED_PROVIDERS.includes(p) &&
envProviders.some(
(e) => e.provider === p,
)
return (
<SelectItem
key={p}
value={p}
disabled={envBlocked}
>
<div className="flex items-center gap-2">
<ProviderLogo provider={p} />
{PROVIDER_INFO[p].label}
{envBlocked && (
<span className="text-xs text-muted-foreground">
{
dict.admin
.managedViaEnv
}
</span>
)}
</div>
</SelectItem>
)
},
)}
</SelectContent>
</Select>
</div>
</div>
{/* Detail */}
<div className="min-w-0 flex-1 p-4">
{selected ? (
<ProviderDetail
key={selected.id}
provider={selected}
disabled={disabled}
password={password}
onUpdate={(patch) => updateProvider(selected.id, patch)}
onDelete={() => deleteProvider(selected.id)}
/>
) : selectedEnv ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<ProviderLogo
provider={selectedEnv.provider}
className="size-5"
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="font-semibold">
{selectedEnv.name}
</h3>
<p className="text-xs text-muted-foreground">
{dict.admin.envReadOnly}
</p>
</div>
</div>
<div className="overflow-hidden rounded-lg border">
<ul className="divide-y">
{selectedEnv.models.map((modelId, index) => (
<li
key={modelId}
className="flex items-center gap-2 px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{modelId}
{selectedEnv.isDefault &&
index === 0 && (
<span className="ml-2 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase text-amber-600 dark:text-amber-400">
{
dict.admin
.defaultModel
}
</span>
)}
</span>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="py-12 text-center text-sm text-muted-foreground">
{dict.admin.selectProviderHint}
</p>
)}
</div>
</div>
)
}

610
app/[lang]/admin/page.tsx Normal file
View File

@@ -0,0 +1,610 @@
"use client"
import {
AlertTriangle,
Check,
Loader2,
LockKeyhole,
ShieldCheck,
} from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import {
SETTING_GROUPS,
SETTINGS_BY_GROUP,
} from "@/lib/admin/settings-registry"
import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
type AdminProvider,
adminFetch,
type EnvProvider,
isSecretValue,
SESSION_PASSWORD_KEY,
type SettingState,
type SettingsMap,
savedTextOf,
} from "./admin-shared"
import { ModelsSection } from "./models-section"
import { SettingField } from "./setting-field"
// ── Page ─────────────────────────────────────────────────────────────
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
const groupText = (id: string) =>
(
dict.admin.groups as Record<
string,
{ title: string; description: string } | undefined
>
)[id]
const navItems = NAV_GROUP_IDS.map((id) => ({
id,
title:
id === "models" ? dict.admin.models : (groupText(id)?.title ?? id),
}))
const [password, setPassword] = useState("")
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
const [authError, setAuthError] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [writable, setWritable] = useState(true)
// Models section state
const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
const [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders
// General settings state
const [settings, setSettings] = useState<SettingsMap>({})
const [pending, setPending] = useState<Record<string, string | null>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [enabledGroups, setEnabledGroups] = useState<Record<string, boolean>>(
{},
)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState<{
ok: boolean
text: string
} | null>(null)
const [activeGroup, setActiveGroup] = useState("models")
const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0)
const applySettingsResponse = useCallback(
(data: { writable: boolean; settings: SettingState[] }) => {
setWritable(data.writable)
const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s
setSettings(map)
// Seed each toggle once from whether the group has configured
// values; don't stomp a user's explicit toggle on later saves
setEnabledGroups((prev) => {
const next = { ...prev }
for (const group of SETTING_GROUPS) {
if (!group.toggleable || group.id in next) continue
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return next
})
},
[],
)
const applyProvidersResponse = useCallback(
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
},
[],
)
const login = useCallback(
async (pw: string) => {
setAuthLoading(true)
setAuthError("")
try {
const [settingsData, providersData] = await Promise.all([
adminFetch("/api/admin/settings", pw),
adminFetch("/api/admin/providers", pw),
])
applySettingsResponse(settingsData)
applyProvidersResponse(providersData)
setAuthedPassword(pw)
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
} catch (err) {
setAuthError(
err instanceof Error ? err.message : dict.admin.loginFailed,
)
} finally {
setAuthLoading(false)
}
},
[applySettingsResponse, applyProvidersResponse, dict],
)
// Restore session on mount
useEffect(() => {
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
if (stored) void login(stored)
}, [login])
// Warn before leaving with unsaved changes
const hasDirty = dirtyCount > 0
useEffect(() => {
if (!hasDirty) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
// Some browsers only show the prompt when returnValue is set
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [hasDirty])
// Highlight the section currently in view in the sidebar
useEffect(() => {
if (!authedPassword) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
)
if (visible[0]) setActiveGroup(visible[0].target.id)
},
{ rootMargin: "-10% 0px -50% 0px" },
)
for (const id of NAV_GROUP_IDS) {
const el = document.getElementById(id)
if (el) observer.observe(el)
}
return () => observer.disconnect()
}, [authedPassword])
const handleChange = useCallback(
(key: string, value: string | null) => {
setSaveMessage(null)
setErrors((prev) => {
if (!(key in prev)) return prev
const next = { ...prev }
delete next[key]
return next
})
setPending((prev) => {
const state = settings[key]
const isRevert =
value !== null &&
state?.source === "file" &&
!isSecretValue(state?.value) &&
value === savedTextOf(state)
const isNoop =
value === "" &&
(!state || state.source !== "file") &&
!isSecretValue(state?.value)
if (isRevert || isNoop) {
const next = { ...prev }
delete next[key]
return next
}
return { ...prev, [key]: value === "" ? null : value }
})
},
[settings],
)
// Toggling a group off stages deletion of its saved values so the
// feature actually turns off on save; toggling on drops those deletions.
const handleGroupToggle = useCallback(
(groupId: string, enabled: boolean) => {
setSaveMessage(null)
setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled }))
const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map(
(d) => d.key,
)
setPending((prev) => {
const next = { ...prev }
for (const key of keys) {
if (!enabled) {
// Stage deletion only for values currently set
if (settings[key]?.source !== "default")
next[key] = null
} else if (next[key] === null) {
delete next[key]
}
}
return next
})
},
[settings],
)
const handleSave = useCallback(async () => {
if (!authedPassword || dirtyCount === 0) return
setSaving(true)
setSaveMessage(null)
setErrors({})
try {
if (providersDirty) {
const data = await adminFetch(
"/api/admin/providers",
authedPassword,
{ method: "PUT", body: JSON.stringify({ providers }) },
)
applyProvidersResponse(data)
}
if (Object.keys(pending).length > 0) {
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-admin-password": authedPassword,
},
body: JSON.stringify({ values: pending }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
// Per-field validation errors come back as {errors: {...}}
if (data.errors) {
setErrors(data.errors)
const firstKey = Object.keys(data.errors)[0]
document.getElementById(`setting-${firstKey}`)?.focus()
throw new Error(dict.admin.invalidSettings)
}
throw new Error(
data.error || `Request failed (${res.status})`,
)
}
applySettingsResponse(data)
setPending({})
}
setSaveMessage({
ok: true,
text: dict.admin.saved,
})
setTimeout(() => setSaveMessage(null), 4000)
} catch (err) {
setSaveMessage({
ok: false,
text:
err instanceof Error ? err.message : dict.admin.saveFailed,
})
} finally {
setSaving(false)
}
}, [
authedPassword,
pending,
providers,
providersDirty,
dirtyCount,
applySettingsResponse,
applyProvidersResponse,
dict,
])
// ── Login screen ─────────────────────────────────────────────────
if (!authedPassword) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<form
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
onSubmit={(e) => {
e.preventDefault()
void login(password)
}}
>
<div className="flex items-center gap-2">
<LockKeyhole
className="h-5 w-5 text-muted-foreground"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-sm text-muted-foreground">
{dict.admin.loginPrompt}
</p>
<div className="space-y-1.5">
<Label htmlFor="admin-password">
{dict.admin.password}
</Label>
<Input
id="admin-password"
name="admin-password"
type="password"
value={password}
autoComplete="current-password"
spellCheck={false}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<p
className={cn(
"text-sm text-destructive",
!authError && "sr-only",
)}
aria-live="polite"
>
{authError}
</p>
<Button
type="submit"
className="w-full"
disabled={authLoading}
>
{authLoading ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.signingIn}
</>
) : (
dict.admin.signIn
)}
</Button>
</form>
</div>
)
}
// ── Settings screen ──────────────────────────────────────────────
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<ShieldCheck
className="h-5 w-5 text-primary"
aria-hidden="true"
/>
<h1 className="text-lg font-semibold">
{dict.admin.title}
</h1>
</div>
<p className="text-xs text-muted-foreground">
{dict.admin.precedence}
</p>
</div>
</header>
{!writable && (
<div className="border-b bg-amber-500/10">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
{dict.admin.notWritable}
</div>
</div>
)}
<div className="mx-auto flex max-w-6xl gap-8 px-4 py-6">
<nav
aria-label={dict.admin.settingGroups}
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
>
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={
activeGroup === item.id
? "true"
: undefined
}
className={cn(
"block rounded-md px-3 py-1.5 text-sm hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
activeGroup === item.id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground",
)}
>
{item.title}
</a>
</li>
))}
</ul>
</nav>
<main className="min-w-0 flex-1 pb-24">
{/* Models section */}
<section aria-labelledby="models" className="mb-10">
<h2
id="models"
className="scroll-mt-20 text-base font-semibold"
>
{dict.admin.models}
</h2>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{dict.admin.modelsDescription}
</p>
<div className="overflow-hidden rounded-lg border bg-card">
<ModelsSection
providers={providers}
envProviders={envProviders}
disabled={!writable || saving}
password={authedPassword}
onChange={(next) => {
setSaveMessage(null)
setProviders(next)
}}
/>
</div>
</section>
{/* Registry-driven groups */}
{SETTING_GROUPS.map((group) => {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff =
group.toggleable && !enabledGroups[group.id]
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
return (
<section
key={group.id}
aria-labelledby={group.id}
className="mb-10"
>
<div className="flex items-center justify-between gap-4">
<h2
id={group.id}
className="scroll-mt-20 text-base font-semibold"
>
{title}
</h2>
{group.toggleable && (
<label
className={cn(
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
enabledGroups[group.id]
? "border-primary/30 bg-primary/5 text-primary"
: "border-border bg-muted/50 text-muted-foreground hover:border-foreground/30 hover:text-foreground",
)}
>
{enabledGroups[group.id]
? dict.admin.enabled
: dict.admin.disabled}
<Switch
checked={
!!enabledGroups[group.id]
}
disabled={!writable || saving}
aria-label={formatMessage(
dict.admin.enableGroup,
{ group: title },
)}
onCheckedChange={(checked) =>
handleGroupToggle(
group.id,
checked,
)
}
/>
</label>
)}
</div>
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
{gt?.description ?? group.description}
</p>
<div
className={cn(
"rounded-lg border bg-card px-4",
groupOff &&
"pointer-events-none opacity-50",
)}
>
{defs.map((def) => (
<SettingField
key={def.key}
def={def}
state={settings[def.key]}
pendingValue={pending[def.key]}
error={errors[def.key]}
disabled={fieldsDisabled}
onChange={(v) =>
handleChange(def.key, v)
}
/>
))}
</div>
</section>
)
})}
</main>
</div>
{/* Always-mounted live region so save results are announced */}
<p aria-live="polite" className="sr-only">
{saveMessage?.text ?? ""}
</p>
{(dirtyCount > 0 || saveMessage) && (
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<p
className={cn(
"flex min-w-0 items-center gap-1.5 truncate text-sm",
saveMessage?.ok
? "text-green-600 dark:text-green-400"
: saveMessage
? "text-destructive"
: "text-muted-foreground",
)}
>
{saveMessage?.ok && (
<Check
className="h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
{saveMessage && !saveMessage.ok
? saveMessage.text
: dirtyCount > 0
? dict.admin.unsavedChanges
: saveMessage?.text}
</p>
{dirtyCount > 0 && (
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => {
setPending({})
setErrors({})
setProviders(JSON.parse(savedProviders))
}}
>
{dict.admin.discard}
</Button>
<Button
type="button"
disabled={saving || !writable}
onClick={() => void handleSave()}
>
{saving ? (
<>
<Loader2
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{dict.admin.saving}
</>
) : (
dict.admin.saveChanges
)}
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,312 @@
import { Eye, EyeOff, X } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary"
import type { SettingDef } from "@/lib/admin/settings-registry"
import { formatMessage } from "@/lib/i18n/utils"
import { cn } from "@/lib/utils"
import {
isSecretValue,
type SecretValue,
type SettingState,
savedTextOf,
} from "./admin-shared"
// ── Small shared UI bits ─────────────────────────────────────────────
export function SourceChip({ source }: { source: "file" | "env" | "default" }) {
const dict = useDictionary()
if (source === "default") return null
return (
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
source === "file"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}
title={
source === "file"
? dict.admin.sourceSavedTitle
: dict.admin.sourceEnvTitle
}
>
{source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv}
</span>
)
}
export function RestartBadge() {
const dict = useDictionary()
return (
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
{dict.admin.restartRequired}
</span>
)
}
// Secret input: shows masked hint as placeholder, typing replaces.
// With keepOnEmpty, clearing the field reverts to the stored value
// ("keep") instead of deleting it — explicit deletion is via the X button.
export function SecretInput({
id,
value,
disabled,
keepOnEmpty,
onChange,
}: {
id: string
value: string | SecretValue | undefined
disabled?: boolean
keepOnEmpty?: boolean
onChange: (value: string | SecretValue) => void
}) {
const dict = useDictionary()
const [show, setShow] = useState(false)
// The stored marker as it was at mount, to revert to on empty
const [original] = useState(value)
const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value)
? formatMessage(dict.admin.savedReplace, { hint: value.hint })
: dict.admin.notSet
const handleText = (t: string) => {
if (t === "" && keepOnEmpty && hadStored && original) {
onChange(original)
} else {
onChange(t)
}
}
return (
<div className="flex items-center gap-1">
<Input
id={id}
type={show ? "text" : "password"}
value={text}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={placeholder}
className="h-9 font-mono text-xs"
onChange={(e) => handleText(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={show ? dict.admin.hideValue : dict.admin.showValue}
onClick={() => setShow((s) => !s)}
>
{show ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{keepOnEmpty && (hadStored || text) && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label={dict.admin.removeValue}
title={dict.admin.removeValueTitle}
onClick={() => onChange("")}
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
)}
</div>
)
}
// ── General settings field (registry-driven) ─────────────────────────
export function SettingField({
def,
state,
pendingValue,
error,
disabled,
onChange,
}: {
def: SettingDef
state: SettingState | undefined
pendingValue: string | null | undefined
error?: string
disabled: boolean
onChange: (value: string | null) => void
}) {
const dict = useDictionary()
const isDirty = pendingValue !== undefined
const source = state?.source ?? "default"
const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state)
const secretState = state && isSecretValue(state.value) ? state.value : null
// Localized label/description keyed by env var name, falling back to the
// registry's English (the registry stays canonical for the server).
const t = (
dict.admin.settings as Record<
string,
{ label?: string; description?: string } | undefined
>
)[def.key]
const label = t?.label ?? def.label
const description = t?.description ?? def.description
const inputId = `setting-${def.key}`
const errorId = `${inputId}-error`
let control: React.ReactNode
switch (def.type) {
case "boolean": {
// When unset, reflect the built-in runtime default so the toggle
// matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on).
const effective =
currentValue !== "" ? currentValue : (def.default ?? "false")
// A saved boolean can be cleared back to its env/default value.
const canClear =
(isDirty && pendingValue !== null) || source === "file"
control = (
<div className="flex items-center gap-3">
<Switch
id={inputId}
checked={effective === "true"}
disabled={disabled}
onCheckedChange={(checked) =>
onChange(checked ? "true" : "false")
}
/>
{canClear && !disabled && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => onChange(null)}
>
{dict.admin.resetToDefault}
</Button>
)}
</div>
)
break
}
case "enum":
control = (
<Select
value={currentValue || undefined}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger id={inputId} className="w-full max-w-xs">
<SelectValue placeholder={dict.admin.notSet} />
</SelectTrigger>
<SelectContent>
{def.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)
break
case "secret":
control = (
<div className="w-full max-w-md">
<SecretInput
id={inputId}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? currentValue)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : "")
}
/>
</div>
)
break
case "number":
control = (
<Input
id={inputId}
type="number"
inputMode="numeric"
min={def.min}
max={def.max}
value={currentValue}
disabled={disabled}
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-xs tabular-nums"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
break
default:
control = (
<Input
id={inputId}
type="text"
value={currentValue}
disabled={disabled}
spellCheck={false}
autoComplete="off"
placeholder={def.placeholder ?? dict.admin.notSet}
className="w-full max-w-md"
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
onChange={(e) => onChange(e.target.value)}
/>
)
}
return (
<div className="border-b border-border/60 py-4 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Label htmlFor={inputId} className="text-sm font-medium">
{label}
</Label>
<SourceChip source={source} />
{def.restartRequired && <RestartBadge />}
{isDirty && (
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
{dict.admin.modified}
</span>
)}
</div>
{description && (
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
{description}
</p>
)}
{control}
<p
id={errorId}
className={cn(
"text-xs text-destructive",
error ? "mt-1.5" : "sr-only",
)}
aria-live="polite"
>
{error ?? ""}
</p>
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
async function payload() {
// Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown
// read-only in the panel; their credentials live in the environment
const envConfig = await loadEnvServerModelsConfig()
const adminProviders = loadAdminProviders()
// A panel default overrides any env default (matches the merge in
// loadRawServerModelsConfig), so env stars must reflect that
const adminHasDefault = adminProviders.some(
(p) => p.isDefault && p.models.length > 0,
)
return {
writable: isSettingsWritable(),
providers: maskAdminProviders(adminProviders),
envProviders:
envConfig?.providers.map((p) => ({
name: p.name,
provider: p.provider,
models: p.models,
isDefault: !!p.default && !adminHasDefault,
})) ?? [],
}
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json(await payload())
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: unknown
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProvidersSchema.safeParse(
(body as { providers?: unknown })?.providers,
)
if (!parsed.success) {
return Response.json(
{
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
},
{ status: 400 },
)
}
const stored = loadAdminProviders()
const merged = mergeSecrets(parsed.data, stored)
const envConfig = await loadEnvServerModelsConfig()
const validationError = validateAdminProviders(merged, envConfig)
if (validationError) {
return Response.json({ error: validationError }, { status: 400 })
}
saveSettings(deriveEnvUpdates(merged, stored))
return Response.json(await payload())
}

View File

@@ -0,0 +1,126 @@
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
import {
SETTINGS_BY_KEY,
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
const source = getValueSource(def.key)
const raw =
source === "file"
? fileValues[def.key]
: (getEnvFallback(def.key) ?? null)
const value = def.type === "secret" && raw ? maskSecret(raw) : raw
return { key: def.key, source, value }
})
}
export async function GET(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
writable: isSettingsWritable(),
settings: serializeSettings(),
})
}
function validateValue(def: SettingDef, value: string): string | null {
switch (def.type) {
case "number": {
const num = Number(value)
if (!Number.isFinite(num)) return "Must be a number"
if (def.min !== undefined && num < def.min)
return `Must be at least ${def.min}`
if (def.max !== undefined && num > def.max)
return `Must be at most ${def.max}`
return null
}
case "boolean":
return value === "true" || value === "false"
? null
: 'Must be "true" or "false"'
case "enum":
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {
return Response.json(
{
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
},
{ status: 503 },
)
}
let body: { values?: Record<string, unknown> }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
if (!body.values || typeof body.values !== "object") {
return Response.json(
{ error: "Body must contain a values object" },
{ status: 400 },
)
}
const updates: Record<string, string | null> = {}
const errors: Record<string, string> = {}
for (const [key, value] of Object.entries(body.values)) {
const def = SETTINGS_BY_KEY.get(key)
if (!def) {
errors[key] = "Unknown setting"
continue
}
if (value === null || value === "") {
updates[key] = null
continue
}
if (typeof value !== "string") {
errors[key] = "Value must be a string"
continue
}
const error = validateValue(def, value)
if (error) {
errors[key] = error
continue
}
updates[key] = value
}
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 400 })
}
saveSettings(updates)
return Response.json({
writable: true,
settings: serializeSettings(),
})
}

View File

@@ -0,0 +1,66 @@
import { POST as validateModel } from "@/app/api/validate-model/route"
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProviderSchema,
loadAdminProviders,
mergeSecrets,
} from "@/lib/admin/providers"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
// Test a model with the client's CURRENT provider state (which may be
// unsaved). Secret fields arrive either as plaintext (newly typed) or as
// masked {isSet} markers, which are resolved against settings.json — so
// testing works both before and after saving.
export async function POST(req: Request) {
const authError = checkAdminAuth(req)
if (authError) return authError
let body: { provider?: unknown; modelId?: string }
try {
body = await req.json()
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
}
const parsed = AdminProviderSchema.safeParse(body.provider)
if (!parsed.success || !body.modelId) {
return Response.json(
{ valid: false, error: "Invalid provider or model" },
{ status: 400 },
)
}
// SECURITY: a stored secret is only resolved from an {isSet} marker if
// the endpoint it would be sent to (provider + baseUrl) still matches
// the stored entry. Otherwise a tampered baseUrl could exfiltrate the
// stored key to an arbitrary host. Mismatches must re-supply plaintext.
const stored = loadAdminProviders().find((p) => p.id === parsed.data.id)
const sameEndpoint =
stored &&
stored.provider === parsed.data.provider &&
(stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") &&
(stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "")
const [resolved] = mergeSecrets(
[parsed.data],
sameEndpoint && stored ? [stored] : [],
)
return validateModel(
new Request(new URL("/api/validate-model", req.url), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: resolved.provider,
apiKey: resolved.apiKey,
baseUrl: resolved.baseUrl,
modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey,
awsRegion: resolved.awsRegion,
vertexApiKey: resolved.vertexApiKey,
}),
}),
)
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import {
AIHUBMIX_MODELS_ENDPOINT,
extractAihubmixModelIds,
} from "@/lib/aihubmix-models"
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
const SUCCESS_CACHE_CONTROL =
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
function fallbackResponse() {
return NextResponse.json(
{
models: SUGGESTED_MODELS.aihubmix || [],
source: "fallback",
},
{
headers: {
"Cache-Control": "no-store",
},
},
)
}
export async function GET() {
try {
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
next: { revalidate: 3600 },
})
if (!response.ok) {
console.warn(
`[aihubmix-models] Failed to fetch models: ${response.status}`,
)
return fallbackResponse()
}
const payload = await response.json()
const models = extractAihubmixModelIds(payload)
if (models.length === 0) {
console.warn("[aihubmix-models] Model list response was empty")
return fallbackResponse()
}
return NextResponse.json(
{
models,
source: "aihubmix",
},
{
headers: {
"Cache-Control": SUCCESS_CACHE_CONTROL,
},
},
)
} catch (error) {
console.warn("[aihubmix-models] Failed to load models:", error)
return fallbackResponse()
}
}

View File

@@ -15,7 +15,6 @@ import { z } from "zod"
import { import {
getAIModel, getAIModel,
SINGLE_SYSTEM_PROVIDERS, SINGLE_SYSTEM_PROVIDERS,
supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
@@ -266,16 +265,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
lastUserMessage?.parts?.filter((part: any) => part.type === "file") || lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[] []
// Check if user is sending images to a model that doesn't support them // Note: we used to pre-emptively reject images for models we guessed were
// AI SDK silently drops unsupported parts, so we need to catch this early // text-only (by name matching). That heuristic misfired on newer models
if (fileParts.length > 0 && !supportsImageInput(modelId)) { // (see issue #874), so we now let the request through and surface the real
return Response.json( // provider error if the model genuinely can't accept images.
{
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
},
{ status: 400 },
)
}
// User input only - XML is now in a separate cached system message // User input only - XML is now in a separate cached system message
const formattedUserInput = `User input: const formattedUserInput = `User input:
@@ -500,9 +493,9 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const result = streamText({ const result = streamText({
model, model,
abortSignal: req.signal, abortSignal: req.signal,
...(process.env.MAX_OUTPUT_TOKENS && { // Must be sent: unset means the provider's own default, and Bedrock's is 4096 —
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10), // enough for a small diagram, so larger ones were cut off mid-attribute.
}), maxOutputTokens: Number(process.env.MAX_OUTPUT_TOKENS) || 16000,
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON // Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => { experimental_repairToolCall: async ({ toolCall, error }) => {

View File

@@ -1,4 +1,4 @@
import { extract } from "@extractus/article-extractor" import { extractFromHtml } from "@extractus/article-extractor"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import TurndownService from "turndown" import TurndownService from "turndown"
import { isPrivateUrl } from "@/lib/ssrf-protection" import { isPrivateUrl } from "@/lib/ssrf-protection"
@@ -7,6 +7,31 @@ const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const EXTRACT_TIMEOUT_MS = 15000 const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)" const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
// would produce mojibake; the article-extractor library does the same detection
// when it fetches the page itself, which we no longer rely on.
function detectCharset(
contentType: string | null,
buffer: ArrayBuffer,
): string {
// 1. HTTP Content-Type header charset (most authoritative).
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
const metaCharset =
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
try {
new TextDecoder(charset)
return charset
} catch {
return "utf-8"
}
}
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const { url } = await req.json() const { url } = await req.json()
@@ -31,21 +56,31 @@ export async function POST(req: Request) {
// SSRF protection: parse-url has no use case for fetching internal // SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only // hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat). // governs LLM provider baseUrl overrides (validate-model, chat).
if (isPrivateUrl(url)) { if (await isPrivateUrl(url)) {
return NextResponse.json( return NextResponse.json(
{ error: "Cannot access private/internal URLs" }, { error: "Cannot access private/internal URLs" },
{ status: 400 }, { status: 400 },
) )
} }
const headController = new AbortController() // Fetch the page ourselves so we control redirect handling. The
const headTimeout = setTimeout(() => headController.abort(), 3000) // article-extractor library follows redirects internally and ignores a
// `redirect` option, which would let a public URL 302 to an internal
// host and bypass the SSRF check above. `redirect: "error"` rejects any
// redirect outright.
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let html: string
try { try {
const headResponse = await fetch(url, { const response = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT }, headers: { "User-Agent": USER_AGENT },
signal: headController.signal, redirect: "error",
signal: controller.signal,
}) })
const contentType = headResponse.headers.get("content-type")
const contentType = response.headers.get("content-type")
if (contentType?.includes("application/pdf")) { if (contentType?.includes("application/pdf")) {
return NextResponse.json( return NextResponse.json(
{ {
@@ -54,27 +89,17 @@ export async function POST(req: Request) {
{ status: 422 }, { status: 422 },
) )
} }
} catch (err) {
console.warn( if (!response.ok) {
"HEAD pre-check failed, proceeding with extraction:", return NextResponse.json(
err, { error: "Could not fetch URL content" },
{ status: 400 },
) )
} finally {
clearTimeout(headTimeout)
} }
// Extract article content with timeout to avoid tying up server resources const buffer = await response.arrayBuffer()
const controller = new AbortController() const charset = detectCharset(contentType, buffer)
const timeoutId = setTimeout(() => { html = new TextDecoder(charset).decode(buffer)
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let article
try {
article = await extract(url, undefined, {
headers: { "User-Agent": USER_AGENT },
signal: controller.signal,
})
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") { if (err?.name === "AbortError") {
return NextResponse.json( return NextResponse.json(
@@ -82,11 +107,25 @@ export async function POST(req: Request) {
{ status: 504 }, { status: 504 },
) )
} }
throw err // Redirects are rejected with a TypeError ("failed to fetch" /
// "unexpected redirect") when redirect: "error" is set.
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
} finally { } finally {
clearTimeout(timeoutId) clearTimeout(timeoutId)
} }
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
// so map any parse error to the same 400 as the no-content case.
let article: Awaited<ReturnType<typeof extractFromHtml>>
try {
article = await extractFromHtml(html, url)
} catch {
article = null
}
if (!article || !article.content) { if (!article || !article.content) {
return NextResponse.json( return NextResponse.json(
{ error: "Could not extract content from URL" }, { error: "Could not extract content from URL" },

View File

@@ -5,11 +5,16 @@ import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex" import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai" import { createOpenAI } from "@ai-sdk/openai"
import { createAihubmix } from "@aihubmix/ai-sdk-provider"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai" import { generateText } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { createOllama } from "ollama-ai-provider-v2" import { createOllama } from "ollama-ai-provider-v2"
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers" import {
AIHUBMIX_APP_CODE,
isAihubmixStandardBaseURL,
normalizeMiniMaxBaseURL,
} from "@/lib/ai-providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
@@ -51,7 +56,7 @@ export async function POST(req: Request) {
} }
// SECURITY: Block SSRF attacks via custom baseUrl // SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) { if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Invalid base URL" }, { valid: false, error: "Invalid base URL" },
{ status: 400 }, { status: 400 },
@@ -153,6 +158,28 @@ export async function POST(req: Request) {
break break
} }
case "aihubmix": {
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseUrl) ||
baseUrl === defaultBaseURL
) {
const aihubmix = createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
model = aihubmix(modelId)
} else {
const aihubmixCompatible = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = aihubmixCompatible.chat(modelId)
}
break
}
case "deepseek": { case "deepseek": {
if (baseUrl || apiKey) { if (baseUrl || apiKey) {
const ds = createDeepSeek({ const ds = createDeepSeek({
@@ -345,12 +372,14 @@ export async function POST(req: Request) {
break break
} }
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible // GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
case "glm": case "glm":
case "qwen": case "qwen":
case "kimi": case "kimi":
case "qiniu": case "qiniu":
case "novita": { case "novita":
case "atlascloud":
case "mimo": {
const baseURL = const baseURL =
baseUrl || baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl || PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||

View File

@@ -6,7 +6,8 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false "ignoreUnknown": false,
"includes": ["**", "!public"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,

View File

@@ -830,10 +830,6 @@ export default function ChatPanel({
let chartXml = await onFetchChart() let chartXml = await onFetchChart()
chartXml = formatXML(chartXml) chartXml = formatXML(chartXml)
// Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml
// Build user text by concatenating input with pre-extracted text // Build user text by concatenating input with pre-extracted text
// (Backend only reads first text part, so we must combine them) // (Backend only reads first text part, so we must combine them)
const parts: any[] = [] const parts: any[] = []

File diff suppressed because it is too large Load Diff

View File

@@ -158,7 +158,7 @@ export function ModelSelector({
}, []) }, [])
return ( return (
<div ref={wrapperRef} className="inline-block"> <div ref={wrapperRef} className="min-w-0 max-w-48">
<ModelSelectorRoot open={open} onOpenChange={setOpen}> <ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild> <ModelSelectorTrigger asChild>
<ButtonWithTooltip <ButtonWithTooltip
@@ -167,7 +167,7 @@ export function ModelSelector({
size="sm" size="sm"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out", "h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
!showLabel && "px-1.5 justify-center", !showLabel && "px-1.5 justify-center",
)} )}
// accessibility: expose label to screen readers // accessibility: expose label to screen readers
@@ -176,7 +176,7 @@ export function ModelSelector({
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> <Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */} {/* show/hide visible label based on measured width */}
{showLabel ? ( {showLabel ? (
<span className="text-xs truncate"> <span className="min-w-0 truncate text-xs">
{selectedModel {selectedModel
? selectedModel.modelId ? selectedModel.modelId
: dict.modelConfig.default} : dict.modelConfig.default}

View File

@@ -0,0 +1,264 @@
"use client"
import { Key, Link2, Tag } from "lucide-react"
import type { ReactNode } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
// renderSecret. That (and the optional test action) are the only genuine
// differences between the two screens; the field structure is shared here.
export type SecretField =
| "apiKey"
| "awsAccessKeyId"
| "awsSecretAccessKey"
| "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens)
const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"],
["us-west-2", "Oregon"],
["eu-west-1", "Ireland"],
["eu-west-2", "London"],
["eu-west-3", "Paris"],
["eu-central-1", "Frankfurt"],
["ap-south-1", "Mumbai"],
["ap-northeast-1", "Tokyo"],
["ap-northeast-2", "Seoul"],
["ap-southeast-1", "Singapore"],
["ap-southeast-2", "Sydney"],
["sa-east-1", "São Paulo"],
]
interface ProviderCredentialsFieldsProps {
provider: ProviderName
// Plain (non-secret) field values — secrets are owned by renderSecret
name?: string
baseUrl?: string
awsRegion?: string
disabled?: boolean
// Update a plain text field
onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void
// Render the control for a secret field. The caller may include trailing
// UI (e.g. the user dialog's inline Test button + validation error); the
// shared component only supplies the label above it.
renderSecret: (opts: { field: SecretField; id: string }) => ReactNode
// Extra content after the fields — used for the Bedrock test row and the
// EdgeOne test button, which aren't beside a credential input.
footer?: ReactNode
}
// Display name + per-provider credential inputs, shared by the user
// ModelConfigDialog and the admin Models panel.
export function ProviderCredentialsFields({
provider,
name,
baseUrl,
awsRegion,
disabled,
onChange,
renderSecret,
footer,
}: ProviderCredentialsFieldsProps) {
const dict = useDictionary()
const info = PROVIDER_INFO[provider]
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1",
})
// EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") {
return <div className="space-y-5">{footer}</div>
}
return (
<div className="space-y-5">
{/* Display Name */}
<div className="space-y-2">
<Label
htmlFor="provider-name"
className="text-xs font-medium flex items-center gap-1.5"
>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.displayName}
</Label>
<Input
id="provider-name"
value={name ?? ""}
disabled={disabled}
onChange={(e) => onChange("name", e.target.value)}
placeholder={info.label}
className="h-9"
/>
</div>
{provider === "bedrock" ? (
<>
{/* AWS Access Key ID */}
<div className="space-y-2">
<Label
htmlFor="aws-access-key-id"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsAccessKeyId}
</Label>
{renderSecret({
field: "awsAccessKeyId",
id: "aws-access-key-id",
})}
</div>
{/* AWS Secret Access Key */}
<div className="space-y-2">
<Label
htmlFor="aws-secret-access-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsSecretAccessKey}
</Label>
{renderSecret({
field: "awsSecretAccessKey",
id: "aws-secret-access-key",
})}
</div>
{/* AWS Region */}
<div className="space-y-2">
<Label
htmlFor="aws-region"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.awsRegion}
</Label>
<Select
value={awsRegion || ""}
disabled={disabled}
onValueChange={(v) => onChange("awsRegion", v)}
>
<SelectTrigger
id="aws-region"
className="h-9 font-mono text-xs hover:bg-accent"
>
<SelectValue
placeholder={dict.modelConfig.selectRegion}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{AWS_REGIONS.map(([region, label]) => (
<SelectItem key={region} value={region}>
{region} ({label})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
) : provider === "vertexai" ? (
<>
{/* Vertex AI API Key (Express Mode) */}
<div className="space-y-2">
<Label
htmlFor="vertex-api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
</Label>
{renderSecret({
field: "vertexApiKey",
id: "vertex-api-key",
})}
</div>
{/* Base URL (optional) */}
<div className="space-y-2">
<Label
htmlFor="vertex-base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="vertex-base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
placeholder={dict.modelConfig.customEndpoint}
className="h-9 font-mono text-xs"
/>
</div>
</>
) : (
<>
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{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)
}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{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>
)
}

View File

@@ -0,0 +1,36 @@
import { Cloud, Server, Sparkles } from "lucide-react"
import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config"
import { cn } from "@/lib/utils"
// Provider logo from models.dev, with Lucide fallbacks for providers
// that have no logo there
export function ProviderLogo({
provider,
className,
}: {
provider: ProviderName
className?: string
}) {
if (provider === "bedrock") {
return <Cloud className={cn("size-4", className)} />
}
if (provider === "sglang") {
return <Server className={cn("size-4", className)} />
}
if (provider === "doubao") {
return <Sparkles className={cn("size-4", className)} />
}
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt=""
aria-hidden="true"
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${logoName}.svg`}
width={16}
/>
)
}

View File

@@ -2,7 +2,7 @@
import type React from "react" import type React from "react"
import { createContext, useContext, useEffect, useRef, useState } from "react" import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio" import type { DrawIoEmbedRef, EventExport } from "react-drawio"
import { toast } from "sonner" import { toast } from "sonner"
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"
@@ -22,7 +22,7 @@ interface DiagramContextType {
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null> resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null> drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: EventExport) => void
handleDiagramAutoSave: (data: { xml?: string }) => void handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
@@ -83,7 +83,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null resolver: ((data: string, fullDiagramXML?: string) => void) | null
format: ExportFormat | null format: ExportFormat | null
}>({ resolver: null, format: null }) }>({ resolver: null, format: null })
@@ -204,7 +204,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
return null return null
} }
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: EventExport) => {
// Handle PNG export for VLM validation // Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) { if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data) pngResolverRef.current(data.data)
@@ -215,7 +215,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Handle save to file if requested (process raw data before extraction) // Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) { if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data) saveResolverRef.current.resolver(data.data, data.xml)
saveResolverRef.current = { resolver: null, format: null } saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail // For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute // Only drawio (which uses xmlsvg internally) has the content attribute
@@ -225,8 +225,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
const extractedXML = extractDiagramXML(data.data) const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML)
setLatestSvg(data.data) setLatestSvg(data.data)
// Only add to history if this was a user-initiated export // Only add to history if this was a user-initiated export
@@ -288,14 +291,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Set up the resolver before triggering export // Set up the resolver before triggering export
saveResolverRef.current = { saveResolverRef.current = {
resolver: (exportData: string) => { resolver: (exportData: string, fullDiagramXML?: string) => {
let fileContent: string | Blob let fileContent: string | Blob
let mimeType: string let mimeType: string
let extension: string let extension: string
if (format === "drawio") { if (format === "drawio") {
// Extract XML from SVG for .drawio format // Prefer the complete document from the export event so all pages are saved.
const xml = extractDiagramXML(exportData) const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
let xmlContent = xml let xmlContent = xml
if (!xml.includes("<mxfile")) { if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>` xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`

View File

@@ -11,6 +11,9 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# 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

View File

@@ -204,6 +204,7 @@ npm run dev
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
- AIHubMix
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope - ModelScope
@@ -216,12 +217,18 @@ npm run dev
### 服务端多模型配置 ### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置。 管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
**模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。 **模型要求**此任务需要强大的模型能力因为它涉及生成具有严格格式约束的长文本draw.io XML。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。 注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理 ## 工作原理

24
docs/cn/admin-panel.md Normal file
View File

@@ -0,0 +1,24 @@
# 管理面板
无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。
## 启用面板
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
2. 访问 `/admin` 并登录。
## 可配置内容
1. **Models模型** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
## 优先级
面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
## 注意事项
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600请妥善保管该文件。
- 在无服务器平台Vercel、Cloudflare Workers上没有持久化磁盘面板为只读 — 请改用环境变量配置。
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。

View File

@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1 OPENAI_BASE_URL=https://your-custom-endpoint/v1
``` ```
### AIHubMix
AIHubMix 通过单个 API Key 聚合 Claude、GPT、Gemini、DeepSeek 等模型。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
可选的自定义端点:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic ### Anthropic
```bash ```bash
@@ -293,6 +308,19 @@ AI_MODEL=your_model_id
QINIU_BASE_URL=https://your-custom-endpoint QINIU_BASE_URL=https://your-custom-endpoint
``` ```
### MiMo (小米)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
可选的自定义端点Token Plan 订阅用户请设置专属 Base URL
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自动检测 ## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER` 如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`
@@ -300,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER` 如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`
```bash ```bash
AI_PROVIDER=google # 或openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu AI_PROVIDER=google # 或openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
``` ```
## 服务端多模型配置 ## 服务端多模型配置
@@ -321,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。 在项目根目录创建 `ai-models.json` 文件(或通过 `AI_MODELS_CONFIG_PATH` 指定路径)。
**方式三:`AI_MODEL` 用逗号分隔**(单 provider 的快速配置)
如果只需要暴露同一 provider 下的多个模型,可以直接在 `AI_MODEL` 里用逗号分隔。第一个模型会作为默认值。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
这是等价 `ai-models.json` 的简写形式。如果需要配置多个 provider或自定义 `apiKeyEnv` / `baseUrlEnv`,请使用方式一或方式二。
### 配置示例 ### 配置示例
```json ```json

24
docs/en/admin-panel.md Normal file
View File

@@ -0,0 +1,24 @@
# Admin Panel
Instead of hand-editing `.env`, you can manage server settings in a web admin panel.
## Enabling the panel
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
2. Visit `/admin` and sign in.
## What you can configure
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
## Precedence
Settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable.
## Notes
- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private.
- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there.
- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`.
- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel.

View File

@@ -61,6 +61,21 @@ Optional custom endpoint (for OpenAI-compatible services):
OPENAI_BASE_URL=https://your-custom-endpoint/v1 OPENAI_BASE_URL=https://your-custom-endpoint/v1
``` ```
### AIHubMix
AIHubMix provides access to Claude, GPT, Gemini, DeepSeek, and other models through a single API key.
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
Optional custom endpoint:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic ### Anthropic
```bash ```bash
@@ -308,6 +323,19 @@ Optional custom endpoint:
QINIU_BASE_URL=https://your-custom-endpoint QINIU_BASE_URL=https://your-custom-endpoint
``` ```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## Auto-Detection ## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`. If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -315,7 +343,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
``` ```
## Server-Side Multi-Model Configuration ## Server-Side Multi-Model Configuration
@@ -336,6 +364,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location). Create an `ai-models.json` file in the project root (or set `AI_MODELS_CONFIG_PATH` to a custom location).
**Option 3: Comma-separated `AI_MODEL`** (quick setup, single provider)
If you only need multiple models from one provider, list them in `AI_MODEL` separated by commas. The first model is treated as the default.
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
This is shorthand for the equivalent `ai-models.json`. For multiple providers or custom `apiKeyEnv` / `baseUrlEnv`, use Option 1 or 2 instead.
### Example Configuration ### Example Configuration
```json ```json

View File

@@ -203,6 +203,7 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- Azure OpenAI - Azure OpenAI
- Ollama - Ollama
- OpenRouter - OpenRouter
- AIHubMix
- DeepSeek - DeepSeek
- SiliconFlow - SiliconFlow
- ModelScope - ModelScope
@@ -215,12 +216,18 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### サーバーサイドマルチモデル設定 ### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。 管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数JSON文字列または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
**モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。 **モデル要件**このタスクは厳密なフォーマット制約draw.io XMLを持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。 注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み ## 仕組み

24
docs/ja/admin-panel.md Normal file
View File

@@ -0,0 +1,24 @@
# 管理パネル
`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。
## パネルの有効化
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
2. `/admin` にアクセスしてサインインします。
## 設定できる項目
1. **Modelsモデル** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要ですLangfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
## 優先順位
パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
## 注意事項
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600。このファイルは非公開に保ってください。
- サーバーレスプラットフォームVercel、Cloudflare Workersには永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。
- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。
- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。

View File

@@ -46,6 +46,21 @@ AI_MODEL=gpt-4o
OPENAI_BASE_URL=https://your-custom-endpoint/v1 OPENAI_BASE_URL=https://your-custom-endpoint/v1
``` ```
### AIHubMix
AIHubMix は、単一の API キーで Claude、GPT、Gemini、DeepSeek などのモデルへのアクセスを提供します。
```bash
AIHUBMIX_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250929
```
任意のカスタムエンドポイント:
```bash
AIHUBMIX_BASE_URL=https://aihubmix.com/v1
```
### Anthropic ### Anthropic
```bash ```bash
@@ -293,6 +308,19 @@ AI_MODEL=your_model_id
QINIU_BASE_URL=https://your-custom-endpoint QINIU_BASE_URL=https://your-custom-endpoint
``` ```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
オプションのカスタムエンドポイントToken Plan 加入者は専用の Base URL を設定してください):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自動検出 ## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。 **1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -300,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります: **複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash ```bash
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
``` ```
## サーバーサイドマルチモデル設定 ## サーバーサイドマルチモデル設定
@@ -321,6 +349,17 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。 プロジェクトルートに `ai-models.json` ファイルを作成します(または `AI_MODELS_CONFIG_PATH` でパスを指定)。
**方法3`AI_MODEL` をカンマ区切りで指定**(単一プロバイダーの簡易設定)
同一プロバイダー内の複数モデルだけを公開したい場合は、`AI_MODEL` にカンマ区切りで列挙できます。最初のモデルがデフォルトになります。
```bash
AI_PROVIDER=doubao
AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
```
これは等価な `ai-models.json` の簡易表記です。複数のプロバイダーや、カスタム `apiKeyEnv` / `baseUrlEnv` を使う場合は、方法1または方法2を使ってください。
### 設定例 ### 設定例
```json ```json

View File

@@ -1,12 +1,19 @@
# AI Provider Configuration # AI Provider Configuration
# AI_PROVIDER: Which provider to use # AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, deepseek, siliconflow, gateway, novita # Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
# Default: bedrock # Default: bedrock
AI_PROVIDER=bedrock AI_PROVIDER=bedrock
# AI_MODEL: The model ID for your chosen provider (REQUIRED) # AI_MODEL: The model ID for your chosen provider (REQUIRED)
# Tip: For a single-provider quick multi-model setup, list comma-separated model IDs.
# The first one becomes the default and the rest appear in the model picker.
# For multiple providers or custom apiKeyEnv/baseUrlEnv, use AI_MODELS_CONFIG / ai-models.json instead.
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Output limit, all providers (default: 16000). Raise it if large diagrams arrive cut off.
# MAX_OUTPUT_TOKENS=16000
# AWS Bedrock Configuration # AWS Bedrock Configuration
# AWS_REGION=us-east-1 # AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your-access-key-id # AWS_ACCESS_KEY_ID=your-access-key-id
@@ -69,6 +76,10 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENROUTER_API_KEY=sk-or-v1-... # OPENROUTER_API_KEY=sk-or-v1-...
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # Optional: Custom endpoint
# AIHubMix Configuration
# AIHUBMIX_API_KEY=your-aihubmix-api-key
# AIHUBMIX_BASE_URL=https://aihubmix.com/v1 # Optional: Custom endpoint
# DeepSeek Configuration # DeepSeek Configuration
# DEEPSEEK_API_KEY=sk-... # DEEPSEEK_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint # DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
@@ -116,6 +127,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Access Control (Optional) # Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code # ACCESS_CODE_LIST=your-secret-code,another-code
# Admin Panel (Optional)
# Set a password to enable the web admin panel at /admin, where most of the
# settings in this file can be edited at runtime (stored in data/settings.json,
# which takes precedence over environment variables).
# Leave unset to disable the admin panel entirely.
# ADMIN_PASSWORD=your-admin-password
# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location
# Draw.io Configuration (Optional) # Draw.io Configuration (Optional)
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net # NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
# Use this to point to a self-hosted draw.io instance # Use this to point to a self-hosted draw.io instance
@@ -173,3 +192,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Get your API key from: https://novita.ai/dashboard/key # Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key # NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default # NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
# MiMo (Xiaomi) Configuration (Optional)
# Get your API key from: https://platform.xiaomimimo.com/
# MIMO_API_KEY=your_mimo_api_key
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
# Atlas Cloud Configuration (Optional)
# Get your API key from: https://www.atlascloud.ai/console/api-keys
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.

View File

@@ -1,7 +1,17 @@
import { LangfuseSpanProcessor } from "@langfuse/otel" import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
export function register() { export async function register() {
// Overlay admin settings file onto process.env before anything reads config
if (process.env.NEXT_RUNTIME === "nodejs") {
try {
const { applyToEnv } = await import("@/lib/admin/settings")
applyToEnv()
} catch (err) {
console.error("[admin-settings] Failed to apply settings:", err)
}
}
// Skip telemetry if Langfuse env vars are not configured // Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn( console.warn(

37
lib/admin/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import { timingSafeEqual } from "crypto"
// Shared auth for admin API routes: compares x-admin-password header
// against the ADMIN_PASSWORD env var. Unset password = panel disabled.
export function checkAdminAuth(req: Request): Response | null {
const password = process.env.ADMIN_PASSWORD
if (!password) {
return Response.json(
{
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
},
{ status: 403 },
)
}
const provided = req.headers.get("x-admin-password") || ""
const a = Buffer.from(provided)
const b = Buffer.from(password)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json(
{ error: "Invalid admin password" },
{ status: 401 },
)
}
return null
}
export interface MaskedSecret {
isSet: true
hint: string
}
export function maskSecret(value: string): MaskedSecret {
return {
isSet: true,
hint: value.length > 8 ? `${value.slice(-4)}` : "••••",
}
}

303
lib/admin/providers.ts Normal file
View File

@@ -0,0 +1,303 @@
import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
} from "@/lib/server-model-config"
import {
FIXED_CRED_PROVIDERS,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
import { type MaskedSecret, maskSecret } from "./auth"
import { loadSettings } from "./settings"
// Admin-configured providers, mirroring the user ModelConfigDialog's data
// model but stored server-side (settings.json, ADMIN_PROVIDERS key).
//
// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json:
// loadRawServerModelsConfig() merges the env baseline with the panel's
// providers at read time, so .env stays authoritative for its own entries.
// Panel credentials are written to ADMIN_-prefixed env vars (wired up via
// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like
// OPENAI_API_KEY that env-based entries may rely on.
export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS"
// A secret field in transit: plaintext string (new value) or an
// {isSet} marker meaning "keep the stored value".
const SecretInputSchema = z
.union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })])
.optional()
export const AdminProviderSchema = z.object({
id: z.string().min(1),
provider: ProviderNameSchema,
name: z.string().optional(),
apiKey: SecretInputSchema,
baseUrl: z.string().optional(),
awsAccessKeyId: SecretInputSchema,
awsSecretAccessKey: SecretInputSchema,
awsRegion: z.string().optional(),
vertexApiKey: SecretInputSchema,
models: z.array(z.string().min(1)),
isDefault: z.boolean().optional(),
})
export const AdminProvidersSchema = z.array(AdminProviderSchema)
// Stored shape: secrets are plain strings (never {isSet} markers, which
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
// hand-edited/corrupted value can't slip a marker object past maskSecret.
const StoredAdminProviderSchema = AdminProviderSchema.extend({
apiKey: z.string().optional(),
awsAccessKeyId: z.string().optional(),
awsSecretAccessKey: z.string().optional(),
vertexApiKey: z.string().optional(),
})
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
// Stored form: secrets are plain strings
export interface StoredAdminProvider {
id: string
provider: ProviderName
name?: string
apiKey?: string
baseUrl?: string
awsAccessKeyId?: string
awsSecretAccessKey?: string
awsRegion?: string
vertexApiKey?: string
models: string[]
isDefault?: boolean
}
const SECRET_FIELDS = [
"apiKey",
"awsAccessKeyId",
"awsSecretAccessKey",
"vertexApiKey",
] as const
// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider
function credEnvNames(
provider: ProviderName,
index: number,
): { key?: string; url?: string } {
if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") {
return {}
}
const prefix =
provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase()
const suffix = index === 0 ? "" : `_${index + 1}`
return {
key: `ADMIN_${prefix}_API_KEY${suffix}`,
url: `ADMIN_${prefix}_BASE_URL${suffix}`,
}
}
export function loadAdminProviders(): StoredAdminProvider[] {
const raw = loadSettings()[ADMIN_PROVIDERS_KEY]
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
// Validate each entry's shape — a malformed/hand-edited value must
// not reach runtime code that assumes provider/models exist.
return parsed.flatMap((entry) => {
const result = StoredAdminProviderSchema.safeParse(entry)
return result.success ? [result.data as StoredAdminProvider] : []
})
} catch {
console.error("[admin-providers] Failed to parse stored providers")
return []
}
}
export type MaskedAdminProvider = Omit<
StoredAdminProvider,
(typeof SECRET_FIELDS)[number]
> & {
apiKey?: MaskedSecret
awsAccessKeyId?: MaskedSecret
awsSecretAccessKey?: MaskedSecret
vertexApiKey?: MaskedSecret
}
export function maskAdminProviders(
list: StoredAdminProvider[],
): MaskedAdminProvider[] {
return list.map((p) => {
const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
masked[field] = value ? maskSecret(value) : undefined
}
return masked
})
}
// Resolve {isSet} markers in incoming secrets against the stored list
export function mergeSecrets(
incoming: AdminProviderInput[],
stored: StoredAdminProvider[],
): StoredAdminProvider[] {
const storedById = new Map(stored.map((p) => [p.id, p]))
return incoming.map((p) => {
const prev = storedById.get(p.id)
const merged = { ...p } as StoredAdminProvider
for (const field of SECRET_FIELDS) {
const value = p[field]
if (typeof value === "string") {
merged[field] = value || undefined
} else if (value?.isSet) {
merged[field] = prev?.[field]
} else {
merged[field] = undefined
}
}
return merged
})
}
function displayName(p: StoredAdminProvider): string {
return p.name?.trim() || PROVIDER_INFO[p.provider].label
}
export function validateAdminProviders(
list: StoredAdminProvider[],
envConfig: ServerModelsConfig | null = null,
): string | null {
const envProviders = envConfig?.providers ?? []
for (const single of FIXED_CRED_PROVIDERS) {
if (list.filter((p) => p.provider === single).length > 1) {
return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).`
}
// Its credentials are global; a panel instance would silently
// override the credentials env-configured models rely on
if (
list.some((p) => p.provider === single) &&
envProviders.some((p) => p.provider === single)
) {
return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.`
}
}
const names = list.map((p) => displayName(p))
if (new Set(names).size !== names.length) {
return "Provider display names must be unique."
}
const envNames = new Set(envProviders.map((p) => p.name))
const clash = names.find((n) => envNames.has(n))
if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
}
if (list.filter((p) => p.isDefault).length > 1) {
return "Only one provider can be the default."
}
return null
}
// The panel's contribution to the server models config, derived at read
// time and merged with the env baseline by loadRawServerModelsConfig().
export function adminProvidersToConfig(
list: StoredAdminProvider[],
): ServerModelsConfig {
const config: ServerModelsConfig = { providers: [] }
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.models.length === 0) continue
const env = credEnvNames(p.provider, index)
config.providers.push({
name: displayName(p),
provider: p.provider,
models: p.models,
...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}),
...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}),
...(p.isDefault ? { default: true } : {}),
})
}
return config
}
// Settings updates derived from the provider list: credential env vars,
// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set.
// Keys derived from `previous` but absent now are set to null (removed,
// falling back to the environment).
export function deriveEnvUpdates(
list: StoredAdminProvider[],
previous: StoredAdminProvider[],
): Record<string, string | null> {
const updates: Record<string, string | null> = {}
// Clear everything the previous list owned, then overwrite below
for (const key of derivedEnvKeys(previous)) updates[key] = null
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
if (p.awsSecretAccessKey)
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
} else if (p.provider === "ollama") {
if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey
if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl
} else {
const env = credEnvNames(p.provider, index)
if (env.key && p.apiKey) updates[env.key] = p.apiKey
if (env.url && p.baseUrl) updates[env.url] = p.baseUrl
}
}
updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null
// The panel's default also becomes the server-wide default model;
// without one, the env-configured default applies.
const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0)
if (defaultEntry) {
updates.AI_PROVIDER = defaultEntry.provider
updates.AI_MODEL = defaultEntry.models[0]
}
return updates
}
// Every settings key the panel may have written for a given list.
// AI_MODELS_CONFIG is included to clean up values written by older
// versions of the panel (it is no longer written).
function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const keys = new Set<string>([
"AI_MODELS_CONFIG",
"AI_PROVIDER",
"AI_MODEL",
])
const indexByProvider = new Map<ProviderName, number>()
for (const p of list) {
const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") {
keys.add("AWS_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION")
} else if (p.provider === "vertexai") {
keys.add("GOOGLE_VERTEX_API_KEY")
keys.add("GOOGLE_VERTEX_BASE_URL")
} else if (p.provider === "ollama") {
keys.add("OLLAMA_API_KEY")
keys.add("OLLAMA_BASE_URL")
} else {
const env = credEnvNames(p.provider, index)
if (env.key) keys.add(env.key)
if (env.url) keys.add(env.url)
}
}
return [...keys]
}

View File

@@ -0,0 +1,229 @@
// Declarative registry of the general env vars editable in the admin panel.
// Drives both server-side validation (app/api/admin/settings) and UI
// rendering (app/[lang]/admin). Keys are exactly the env var names.
//
// AI providers and models are managed separately in the panel's Models
// section (lib/admin/providers.ts), not here.
//
// Not listed here (and therefore rejected by the API):
// - NEXT_PUBLIC_* vars: baked into the client bundle at build time
// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout
// - Per-provider reasoning/thinking tuning vars: env-only (see env.example)
export type SettingType = "string" | "secret" | "number" | "boolean" | "enum"
export interface SettingDef {
key: string
group: string
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
// Built-in default applied at runtime when the value is unset, so the UI
// can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true").
default?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
}
export interface SettingGroup {
id: string
title: string
description: string
// Optional sections gated by an on/off switch in the panel; fields are
// grayed out until enabled. Starts on when any field is already set.
toggleable?: boolean
}
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "generation",
title: "Generation",
description: "Output parameters applied to all chat requests.",
},
{
id: "access",
title: "Access Control",
description: "Restrict who can use this deployment.",
},
{
id: "features",
title: "Features",
description: "Optional features and security toggles.",
},
{
id: "observability",
title: "Observability",
description: "Langfuse tracing for LLM calls.",
toggleable: true,
},
{
id: "quota",
title: "Quota & Rate Limits",
description:
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
toggleable: true,
},
]
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── Generation ───────────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "generation",
type: "number",
label: "Temperature",
description:
"Leave unset for reasoning models that reject temperature.",
min: 0,
max: 2,
},
{
key: "MAX_OUTPUT_TOKENS",
group: "generation",
type: "number",
label: "Max Output Tokens",
min: 1,
},
// ── Access Control ───────────────────────────────────────────────
{
key: "ACCESS_CODE_LIST",
group: "access",
type: "string",
label: "Access Codes",
description:
"Comma-separated list. Users must enter one to chat. Empty = open access.",
placeholder: "code1,code2",
},
// ── Features ─────────────────────────────────────────────────────
{
key: "ENABLE_VLM_VALIDATION",
group: "features",
type: "boolean",
label: "VLM Diagram Validation",
description:
"Visually validate generated diagrams with a vision model.",
},
{
key: "VALIDATION_MODEL",
group: "features",
type: "string",
label: "Validation Model",
description: "Falls back to the default AI model when empty.",
},
{
key: "VALIDATION_TIMEOUT",
group: "features",
type: "number",
label: "Validation Timeout (ms)",
min: 1000,
},
{
key: "ENABLE_HISTORY_XML_REPLACE",
group: "features",
type: "boolean",
label: "History XML Compression",
description: "Replace old diagram XML in history with placeholders.",
},
{
key: "ALLOW_PRIVATE_URLS",
group: "features",
type: "boolean",
label: "Allow Private URLs",
description:
"Turn off to block requests to private IPs and internal hostnames (SSRF protection).",
// Unset means allowed at runtime (ssrf-protection: !== "false")
default: "true",
},
// ── Observability ────────────────────────────────────────────────
{
key: "LANGFUSE_PUBLIC_KEY",
group: "observability",
type: "string",
label: "Langfuse Public Key",
placeholder: "pk-lf-…",
restartRequired: true,
},
{
key: "LANGFUSE_SECRET_KEY",
group: "observability",
type: "secret",
label: "Langfuse Secret Key",
restartRequired: true,
},
{
key: "LANGFUSE_BASEURL",
group: "observability",
type: "string",
label: "Langfuse Base URL",
placeholder: "https://cloud.langfuse.com",
restartRequired: true,
},
// ── Quota ────────────────────────────────────────────────────────
{
key: "DAILY_REQUEST_LIMIT",
group: "quota",
type: "number",
label: "Daily Request Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "DAILY_TOKEN_LIMIT",
group: "quota",
type: "number",
label: "Daily Token Limit",
description: "Per IP per day.",
min: 1,
},
{
key: "TPM_LIMIT",
group: "quota",
type: "number",
label: "Tokens Per Minute",
min: 1,
},
{
key: "DYNAMODB_QUOTA_TABLE",
group: "quota",
type: "string",
label: "DynamoDB Table",
description: "Quota enforcement is disabled when empty.",
restartRequired: true,
},
{
key: "DYNAMODB_REGION",
group: "quota",
type: "string",
label: "DynamoDB Region",
placeholder: "ap-northeast-1",
restartRequired: true,
},
{
key: "QUOTA_TIMEZONE",
group: "quota",
type: "string",
label: "Quota Timezone",
description: "Timezone for the daily reset boundary.",
placeholder: "UTC",
restartRequired: true,
},
]
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)
export const SETTINGS_BY_GROUP: Map<string, SettingDef[]> = new Map(
SETTING_GROUPS.map((g) => [
g.id,
SETTINGS_REGISTRY.filter((d) => d.group === g.id),
]),
)

134
lib/admin/settings.ts Normal file
View File

@@ -0,0 +1,134 @@
import fs from "fs"
import path from "path"
// File-based admin settings, overlaid onto process.env (dotenv-style).
// Precedence: settings file > env var > built-in default.
// Keys are exactly the env var names.
interface SettingsFile {
version: 1
values: Record<string, string>
}
// Original env values snapshotted before the first overlay, so removing a
// key from the settings file restores the env default. null = was unset.
const originalEnv: Record<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
let cachedSettings: Record<string, string> | null = null
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
}
export function loadSettings(): Record<string, string> {
if (cachedSettings) return cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
// Keep only string values — a hand-edited or corrupted file could
// hold null/arrays/numbers that would otherwise be overlaid onto
// process.env and coerce to junk like "[object Object]".
const values: Record<string, string> = {}
const rawValues =
parsed &&
typeof parsed.values === "object" &&
parsed.values &&
!Array.isArray(parsed.values)
? parsed.values
: {}
for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value
}
cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
cachedSettings = {}
}
return cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
// Restore env for keys that were overlaid before but are now gone
for (const key of overlaidKeys) {
if (!(key in values)) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else process.env[key] = original
}
}
for (const [key, value] of Object.entries(values)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key] ?? null
}
process.env[key] = value
}
overlaidKeys = new Set(Object.keys(values))
}
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
return process.env[key] ?? null
}
// Whether a key's current value comes from the file, the environment, or is unset
export function getValueSource(key: string): "file" | "env" | "default" {
if (key in loadSettings()) return "file"
return getEnvFallback(key) !== null ? "env" : "default"
}
export function saveSettings(updates: Record<string, string | null>): void {
const current = { ...loadSettings() }
for (const [key, value] of Object.entries(updates)) {
if (value === null) delete current[key]
else current[key] = value
}
const filePath = getSettingsPath()
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const tmpPath = `${filePath}.tmp`
const data: SettingsFile = { version: 1, values: current }
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath)
cachedSettings = current
applyToEnv()
}
let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
} catch {
writableCache = false
}
return writableCache
}
// Test-only: reset module state
export function _resetForTests(): void {
cachedSettings = null
writableCache = null
for (const key of overlaidKeys) {
const original = originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
overlaidKeys = new Set()
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
}

View File

@@ -6,6 +6,7 @@ import { createGateway, gateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI, google } from "@ai-sdk/google" import { createGoogleGenerativeAI, google } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex" import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI, openai } from "@ai-sdk/openai" import { createOpenAI, openai } from "@ai-sdk/openai"
import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers" import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import { createOpenRouter } from "@openrouter/ai-sdk-provider" import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { createOllama, ollama } from "ollama-ai-provider-v2" import { createOllama, ollama } from "ollama-ai-provider-v2"
@@ -13,6 +14,8 @@ import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export type { ProviderName } export type { ProviderName }
export const AIHUBMIX_APP_CODE = "MSBS9675"
interface ModelConfig { interface ModelConfig {
model: any model: any
providerOptions?: any providerOptions?: any
@@ -29,6 +32,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
"kimi", "kimi",
"qiniu", "qiniu",
"novita", "novita",
"mimo",
]) ])
/** /**
@@ -57,6 +61,18 @@ export function normalizeMiniMaxBaseURL(rawUrl: string): {
return { baseURL, isAnthropicCompatible } return { baseURL, isAnthropicCompatible }
} }
export function isAihubmixStandardBaseURL(
rawUrl: string | null | undefined,
): boolean {
if (!rawUrl) return true
const baseURL = rawUrl.replace(/\/+$/, "")
return (
baseURL === "https://aihubmix.com" ||
baseURL === "https://aihubmix.com/v1"
)
}
export interface ClientOverrides { export interface ClientOverrides {
provider?: string | null provider?: string | null
baseUrl?: string | null baseUrl?: string | null
@@ -86,6 +102,7 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"azure", "azure",
"bedrock", "bedrock",
"openrouter", "openrouter",
"aihubmix",
"deepseek", "deepseek",
"siliconflow", "siliconflow",
"sglang", "sglang",
@@ -100,6 +117,8 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
"kimi", "kimi",
"minimax", "minimax",
"novita", "novita",
"mimo",
"atlascloud",
] ]
// Bedrock provider options for Anthropic beta features // Bedrock provider options for Anthropic beta features
@@ -513,6 +532,7 @@ function buildProviderOptions(
case "deepseek": case "deepseek":
case "openrouter": case "openrouter":
case "aihubmix":
case "siliconflow": case "siliconflow":
case "sglang": case "sglang":
case "gateway": case "gateway":
@@ -523,7 +543,9 @@ function buildProviderOptions(
case "qwen": case "qwen":
case "kimi": case "kimi":
case "qiniu": case "qiniu":
case "novita": { case "novita":
case "atlascloud":
case "mimo": {
// These providers don't have reasoning configs in AI SDK yet // These providers don't have reasoning configs in AI SDK yet
// Gateway passes through to underlying providers which handle their own configs // Gateway passes through to underlying providers which handle their own configs
break break
@@ -537,7 +559,7 @@ function buildProviderOptions(
} }
// Map of provider to required environment variable // Map of provider to required environment variable
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = { export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
openai: "OPENAI_API_KEY", openai: "OPENAI_API_KEY",
anthropic: "ANTHROPIC_API_KEY", anthropic: "ANTHROPIC_API_KEY",
@@ -546,6 +568,7 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
azure: "AZURE_API_KEY", azure: "AZURE_API_KEY",
ollama: null, // No credentials needed for local Ollama ollama: null, // No credentials needed for local Ollama
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
aihubmix: "AIHUBMIX_API_KEY",
deepseek: "DEEPSEEK_API_KEY", deepseek: "DEEPSEEK_API_KEY",
siliconflow: "SILICONFLOW_API_KEY", siliconflow: "SILICONFLOW_API_KEY",
sglang: "SGLANG_API_KEY", sglang: "SGLANG_API_KEY",
@@ -559,6 +582,8 @@ const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
kimi: "KIMI_API_KEY", kimi: "KIMI_API_KEY",
minimax: "MINIMAX_API_KEY", minimax: "MINIMAX_API_KEY",
novita: "NOVITA_API_KEY", novita: "NOVITA_API_KEY",
mimo: "MIMO_API_KEY",
atlascloud: "ATLASCLOUD_API_KEY",
} }
/** /**
@@ -662,7 +687,7 @@ function validateProviderCredentials(
* Get the AI model based on environment variables * Get the AI model based on environment variables
* *
* Environment variables: * Environment variables:
* - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, modelscope) * - AI_PROVIDER: The provider to use (bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, modelscope)
* - AI_MODEL: The model ID/name for the selected provider * - AI_MODEL: The model ID/name for the selected provider
* *
* Provider-specific env vars: * Provider-specific env vars:
@@ -674,6 +699,7 @@ function validateProviderCredentials(
* - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials * - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: AWS Bedrock credentials
* - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api) * - OLLAMA_BASE_URL: Ollama server URL (optional, defaults to https://ollama.com/api)
* - OPENROUTER_API_KEY: OpenRouter API key * - OPENROUTER_API_KEY: OpenRouter API key
* - AIHUBMIX_API_KEY: AIHubMix API key
* - DEEPSEEK_API_KEY: DeepSeek API key * - DEEPSEEK_API_KEY: DeepSeek API key
* - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional) * - DEEPSEEK_BASE_URL: DeepSeek endpoint (optional)
* - SILICONFLOW_API_KEY: SiliconFlow API key * - SILICONFLOW_API_KEY: SiliconFlow API key
@@ -710,8 +736,10 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
(overrides?.provider === "vertexai" && overrides?.vertexApiKey)) (overrides?.provider === "vertexai" && overrides?.vertexApiKey))
) )
// Use client override if provided, otherwise fall back to env vars // Use client override if provided, otherwise fall back to env vars.
const modelId = overrides?.modelId || process.env.AI_MODEL // AI_MODEL may be comma-separated (multi-model fallback); pick the first.
const envModel = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
const modelId = overrides?.modelId || envModel
if (!modelId) { if (!modelId) {
if (isClientOverride) { if (isClientOverride) {
@@ -761,6 +789,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` + `- GOOGLE_GENERATIVE_AI_API_KEY for Google\n` +
`- AWS_ACCESS_KEY_ID for Bedrock\n` + `- AWS_ACCESS_KEY_ID for Bedrock\n` +
`- OPENROUTER_API_KEY for OpenRouter\n` + `- OPENROUTER_API_KEY for OpenRouter\n` +
`- AIHUBMIX_API_KEY for AIHubMix\n` +
`- AZURE_API_KEY for Azure\n` + `- AZURE_API_KEY for Azure\n` +
`- SILICONFLOW_API_KEY for SiliconFlow\n` + `- SILICONFLOW_API_KEY for SiliconFlow\n` +
`- SGLANG_API_KEY for SGLang\n` + `- SGLANG_API_KEY for SGLang\n` +
@@ -1003,6 +1032,42 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "aihubmix": {
const apiKey = resolveApiKey(overrides, "AIHUBMIX_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv(
overrides,
"AIHUBMIX_BASE_URL",
)
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
serverBaseUrl,
PROVIDER_INFO.aihubmix.defaultBaseUrl,
)
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseURL) ||
baseURL === defaultBaseURL
) {
const aihubmixProvider =
overrides?.apiKey || apiKey
? createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
: aihubmix
model = aihubmixProvider(modelId)
} else {
const aihubmixCompatibleProvider = createOpenAI({
apiKey,
baseURL,
})
model = aihubmixCompatibleProvider.chat(modelId)
}
break
}
case "deepseek": { case "deepseek": {
const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY") const apiKey = resolveApiKey(overrides, "DEEPSEEK_API_KEY")
const serverBaseUrl = resolveBaseUrlEnv( const serverBaseUrl = resolveBaseUrlEnv(
@@ -1288,10 +1353,28 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
break break
} }
case "mimo": {
const apiKey = resolveApiKey(overrides, "MIMO_API_KEY")
const baseURL = resolveBaseURL(
overrides?.apiKey,
overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"),
PROVIDER_INFO.mimo?.defaultBaseUrl,
)
// Use createDeepSeek to properly handle reasoning_content for MiMo
// thinking models (e.g., mimo-v2.5-pro). MiMo's API requires
// reasoning_content to be passed back during multi-turn tool calls
// (returns 400 otherwise), same convention as DeepSeek and Kimi.
const mimoProvider = createDeepSeek({ apiKey, baseURL })
model = mimoProvider(modelId)
break
}
case "glm": case "glm":
case "qwen": case "qwen":
case "qiniu": case "qiniu":
case "novita": { case "novita":
case "atlascloud": {
const envVar = PROVIDER_ENV_VARS[provider] const envVar = PROVIDER_ENV_VARS[provider]
if (!envVar) { if (!envVar) {
throw new Error( throw new Error(
@@ -1322,7 +1405,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
overrides?.apiKey, overrides?.apiKey,
overrides?.baseUrl, overrides?.baseUrl,
resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"), resolveBaseUrlEnv(overrides, "KIMI_BASE_URL"),
PROVIDER_INFO["kimi"]?.defaultBaseUrl, PROVIDER_INFO.kimi?.defaultBaseUrl,
) )
// Use createDeepSeek to properly handle reasoning_content for Kimi // Use createDeepSeek to properly handle reasoning_content for Kimi
// thinking models (e.g., kimi-k2.6). Kimi's API uses the same // thinking models (e.g., kimi-k2.6). Kimi's API uses the same
@@ -1335,7 +1418,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
default: default:
throw new Error( throw new Error(
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`, `Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`,
) )
} }
@@ -1361,80 +1444,19 @@ export function supportsPromptCaching(modelId: string): boolean {
) )
} }
/**
* Check if a model supports image/vision input.
* Some models silently drop image parts without error (AI SDK warning only).
*/
export function supportsImageInput(modelId: string): boolean {
const lowerModelId = modelId.toLowerCase()
// Helper to check if model has vision capability indicator
const hasVisionIndicator =
lowerModelId.includes("vision") || lowerModelId.includes("vl")
// Models that DON'T support image/vision input (unless vision variant)
// Kimi K2 doesn't support images, but K2.5 does
// Only block kimi-k2 specifically, not other Kimi models
if (
(lowerModelId.includes("kimi-k2") ||
lowerModelId.includes("kimi_k2")) &&
!hasVisionIndicator &&
!lowerModelId.includes("2.5") &&
!lowerModelId.includes("k2.5")
) {
return false
}
// Moonshot text models (moonshot-v1 series are text-only)
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
return false
}
// MiniMax text models (MiniMax-M2.x series are text-only; M3 supports image input)
if (
lowerModelId.includes("minimax") &&
!hasVisionIndicator &&
!lowerModelId.includes("m3")
) {
return false
}
// DeepSeek text models (not vision variants)
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
return false
}
// Qwen text models (not vision variants like qwen-vl)
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
if (
lowerModelId.includes("qwen") &&
!hasVisionIndicator &&
!lowerModelId.includes("qwen3.5") &&
!lowerModelId.includes("qvq")
) {
return false
}
// GLM text models (not vision variants)
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
if (!/[\d.]v/.test(lowerModelId)) {
return false
}
}
// Default: assume model supports images
return true
}
/** /**
* Get the AI model for diagram validation. * Get the AI model for diagram validation.
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL. * Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
* Throws if the model doesn't support image input. *
* Note: we no longer guess whether the model supports image input from its
* name — that heuristic misfired on newer models (see issue #874). If a
* configured validation model can't handle images, the API call simply errors
* and the validate-diagram route falls back to "valid".
*/ */
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] { export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL // AI_MODEL may be comma-separated (multi-model fallback); pick the first.
const envFallback = process.env.AI_MODEL?.split(",")[0]?.trim() || undefined
const modelId = process.env.VALIDATION_MODEL || envFallback
if (!modelId) { if (!modelId) {
throw new Error( throw new Error(
@@ -1442,12 +1464,6 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
) )
} }
if (!supportsImageInput(modelId)) {
throw new Error(
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
)
}
const { model } = getAIModel({ modelId }) const { model } = getAIModel({ modelId })
return model return model
} }

79
lib/aihubmix-models.ts Normal file
View File

@@ -0,0 +1,79 @@
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
const NON_CHAT_MODEL_TYPES = new Set([
"embedding",
"image_generation",
"rerank",
"transcription",
"tts",
"video",
])
type AihubmixModelListPayload = {
data?: unknown
}
type AihubmixModelRecord = {
model_id?: unknown
types?: unknown
}
function getModelTypes(types: unknown): Set<string> {
if (typeof types !== "string") {
return new Set()
}
return new Set(
types
.split(",")
.map((type) => type.trim())
.filter(Boolean),
)
}
function isChatModel(record: AihubmixModelRecord): record is {
model_id: string
types: string
} {
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
return false
}
const types = getModelTypes(record.types)
if (!types.has("llm")) {
return false
}
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
}
export function extractAihubmixModelIds(payload: unknown): string[] {
const data = (payload as AihubmixModelListPayload)?.data
if (!Array.isArray(data)) {
return []
}
const seen = new Set<string>()
const modelIds: string[] = []
for (const item of data) {
if (!item || typeof item !== "object") {
continue
}
const record = item as AihubmixModelRecord
if (!isChatModel(record)) {
continue
}
const modelId = record.model_id.trim()
if (seen.has(modelId)) {
continue
}
seen.add(modelId)
modelIds.push(modelId)
}
return modelIds
}

View File

@@ -34,7 +34,8 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu" "qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
}, },
"chat": { "chat": {
"placeholder": "Describe your diagram or upload a file...", "placeholder": "Describe your diagram or upload a file...",
@@ -371,6 +372,7 @@
"baseUrlWithExample": "Base URL (optional, e.g. {example})", "baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL", "customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API", "minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
"models": "Models", "models": "Models",
"customModelId": "Custom model ID...", "customModelId": "Custom model ID...",
"allAdded": "All added", "allAdded": "All added",
@@ -402,6 +404,152 @@
"showUnvalidatedModels": "Show unvalidated models", "showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)", "allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated", "unvalidatedModelWarning": "This model has not been validated",
"serverDefaultModel": "Server default model" "serverDefaultModel": "Server default model",
"showValue": "Show value",
"hideValue": "Hide value"
},
"admin": {
"title": "Admin Settings",
"loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.",
"password": "Password",
"signIn": "Sign In",
"signingIn": "Signing In…",
"loginFailed": "Login failed",
"precedence": "File overrides env · env overrides defaults",
"notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.",
"settingGroups": "Setting groups",
"enabled": "Enabled",
"disabled": "Disabled",
"enableGroup": "Enable {group}",
"unsavedChanges": "Unsaved changes",
"saved": "Settings saved. Changes apply immediately.",
"saveFailed": "Save failed. Check your connection and try again.",
"invalidSettings": "Some settings are invalid.",
"discard": "Discard",
"saveChanges": "Save Changes",
"saving": "Saving…",
"sourceSaved": "Saved",
"sourceEnv": "Env",
"sourceSavedTitle": "Set in the admin settings file",
"sourceEnvTitle": "Set by an environment variable",
"restartRequired": "Restart Required",
"modified": "Modified",
"notSet": "Not set",
"savedReplace": "Saved ({hint}) — type to replace",
"showValue": "Show value",
"hideValue": "Hide value",
"removeValue": "Remove value",
"removeValueTitle": "Remove the stored value",
"resetToDefault": "Reset to default",
"models": "Models",
"modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.",
"addProviderHint": "Add a provider to offer server-side models to all users.",
"selectProviderHint": "Select or add a provider to configure its credentials and models.",
"addProviderToOfferModels": "Add at least one model to expose this provider to users.",
"managedViaEnv": "(managed via env)",
"envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.",
"defaultModel": "Default Model",
"noModelsConfigured": "No models configured",
"modelCount": "{count} model",
"modelCountPlural": "{count} models",
"default": "Default",
"setAsDefault": "Set as default provider",
"defaultProvider": "Default provider",
"modelIdPlaceholder": "Model ID…",
"addModel": "Add model",
"suggested": "Suggested",
"test": "Test",
"testOk": "OK ({ms}ms)",
"testFailed": "Failed",
"removeModel": "Remove {model}",
"deleteProviderTitle": "Delete {name}?",
"deleteProviderDesc": "Its credentials and models will be removed from the server after you save.",
"cancel": "Cancel",
"delete": "Delete",
"groups": {
"generation": {
"title": "Generation",
"description": "Output parameters applied to all chat requests."
},
"access": {
"title": "Access Control",
"description": "Restrict who can use this deployment."
},
"features": {
"title": "Features",
"description": "Optional features and security toggles."
},
"observability": {
"title": "Observability",
"description": "Langfuse tracing for LLM calls."
},
"quota": {
"title": "Quota & Rate Limits",
"description": "Per-IP usage limits. Enforcement requires a DynamoDB table."
}
},
"settings": {
"TEMPERATURE": {
"label": "Temperature",
"description": "Leave unset for reasoning models that reject temperature."
},
"MAX_OUTPUT_TOKENS": {
"label": "Max Output Tokens"
},
"ACCESS_CODE_LIST": {
"label": "Access Codes",
"description": "Comma-separated list. Users must enter one to chat. Empty = open access."
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM Diagram Validation",
"description": "Visually validate generated diagrams with a vision model."
},
"VALIDATION_MODEL": {
"label": "Validation Model",
"description": "Falls back to the default AI model when empty."
},
"VALIDATION_TIMEOUT": {
"label": "Validation Timeout (ms)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "History XML Compression",
"description": "Replace old diagram XML in history with placeholders."
},
"ALLOW_PRIVATE_URLS": {
"label": "Allow Private URLs",
"description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)."
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "Daily Request Limit",
"description": "Per IP per day."
},
"DAILY_TOKEN_LIMIT": {
"label": "Daily Token Limit",
"description": "Per IP per day."
},
"TPM_LIMIT": {
"label": "Tokens Per Minute"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB Table",
"description": "Quota enforcement is disabled when empty."
},
"DYNAMODB_REGION": {
"label": "DynamoDB Region"
},
"QUOTA_TIMEZONE": {
"label": "Quota Timezone",
"description": "Timezone for the daily reset boundary."
}
}
} }
} }

View File

@@ -34,7 +34,8 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu" "qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
}, },
"chat": { "chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -325,6 +326,7 @@
"baseUrlWithExample": "ベース URLオプション、例: {example}", "baseUrlWithExample": "ベース URLオプション、例: {example}",
"customEndpoint": "カスタムエンドポイント URL", "customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API推奨、または /v1 で OpenAI 互換 API を使用", "minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API推奨、または /v1 で OpenAI 互換 API を使用",
"mimoBaseUrlHint": "デフォルトは従量課金キーsk-...用です。Token Plan 加入者tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
"models": "モデル", "models": "モデル",
"customModelId": "カスタムモデル ID...", "customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み", "allAdded": "すべて追加済み",
@@ -356,7 +358,9 @@
"showUnvalidatedModels": "未検証のモデルを表示", "showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)", "allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません", "unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル" "serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示"
}, },
"templates": { "templates": {
"title": "マイテンプレート", "title": "マイテンプレート",
@@ -403,5 +407,149 @@
"importNoFile": "JSON ファイルを選択してください", "importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}", "importFailed": "インポートに失敗しました:{error}",
"importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました" "importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました"
},
"admin": {
"title": "管理者設定",
"loginPrompt": "サーバー設定を管理するには、管理者パスワードADMIN_PASSWORD 環境変数)を入力してください。",
"password": "パスワード",
"signIn": "ログイン",
"signingIn": "ログイン中…",
"loginFailed": "ログインに失敗しました",
"precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き",
"notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。",
"settingGroups": "設定グループ",
"enabled": "有効",
"disabled": "無効",
"enableGroup": "{group} を有効化",
"unsavedChanges": "未保存の変更があります",
"saved": "設定を保存しました。変更は即座に反映されます。",
"saveFailed": "保存に失敗しました。接続を確認して再試行してください。",
"invalidSettings": "一部の設定が無効です。",
"discard": "破棄",
"saveChanges": "変更を保存",
"saving": "保存中…",
"sourceSaved": "保存済み",
"sourceEnv": "環境変数",
"sourceSavedTitle": "管理者設定ファイルで設定",
"sourceEnvTitle": "環境変数で設定",
"restartRequired": "再起動が必要",
"modified": "変更済み",
"notSet": "未設定",
"savedReplace": "保存済み({hint})——入力して置き換え",
"showValue": "値を表示",
"hideValue": "値を非表示",
"removeValue": "値を削除",
"removeValueTitle": "保存された値を削除",
"resetToDefault": "デフォルトに戻す",
"models": "モデル",
"modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。",
"addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。",
"selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。",
"addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。",
"managedViaEnv": "(環境変数で管理)",
"envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。",
"defaultModel": "デフォルトモデル",
"noModelsConfigured": "モデルが構成されていません",
"modelCount": "{count} 個のモデル",
"modelCountPlural": "{count} 個のモデル",
"default": "デフォルト",
"setAsDefault": "デフォルトプロバイダーに設定",
"defaultProvider": "デフォルトプロバイダー",
"modelIdPlaceholder": "モデル ID…",
"addModel": "モデルを追加",
"suggested": "おすすめ",
"test": "テスト",
"testOk": "正常({ms}ms",
"testFailed": "失敗",
"removeModel": "{model} を削除",
"deleteProviderTitle": "{name} を削除しますか?",
"deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。",
"cancel": "キャンセル",
"delete": "削除",
"groups": {
"generation": {
"title": "生成",
"description": "すべてのチャットリクエストに適用される出力パラメーター。"
},
"access": {
"title": "アクセス制御",
"description": "このデプロイを使用できるユーザーを制限します。"
},
"features": {
"title": "機能",
"description": "オプション機能とセキュリティの切り替え。"
},
"observability": {
"title": "オブザーバビリティ",
"description": "LLM 呼び出しの Langfuse トレース。"
},
"quota": {
"title": "クォータとレート制限",
"description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。"
}
},
"settings": {
"TEMPERATURE": {
"label": "温度",
"description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大出力トークン数"
},
"ACCESS_CODE_LIST": {
"label": "アクセスコード",
"description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 図検証",
"description": "ビジョンモデルで生成された図を視覚的に検証します。"
},
"VALIDATION_MODEL": {
"label": "検証モデル",
"description": "空の場合はデフォルトの AI モデルにフォールバックします。"
},
"VALIDATION_TIMEOUT": {
"label": "検証タイムアウトms"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "履歴 XML 圧縮",
"description": "履歴内の古い図 XML をプレースホルダーで置き換えます。"
},
"ALLOW_PRIVATE_URLS": {
"label": "プライベート URL を許可",
"description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックしますSSRF 保護)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "1 日あたりのリクエスト上限",
"description": "IP ごと 1 日あたり。"
},
"DAILY_TOKEN_LIMIT": {
"label": "1 日あたりのトークン上限",
"description": "IP ごと 1 日あたり。"
},
"TPM_LIMIT": {
"label": "1 分あたりのトークン数"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB テーブル",
"description": "空の場合、クォータの強制は無効になります。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB リージョン"
},
"QUOTA_TIMEZONE": {
"label": "クォータタイムゾーン",
"description": "1 日のリセット境界に使用するタイムゾーン。"
}
}
} }
} }

View File

@@ -34,7 +34,8 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu" "qiniu": "Qiniu",
"mimo": "MiMo (小米)"
}, },
"chat": { "chat": {
"placeholder": "描述您的圖表或上傳檔案...", "placeholder": "描述您的圖表或上傳檔案...",
@@ -371,6 +372,7 @@
"baseUrlWithExample": "基礎 URL可選例如 {example}", "baseUrlWithExample": "基礎 URL可選例如 {example}",
"customEndpoint": "自訂端點 URL", "customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API推薦或使用 /v1 端點為 OpenAI 相容 API", "minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API推薦或使用 /v1 端點為 OpenAI 相容 API",
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰sk-...。Token Plan 訂閱用戶tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型", "models": "模型",
"customModelId": "自訂模型 ID...", "customModelId": "自訂模型 ID...",
"allAdded": "已全部新增", "allAdded": "已全部新增",
@@ -402,6 +404,152 @@
"showUnvalidatedModels": "顯示未驗證的模型", "showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)", "allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證", "unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型" "serverDefaultModel": "伺服器預設模型",
"showValue": "顯示值",
"hideValue": "隱藏值"
},
"admin": {
"title": "管理員設定",
"loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。",
"password": "密碼",
"signIn": "登入",
"signingIn": "正在登入…",
"loginFailed": "登入失敗",
"precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值",
"notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。",
"settingGroups": "設定分組",
"enabled": "已啟用",
"disabled": "已停用",
"enableGroup": "啟用 {group}",
"unsavedChanges": "有未儲存的變更",
"saved": "設定已儲存,變更立即生效。",
"saveFailed": "儲存失敗。請檢查網路連線後重試。",
"invalidSettings": "部分設定無效。",
"discard": "捨棄",
"saveChanges": "儲存變更",
"saving": "正在儲存…",
"sourceSaved": "已儲存",
"sourceEnv": "環境變數",
"sourceSavedTitle": "在管理員設定檔中設定",
"sourceEnvTitle": "透過環境變數設定",
"restartRequired": "需要重新啟動",
"modified": "已修改",
"notSet": "未設定",
"savedReplace": "已儲存({hint})——輸入以取代",
"showValue": "顯示值",
"hideValue": "隱藏值",
"removeValue": "移除值",
"removeValueTitle": "移除已儲存的值",
"resetToDefault": "重設為預設",
"models": "模型",
"modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。",
"addProviderHint": "新增一個 provider為所有使用者提供伺服器端模型。",
"selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。",
"addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。",
"managedViaEnv": "(透過環境變數管理)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。",
"defaultModel": "預設模型",
"noModelsConfigured": "未設定模型",
"modelCount": "{count} 個模型",
"modelCountPlural": "{count} 個模型",
"default": "預設",
"setAsDefault": "設為預設 provider",
"defaultProvider": "預設 provider",
"modelIdPlaceholder": "模型 ID…",
"addModel": "新增模型",
"suggested": "推薦",
"test": "測試",
"testOk": "正常({ms} 毫秒)",
"testFailed": "失敗",
"removeModel": "移除 {model}",
"deleteProviderTitle": "刪除 {name}",
"deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。",
"cancel": "取消",
"delete": "刪除",
"groups": {
"generation": {
"title": "生成",
"description": "套用於所有聊天請求的輸出參數。"
},
"access": {
"title": "存取控制",
"description": "限制誰可以使用此部署。"
},
"features": {
"title": "功能",
"description": "選用功能和安全開關。"
},
"observability": {
"title": "可觀測性",
"description": "對 LLM 呼叫進行 Langfuse 追蹤。"
},
"quota": {
"title": "配額與速率限制",
"description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。"
}
},
"settings": {
"TEMPERATURE": {
"label": "溫度",
"description": "對於拒絕溫度參數的推理模型,請留空。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大輸出 token 數"
},
"ACCESS_CODE_LIST": {
"label": "存取碼",
"description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 圖表驗證",
"description": "使用視覺模型對產生的圖表進行視覺化驗證。"
},
"VALIDATION_MODEL": {
"label": "驗證模型",
"description": "留空時回退到預設 AI 模型。"
},
"VALIDATION_TIMEOUT": {
"label": "驗證逾時(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "歷史 XML 壓縮",
"description": "用占位符取代歷史記錄中的舊圖表 XML。"
},
"ALLOW_PRIVATE_URLS": {
"label": "允許私有 URL",
"description": "關閉以阻擋對私有 IP 和內部主機名的請求SSRF 防護)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "每日請求上限",
"description": "每個 IP 每天。"
},
"DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限",
"description": "每個 IP 每天。"
},
"TPM_LIMIT": {
"label": "每分鐘 token 數"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表",
"description": "留空時配額強制執行被停用。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB 區域"
},
"QUOTA_TIMEZONE": {
"label": "配額時區",
"description": "每日重置邊界所用的時區。"
}
}
} }
} }

View File

@@ -34,7 +34,8 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu" "qiniu": "Qiniu",
"mimo": "MiMo (小米)"
}, },
"chat": { "chat": {
"placeholder": "描述您的图表或上传文件...", "placeholder": "描述您的图表或上传文件...",
@@ -371,6 +372,7 @@
"baseUrlWithExample": "基础 URL可选例如 {example}", "baseUrlWithExample": "基础 URL可选例如 {example}",
"customEndpoint": "自定义端点 URL", "customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API推荐或使用 /v1 端点为 OpenAI 兼容 API", "minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API推荐或使用 /v1 端点为 OpenAI 兼容 API",
"mimoBaseUrlHint": "默认地址适用于按量付费密钥sk-...。Token Plan 订阅用户tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型", "models": "模型",
"customModelId": "自定义模型 ID...", "customModelId": "自定义模型 ID...",
"allAdded": "已全部添加", "allAdded": "已全部添加",
@@ -402,6 +404,152 @@
"showUnvalidatedModels": "显示未验证的模型", "showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)", "allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证", "unvalidatedModelWarning": "此模型尚未验证",
"serverDefaultModel": "服务器默认模型" "serverDefaultModel": "服务器默认模型",
"showValue": "显示值",
"hideValue": "隐藏值"
},
"admin": {
"title": "管理员设置",
"loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。",
"password": "密码",
"signIn": "登录",
"signingIn": "正在登录…",
"loginFailed": "登录失败",
"precedence": "文件覆盖环境变量 · 环境变量覆盖默认值",
"notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。",
"settingGroups": "设置分组",
"enabled": "已启用",
"disabled": "已禁用",
"enableGroup": "启用 {group}",
"unsavedChanges": "有未保存的更改",
"saved": "设置已保存,更改立即生效。",
"saveFailed": "保存失败。请检查网络连接后重试。",
"invalidSettings": "部分设置无效。",
"discard": "放弃",
"saveChanges": "保存更改",
"saving": "正在保存…",
"sourceSaved": "已保存",
"sourceEnv": "环境变量",
"sourceSavedTitle": "在管理员设置文件中设置",
"sourceEnvTitle": "通过环境变量设置",
"restartRequired": "需要重启",
"modified": "已修改",
"notSet": "未设置",
"savedReplace": "已保存({hint})——输入以替换",
"showValue": "显示值",
"hideValue": "隐藏值",
"removeValue": "移除值",
"removeValueTitle": "移除已保存的值",
"resetToDefault": "恢复默认",
"models": "模型",
"modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。",
"addProviderHint": "添加一个 provider为所有用户提供服务端模型。",
"selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。",
"addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。",
"managedViaEnv": "(通过环境变量管理)",
"envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。",
"defaultModel": "默认模型",
"noModelsConfigured": "未配置模型",
"modelCount": "{count} 个模型",
"modelCountPlural": "{count} 个模型",
"default": "默认",
"setAsDefault": "设为默认 provider",
"defaultProvider": "默认 provider",
"modelIdPlaceholder": "模型 ID…",
"addModel": "添加模型",
"suggested": "推荐",
"test": "测试",
"testOk": "正常({ms} 毫秒)",
"testFailed": "失败",
"removeModel": "移除 {model}",
"deleteProviderTitle": "删除 {name}",
"deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。",
"cancel": "取消",
"delete": "删除",
"groups": {
"generation": {
"title": "生成",
"description": "应用于所有聊天请求的输出参数。"
},
"access": {
"title": "访问控制",
"description": "限制谁可以使用此部署。"
},
"features": {
"title": "功能",
"description": "可选功能和安全开关。"
},
"observability": {
"title": "可观测性",
"description": "对 LLM 调用进行 Langfuse 追踪。"
},
"quota": {
"title": "配额与速率限制",
"description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。"
}
},
"settings": {
"TEMPERATURE": {
"label": "温度",
"description": "对于拒绝温度参数的推理模型,请留空。"
},
"MAX_OUTPUT_TOKENS": {
"label": "最大输出 token 数"
},
"ACCESS_CODE_LIST": {
"label": "访问码",
"description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。"
},
"ENABLE_VLM_VALIDATION": {
"label": "VLM 图表验证",
"description": "使用视觉模型对生成的图表进行可视化验证。"
},
"VALIDATION_MODEL": {
"label": "验证模型",
"description": "留空时回退到默认 AI 模型。"
},
"VALIDATION_TIMEOUT": {
"label": "验证超时(毫秒)"
},
"ENABLE_HISTORY_XML_REPLACE": {
"label": "历史 XML 压缩",
"description": "用占位符替换历史记录中的旧图表 XML。"
},
"ALLOW_PRIVATE_URLS": {
"label": "允许私有 URL",
"description": "关闭以阻止对私有 IP 和内部主机名的请求SSRF 防护)。"
},
"LANGFUSE_PUBLIC_KEY": {
"label": "Langfuse Public Key"
},
"LANGFUSE_SECRET_KEY": {
"label": "Langfuse Secret Key"
},
"LANGFUSE_BASEURL": {
"label": "Langfuse Base URL"
},
"DAILY_REQUEST_LIMIT": {
"label": "每日请求上限",
"description": "每个 IP 每天。"
},
"DAILY_TOKEN_LIMIT": {
"label": "每日 token 上限",
"description": "每个 IP 每天。"
},
"TPM_LIMIT": {
"label": "每分钟 token 数"
},
"DYNAMODB_QUOTA_TABLE": {
"label": "DynamoDB 表",
"description": "留空时配额强制执行被禁用。"
},
"DYNAMODB_REGION": {
"label": "DynamoDB 区域"
},
"QUOTA_TIMEZONE": {
"label": "配额时区",
"description": "每日重置边界所用的时区。"
}
}
} }
} }

View File

@@ -62,7 +62,54 @@ function getConfigPath(): string {
return path.join(process.cwd(), "ai-models.json") return path.join(process.cwd(), "ai-models.json")
} }
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> { /**
* Synthesize a config from a comma-separated AI_MODEL value (Priority 3 fallback).
* Lets users expose multiple models without authoring AI_MODELS_CONFIG / ai-models.json.
* Triggers only when AI_MODEL contains a comma AND AI_PROVIDER is set to a known provider.
*/
function configFromCommaSeparatedAiModel(): ServerModelsConfig | null {
const aiModel = process.env.AI_MODEL
if (!aiModel || !aiModel.includes(",")) return null
const aiProvider = process.env.AI_PROVIDER
if (!aiProvider) {
console.warn(
"[server-model-config] AI_MODEL contains commas but AI_PROVIDER is not set; " +
"skipping multi-model fallback. Set AI_PROVIDER, or use AI_MODELS_CONFIG / ai-models.json.",
)
return null
}
if (!(aiProvider in PROVIDER_INFO)) {
console.warn(
`[server-model-config] AI_PROVIDER="${aiProvider}" is not a known provider; skipping multi-model fallback.`,
)
return null
}
const models = Array.from(
new Set(
aiModel
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
),
)
if (models.length === 0) return null
const providerName = aiProvider as ProviderName
return {
providers: [
{
name: PROVIDER_INFO[providerName]?.label || providerName,
provider: providerName,
models,
default: true,
},
],
}
}
export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments // Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG const envConfig = process.env.AI_MODELS_CONFIG
if (envConfig && envConfig.trim().length > 0) { if (envConfig && envConfig.trim().length > 0) {
@@ -85,15 +132,51 @@ export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig |
const json = JSON.parse(jsonStr) const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json) return ServerModelsConfigSchema.parse(json)
} catch (err: any) { } catch (err: any) {
if (err?.code === "ENOENT") { if (err?.code !== "ENOENT") {
return null
}
console.error( console.error(
"[server-model-config] Failed to load ai-models.json:", "[server-model-config] Failed to load ai-models.json:",
err, err,
) )
return null return null
} }
}
// Priority 3: AI_MODEL with comma-separated values + AI_PROVIDER
return configFromCommaSeparatedAiModel()
}
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
const envConfig = await loadEnvServerModelsConfig()
// Merge in providers managed via the admin panel (settings.json).
// Dynamic import to avoid a module-init cycle with lib/admin/providers.
let adminConfig: ServerModelsConfig | null = null
try {
const { adminProvidersToConfig, loadAdminProviders } = await import(
"./admin/providers"
)
const adminProviders = loadAdminProviders()
if (adminProviders.length > 0) {
adminConfig = adminProvidersToConfig(adminProviders)
}
} catch (err) {
console.error(
"[server-model-config] Failed to load admin providers:",
err,
)
}
if (!adminConfig || adminConfig.providers.length === 0) return envConfig
if (!envConfig) return adminConfig
// A panel default overrides an env default
const adminHasDefault = adminConfig.providers.some((p) => p.default)
const envProviders = adminHasDefault
? envConfig.providers.map((p) =>
p.default ? { ...p, default: undefined } : p,
)
: envConfig.providers
return { providers: [...envProviders, ...adminConfig.providers] }
} }
export async function loadFlattenedServerModels(): Promise< export async function loadFlattenedServerModels(): Promise<

View File

@@ -2,38 +2,37 @@
* SSRF (Server-Side Request Forgery) protection utilities * SSRF (Server-Side Request Forgery) protection utilities
*/ */
import { lookup } from "node:dns/promises"
/** /**
* Check if URL points to private/internal network * Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
* Blocks: localhost, private IPs, link-local, AWS metadata service * Works for both user-supplied literal IPs and DNS-resolved addresses.
*/ */
export function isPrivateUrl(urlString: string): boolean { function isPrivateIp(ip: string): boolean {
try { const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
const url = new URL(urlString)
// Strip a trailing dot so FQDN forms like "localhost." (which still
// resolve to 127.0.0.1) cannot bypass the equality checks below.
const hostname = url.hostname.toLowerCase().replace(/\.$/, "")
// Block localhost // IPv6
if (addr.includes(":")) {
if (addr === "::1" || addr === "::") return true
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
if ( if (
hostname === "localhost" || addr.startsWith("fc") ||
hostname === "127.0.0.1" || addr.startsWith("fd") ||
hostname === "::1" addr.startsWith("::ffff:")
) { ) {
return true return true
} }
// link-local (fe80::/10)
// Block AWS/cloud metadata endpoints const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
if ( if (linkLocal) {
hostname === "169.254.169.254" || const high = parseInt(linkLocal[1], 16)
hostname === "metadata.google.internal" if (high >= 0x80 && high <= 0xbf) return true
) { }
return true return false
} }
// Check for private IPv4 ranges // IPv4
const ipv4Match = hostname.match( const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) { if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number) const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8 if (a === 10) return true // 10.0.0.0/8
@@ -41,25 +40,78 @@ export function isPrivateUrl(urlString: string): boolean {
if (a === 192 && b === 168) return true // 192.168.0.0/16 if (a === 192 && b === 168) return true // 192.168.0.0/16
if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local) if (a === 169 && b === 254) return true // 169.254.0.0/16 (link-local)
if (a === 127) return true // 127.0.0.0/8 (loopback) if (a === 127) return true // 127.0.0.0/8 (loopback)
if (a === 0) return true // 0.0.0.0/8
if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT, used by some cloud internal networks)
} }
// Block common internal hostnames return false
}
/**
* String-only check against well-known private hostnames and literal IPs.
* Fast path that avoids a DNS lookup for obvious cases.
*/
function isPrivateHostname(hostname: string): boolean {
const host = hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
if ( if (
hostname.endsWith(".local") || host === "localhost" ||
hostname.endsWith(".internal") || host === "127.0.0.1" ||
hostname.endsWith(".localhost") host === "::1" ||
host === "::"
) { ) {
return true return true
} }
return false if (host === "169.254.169.254" || host === "metadata.google.internal") {
return true
}
if (
host.endsWith(".local") ||
host.endsWith(".internal") ||
host.endsWith(".localhost")
) {
return true
}
// Literal IP supplied directly in the URL
return isPrivateIp(host)
}
/**
* Check if URL points to private/internal network.
* Blocks: localhost, private IPs, link-local, AWS metadata service.
*
* Resolves the hostname via DNS and validates every returned address, so
* public-looking names that map to internal IPs (e.g. "127-0-0-1.sslip.io")
* are caught even though they pass the string-only check.
*/
export async function isPrivateUrl(urlString: string): Promise<boolean> {
try {
const url = new URL(urlString)
const hostname = url.hostname
// Fast path: obvious string matches and literal IPs.
if (isPrivateHostname(hostname)) return true
// Resolve DNS and reject if any address is private.
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
const addresses = await lookup(stripped, { all: true })
return addresses.some(({ address }) => isPrivateIp(address))
} catch { } catch {
return true // Invalid URL - block it return true // Invalid URL or DNS failure - block it
} }
} }
/** /**
* Whether private URLs are allowed (defaults to true) * Whether private URLs are allowed (defaults to true)
* Set ALLOW_PRIVATE_URLS=false to block private URLs * Set ALLOW_PRIVATE_URLS=false to block private URLs
* Read per call so admin-panel changes apply without restart
*/ */
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false" export function allowPrivateUrls(): boolean {
return process.env.ALLOW_PRIVATE_URLS !== "false"
}

View File

@@ -9,6 +9,7 @@ export type ProviderName =
| "bedrock" | "bedrock"
| "ollama" | "ollama"
| "openrouter" | "openrouter"
| "aihubmix"
| "deepseek" | "deepseek"
| "siliconflow" | "siliconflow"
| "sglang" | "sglang"
@@ -22,6 +23,8 @@ export type ProviderName =
| "kimi" | "kimi"
| "minimax" | "minimax"
| "novita" | "novita"
| "mimo"
| "atlascloud"
// Individual model configuration // Individual model configuration
export interface ModelConfig { export interface ModelConfig {
@@ -85,6 +88,15 @@ export interface FlattenedModel {
baseUrlEnv?: string baseUrlEnv?: string
} }
// Providers whose server credentials live in fixed env vars
// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no
// apiKeyEnv redirection support — their credentials are global
export const FIXED_CRED_PROVIDERS: ProviderName[] = [
"bedrock",
"vertexai",
"ollama",
]
// Map provider names to models.dev logo names // Map provider names to models.dev logo names
export const PROVIDER_LOGO_MAP: Record<string, string> = { export const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai", openai: "openai",
@@ -93,6 +105,7 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
azure: "azure", azure: "azure",
bedrock: "amazon-bedrock", bedrock: "amazon-bedrock",
openrouter: "openrouter", openrouter: "openrouter",
aihubmix: "aihubmix",
deepseek: "deepseek", deepseek: "deepseek",
siliconflow: "siliconflow", siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible sglang: "openai", // SGLang is OpenAI-compatible
@@ -103,6 +116,8 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
modelscope: "modelscope", modelscope: "modelscope",
minimax: "minimax", minimax: "minimax",
novita: "novita", novita: "novita",
mimo: "xiaomi",
atlascloud: "openai",
} }
// Provider metadata // Provider metadata
@@ -136,6 +151,10 @@ export const PROVIDER_INFO: Record<
label: "OpenRouter", label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1", defaultBaseUrl: "https://openrouter.ai/api/v1",
}, },
aihubmix: {
label: "AIHubMix",
defaultBaseUrl: "https://aihubmix.com/v1",
},
deepseek: { deepseek: {
label: "DeepSeek", label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1", defaultBaseUrl: "https://api.deepseek.com/v1",
@@ -185,182 +204,253 @@ export const PROVIDER_INFO: Record<
label: "Novita AI", label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai", defaultBaseUrl: "https://api.novita.ai/openai",
}, },
mimo: {
label: "MiMo (Xiaomi)",
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
},
atlascloud: {
label: "Atlas Cloud",
defaultBaseUrl: "https://api.atlascloud.ai/v1",
},
} }
// Suggested models per provider for quick add // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = { export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
openai: [ openai: [
"gpt-5.2-pro", "gpt-5.5-pro",
"gpt-5.2-chat-latest", "gpt-5.5",
"gpt-5.2", "gpt-5.4-pro",
"gpt-5.1-codex-mini", "gpt-5.4",
"gpt-5.1-codex", "gpt-5.4-mini",
"gpt-5.1-chat-latest", "gpt-5.4-nano",
"gpt-5.1", "gpt-5-codex-mini",
"gpt-5-pro",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-codex",
"gpt-5-chat-latest",
"gpt-4.1", "gpt-4.1",
"gpt-4.1-mini", "gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o", "gpt-4o",
"gpt-4o-mini", "gpt-4o-mini",
], ],
anthropic: [ anthropic: [
// Claude 4.5 series (latest) // Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
"claude-opus-4-5-20250514", "claude-opus-4-8",
"claude-sonnet-4-5-20250514", "claude-sonnet-4-6",
// Claude 4 series "claude-haiku-4-5",
"claude-opus-4-20250514", "claude-opus-4-7",
"claude-sonnet-4-20250514", "claude-opus-4-6",
// Claude 4.5 series
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
// Claude 3.7 series // Claude 3.7 series
"claude-3-7-sonnet-20250219", "claude-3-7-sonnet-20250219",
// Claude 3.5 series // Claude 3.5 series
"claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022", "claude-3-5-haiku-20241022",
// Claude 3 series
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
], ],
google: [ google: [
// Gemini 3 series
"gemini-3.1-pro",
"gemini-3.5-flash",
"gemini-3-flash",
"gemini-3.1-flash-lite",
// Gemini 2.5 series // Gemini 2.5 series
"gemini-2.5-pro", "gemini-2.5-pro",
"gemini-2.5-flash", "gemini-2.5-flash",
"gemini-2.5-flash-preview-05-20", "gemini-2.5-flash-lite",
// Gemini 2.0 series
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-lite",
// Gemini 1.5 series
"gemini-1.5-pro",
"gemini-1.5-flash",
// Legacy
"gemini-pro",
], ],
vertexai: [ vertexai: [
// Gemini 3 series
"gemini-3.1-pro-preview",
"gemini-3.5-flash",
"gemini-3-flash-preview",
"gemini-3.1-flash-lite",
// Gemini 2.5 series // Gemini 2.5 series
"gemini-2.5-pro", "gemini-2.5-pro",
"gemini-2.5-flash", "gemini-2.5-flash",
// Gemini 2.0 series "gemini-2.5-flash-lite",
"gemini-2.0-flash", ],
"gemini-2.0-flash-exp", azure: [
// Gemini 1.5 series "gpt-5.5",
"gemini-1.5-pro", "gpt-5.4",
"gemini-1.5-flash", "gpt-5.1",
"gpt-5",
"gpt-5-mini",
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
"o3",
"o4-mini",
], ],
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
bedrock: [ bedrock: [
// Anthropic Claude // Anthropic Claude
"anthropic.claude-opus-4-5-20250514-v1:0", "anthropic.claude-opus-4-8",
"anthropic.claude-sonnet-4-5-20250514-v1:0", "anthropic.claude-opus-4-7",
"anthropic.claude-sonnet-4-6",
"anthropic.claude-opus-4-6-v1",
"anthropic.claude-opus-4-5-20251101-v1:0",
"anthropic.claude-sonnet-4-5-20250929-v1:0",
"anthropic.claude-haiku-4-5-20251001-v1:0",
"anthropic.claude-opus-4-1-20250805-v1:0",
"anthropic.claude-opus-4-20250514-v1:0", "anthropic.claude-opus-4-20250514-v1:0",
"anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-20250514-v1:0",
"anthropic.claude-3-7-sonnet-20250219-v1:0",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
"anthropic.claude-3-5-haiku-20241022-v1:0", "anthropic.claude-3-5-haiku-20241022-v1:0",
"anthropic.claude-3-opus-20240229-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-haiku-20240307-v1:0",
// Amazon Nova // Amazon Nova
"amazon.nova-2-lite-v1:0",
"amazon.nova-premier-v1:0",
"amazon.nova-pro-v1:0", "amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0", "amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0", "amazon.nova-micro-v1:0",
// Meta Llama // Meta Llama
"meta.llama4-maverick-17b-instruct-v1:0",
"meta.llama4-scout-17b-instruct-v1:0",
"meta.llama3-3-70b-instruct-v1:0", "meta.llama3-3-70b-instruct-v1:0",
"meta.llama3-1-405b-instruct-v1:0",
"meta.llama3-1-70b-instruct-v1:0",
// Mistral // Mistral
"mistral.mistral-large-2411-v1:0", "mistral.mistral-large-3-675b-instruct",
"mistral.mistral-small-2503-v1:0", "mistral.pixtral-large-2502-v1:0",
], ],
openrouter: [ openrouter: [
// Anthropic // Anthropic
"anthropic/claude-sonnet-4", "anthropic/claude-opus-4.8",
"anthropic/claude-opus-4", "anthropic/claude-sonnet-4.6",
"anthropic/claude-3.5-sonnet", "anthropic/claude-haiku-4.5",
"anthropic/claude-3.5-haiku",
// OpenAI // OpenAI
"openai/gpt-4o", "openai/gpt-5.5",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"openai/gpt-4o-mini", "openai/gpt-4o-mini",
"openai/o1",
"openai/o3-mini",
// Google // Google
"google/gemini-2.5-pro", "google/gemini-3.1-pro-preview",
"google/gemini-2.5-flash", "google/gemini-3.5-flash",
"google/gemini-2.0-flash-exp:free", "google/gemini-2.5-flash-lite",
// xAI
"x-ai/grok-4.3",
// Meta Llama // Meta Llama
"meta-llama/llama-4-maverick",
"meta-llama/llama-4-scout",
"meta-llama/llama-3.3-70b-instruct", "meta-llama/llama-3.3-70b-instruct",
"meta-llama/llama-3.1-405b-instruct",
"meta-llama/llama-3.1-70b-instruct",
// DeepSeek // DeepSeek
"deepseek/deepseek-chat", "deepseek/deepseek-v4-pro",
"deepseek/deepseek-r1", "deepseek/deepseek-v3.2",
// Qwen // Qwen
"qwen/qwen-2.5-72b-instruct", "qwen/qwen3.7-max",
"qwen/qwen3-coder",
// MiniMax
"minimax/minimax-m3",
],
aihubmix: [
// Fallback list. The settings UI loads the live model list from AIHubMix when available.
// Anthropic Claude
"claude-fable-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
// OpenAI
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
// Google Gemini
"gemini-3.5-flash",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
// DeepSeek
"deepseek-v4-pro",
"deepseek-v4-flash",
// Qwen
"qwen3.7-max",
"qwen3-coder-next",
// Z.ai
"glm-5.1",
// Moonshot AI
"kimi-k2.6",
// MiniMax
"minimax-m3",
// xAI
"grok-4.3",
// Baidu
"ernie-5.1",
// Mistral
"mistral-large-3",
// Meta
"llama-4-maverick",
],
deepseek: [
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-chat",
"deepseek-reasoner",
], ],
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
siliconflow: [ siliconflow: [
// DeepSeek // DeepSeek
"deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-R1", "deepseek-ai/DeepSeek-V4-Flash",
"deepseek-ai/DeepSeek-V2.5", "deepseek-ai/DeepSeek-V3.2",
// MiniMax
"MiniMaxAI/MiniMax-M3",
// Moonshot
"moonshotai/Kimi-K2.6",
// Z.ai
"zai-org/GLM-5",
// Qwen // Qwen
"Qwen/Qwen2.5-72B-Instruct", "Qwen/Qwen3.6-35B-A3B",
"Qwen/Qwen2.5-32B-Instruct", "Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen2.5-Coder-32B-Instruct", "Qwen/Qwen3-30B-A3B-Instruct-2507",
"Qwen/Qwen2.5-7B-Instruct", "Qwen/Qwen3-VL-32B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct", // OpenAI open-weights
"qwen3.5-plus", "openai/gpt-oss-120b",
], ],
sglang: [ sglang: [
// SGLang is OpenAI-compatible, models depend on deployment // SGLang is OpenAI-compatible, models depend on deployment
"default", "default",
], ],
gateway: [ gateway: [
"openai/gpt-4o", "openai/gpt-5.5",
"openai/gpt-4o-mini", "anthropic/claude-opus-4.7",
"anthropic/claude-sonnet-4-5", "google/gemini-3.1-pro-preview",
"anthropic/claude-3-5-sonnet", "xai/grok-4.3",
"google/gemini-2.0-flash", "anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.4-mini",
], ],
edgeone: ["@tx/deepseek-ai/deepseek-v32"], edgeone: ["@tx/deepseek-ai/deepseek-v32"],
doubao: [ doubao: [
// ByteDance Doubao models // ByteDance Doubao models (Volcengine Ark IDs use dash form)
"doubao-1.5-thinking-pro-250415", "doubao-seed-2-0-pro-260215",
"doubao-1.5-thinking-pro-m-250428", "doubao-seed-2-0-lite-260428",
"doubao-1.5-pro-32k-250115", "doubao-seed-2-0-mini-260428",
"doubao-1.5-pro-256k-250115", "doubao-seed-1-8-251228",
"doubao-pro-32k-241215", "doubao-seed-1-6-251015",
"doubao-pro-256k-241215", "doubao-seed-1-6-flash-250828",
"doubao-seed-1-6-vision-250815",
"doubao-1-5-pro-32k-250115",
"doubao-1-5-lite-32k-250115",
], ],
modelscope: [ modelscope: [
// DeepSeek
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1",
// Qwen // Qwen
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen3-235B-A22B-Instruct-2507", "Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct", "Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
"Qwen/Qwen3-32B", "Qwen/Qwen3-32B",
"qwen3.5-plus", "Qwen/Qwen2.5-72B-Instruct",
// DeepSeek
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2",
], ],
minimax: [ minimax: [
// MiniMax models (Anthropic-compatible API) // MiniMax models (Anthropic-compatible API)
"MiniMax-M3", "MiniMax-M3",
"MiniMax-M2.7", "MiniMax-M2.7",
"MiniMax-M2.7-highspeed", "MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
], ],
novita: [ novita: [
// Novita AI models (OpenAI-compatible API) // Novita AI models (OpenAI-compatible API)
"moonshotai/kimi-k2.5", "minimax/minimax-m3",
"zai-org/glm-5", "deepseek/deepseek-v4-pro",
"minimax/minimax-m2.5", "zai-org/glm-5.1",
"moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-flash",
], ],
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
} }
// Helper to generate UUID // Helper to generate UUID

3682
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@
"@ai-sdk/google-vertex": "^4.0.16", "@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai": "^3.0.0", "@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.1", "@ai-sdk/react": "^3.0.1",
"@aihubmix/ai-sdk-provider": "^2.1.0",
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18", "@extractus/article-extractor": "^8.0.18",
@@ -51,7 +52,7 @@
"@opennextjs/cloudflare": "^1.17.1", "@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^2.0.0", "@openrouter/ai-sdk-provider": "^2.0.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.216.0", "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -108,7 +109,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.4.13", "@biomejs/biome": "2.5.7",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -128,7 +129,7 @@
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"eslint": "9.39.4", "eslint": "9.39.5",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",

View File

@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|------|-------------| |------|-------------|
| `start_session` | Opens browser with real-time diagram preview | | `start_session` | Opens browser with real-time diagram preview |
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) | | `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) | | `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `get_diagram` | Get the current diagram XML | | `get_diagram` | Get the current diagram XML |
| `export_diagram` | Save diagram to a `.drawio` file | | `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
| `list_pages` | List every page (tab) with id, name, index, and cell count |
| `add_page` | Append a new page without touching existing ones |
| `rename_page` | Rename a page |
| `delete_page` | Delete a page (refuses to delete the last one) |
## How It Works ## How It Works

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.2.0", "version": "0.2.3",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -11,6 +11,8 @@
"build": "tsc", "build": "tsc",
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"keywords": [ "keywords": [
@@ -44,7 +46,8 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.8"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@@ -1,8 +1,14 @@
/** /**
* ID-based diagram operations * ID-based diagram operations
* Copied from lib/utils.ts to avoid cross-package imports *
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
* optional pageSelector identifies which page to edit; when omitted, the
* first page is targeted (the "active page by convention" — see pages.ts).
*/ */
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
export interface DiagramOperation { export interface DiagramOperation {
operation: "update" | "add" | "delete" operation: "update" | "add" | "delete"
cell_id: string cell_id: string
@@ -22,15 +28,18 @@ export interface ApplyOperationsResult {
/** /**
* Apply diagram operations (update/add/delete) using ID-based lookup. * Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
* *
* @param xmlContent - The full mxfile XML content * @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
* @param operations - Array of operations to apply * a full <mxfile> with one or more <diagram> children.
* @returns Object with result XML and any errors * @param operations - Array of operations to apply.
* @param pageSelector - Optional page selector for multi-page docs. Defaults
* to the first page.
* @returns Object with result XML (same shape as input) and any per-op errors.
*/ */
export function applyDiagramOperations( export function applyDiagramOperations(
xmlContent: string, xmlContent: string,
operations: DiagramOperation[], operations: DiagramOperation[],
pageSelector?: PageSelector,
): ApplyOperationsResult { ): ApplyOperationsResult {
const errors: OperationError[] = [] const errors: OperationError[] = []
@@ -53,8 +62,60 @@ export function applyDiagramOperations(
} }
} }
// Find the root element (inside mxGraphModel) // Locate the <root> element to operate on.
const root = doc.querySelector("root") //
// - For <mxfile> input: resolve the page via pageSelector, then dive into
// its <root>. This scopes querySelectorAll calls below to one page so
// cells on other pages aren't accidentally matched.
// - For bare <mxGraphModel> input: use the document's only <root>.
let root: Element | null
if (doc.documentElement?.tagName === "mxfile") {
const found = findPageElement(doc as unknown as Document, pageSelector)
if (!found) {
const selDesc = hasPageSelector(pageSelector)
? ` matching selector ${JSON.stringify(pageSelector)}`
: ""
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page${selDesc} not found in <mxfile>`,
},
],
}
}
root = found.element.querySelector("root")
if (!root) {
const pageId =
found.element.getAttribute("id") || `(index ${found.index})`
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page "${pageId}" has no <root> element`,
},
],
}
}
} else {
if (hasPageSelector(pageSelector)) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message:
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
},
],
}
}
root = doc.querySelector("root")
if (!root) { if (!root) {
return { return {
result: xmlContent, result: xmlContent,
@@ -67,8 +128,9 @@ export function applyDiagramOperations(
], ],
} }
} }
}
// Build a map of cell IDs to elements // Build a map of cell IDs to elements (scoped to the resolved page).
const cellMap = new Map<string, Element>() const cellMap = new Map<string, Element>()
root.querySelectorAll("mxCell").forEach((cell) => { root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id") const id = cell.getAttribute("id")
@@ -208,7 +270,9 @@ export function applyDiagramOperations(
cellsToDelete.add(cellId) cellsToDelete.add(cellId)
// Find children (cells where parent === cellId) // Find children (cells where parent === cellId)
const children = root.querySelectorAll( // Scoped to `root` so other pages' cells with the same parent id
// (notably "1") are never touched.
const children = root!.querySelectorAll(
`mxCell[parent="${cellId}"]`, `mxCell[parent="${cellId}"]`,
) )
children.forEach((child) => { children.forEach((child) => {

View File

@@ -0,0 +1,102 @@
/**
* Workflow gate for edit_diagram.
*
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
* clients, see #885), we compare content: `lastSeenXml` is the state-store
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
* edit_diagram / page CRUD). The store only changes on server writes or
* browser pushes (user autosave, sync exports), so if the live store still
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
* edit is safe no matter how much time passed.
*
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
* document when it pushes state back (different attribute order, pretty-
* printed whitespace, regenerated diagram ids, viewport attributes like
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
* is a user edit, so the fingerprint keeps only what a user can actually
* change: the set of pages, each page's name, and each page's cell tree
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
*/
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
export type EditGateResult =
| { ok: true }
| { ok: false; reason: "no-context" | "stale" }
/**
* Canonical serialisation of an element subtree: tag + attributes sorted by
* name + child elements in order + non-whitespace text. Whitespace-only text
* nodes (pretty-printing) are dropped.
*/
function canonicalizeElement(el: Element): string {
const attrs = Array.from(el.attributes)
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
.sort()
.join(" ")
let children = ""
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 1) {
children += canonicalizeElement(child as Element)
} else if (child.nodeType === 3 || child.nodeType === 4) {
const text = (child.textContent ?? "").trim()
if (text) children += JSON.stringify(text)
}
}
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
}
/**
* Structural fingerprint of a diagram document: page names + each page's
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
* Unparseable input falls back to the trimmed raw string, degrading to the
* plain string comparison.
*
* `includeNames=false` drops page names from the fingerprint — used when the
* other side of a comparison is a bare <mxGraphModel>, which carries no page
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
* any real page name).
*/
export function contentFingerprint(xml: string, includeNames = true): string {
const normalized = normalizeToMxfile(xml)
const doc = normalized ? parseMxfile(normalized) : null
if (!doc) return xml.trim()
const pages: string[] = []
doc.querySelectorAll("diagram").forEach((d) => {
const name = includeNames ? d.getAttribute("name") || "" : ""
const root = d.querySelector("root")
// No <root> means the page content is not plain XML (e.g. draw.io's
// compressed format) — fingerprint the raw text instead.
const body = root
? canonicalizeElement(root)
: (d.textContent || "").trim()
pages.push(`${name}=${body}`)
})
return pages.join("\n")
}
export function checkEditGate(
lastSeenXml: string,
liveXml: string,
): EditGateResult {
// Model never fetched or produced any diagram state in this session.
if (!lastSeenXml) return { ok: false, reason: "no-context" }
// Browser state moved since the model last looked (e.g. manual user
// edits): force a re-fetch so update/delete operations don't build on
// stale cell contents. An empty liveXml means the store has no entry to
// compare against, so there is nothing newer to have missed.
if (liveXml && liveXml !== lastSeenXml) {
// A bare <mxGraphModel> on either side carries no page name, so
// comparing names would mismatch against anything not called
// "Page-1". Compare cell trees only in that case.
const includeNames =
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
if (
contentFingerprint(liveXml, includeNames) !==
contentFingerprint(lastSeenXml, includeNames)
)
return { ok: false, reason: "stale" }
}
return { ok: true }
}

View File

@@ -93,6 +93,7 @@ interface SessionState {
svg?: string // Cached SVG from last browser save svg?: string // Cached SVG from last browser save
syncRequested?: number // Timestamp when sync requested, cleared when browser responds syncRequested?: number // Timestamp when sync requested, cleared when browser responds
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
exportXml?: string // Single-page projection to load before a page-targeted export
exportData?: string // Base64/SVG data returned by browser after export exportData?: string // Base64/SVG data returned by browser after export
} }
@@ -117,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
svg: svg || existing?.svg, // Preserve cached SVG if not provided svg: svg || existing?.svg, // Preserve cached SVG if not provided
syncRequested: undefined, // Clear sync request when browser pushes state syncRequested: undefined, // Clear sync request when browser pushes state
exportFormat: existing?.exportFormat, // Preserve pending export request exportFormat: existing?.exportFormat, // Preserve pending export request
exportXml: existing?.exportXml, // Preserve pending projection
exportData: existing?.exportData, // Preserve export result exportData: existing?.exportData, // Preserve export result
}) })
log.debug(`State updated: session=${sessionId}, version=${newVersion}`) log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
return newVersion return newVersion
} }
/**
* Ask the browser bridge to export the current diagram as png/svg.
*
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
* first, waits for draw.io's own load event, exports, then reloads the
* session's real document — so a page-targeted export never mutates the
* canonical session state and needs no fixed-delay guessing on the server.
*
* Returns false when the session is unknown. Callers should then poll
* `getState(sessionId)?.exportData` for the result.
*/
export function requestExport(
sessionId: string,
format: "png" | "svg",
projectionXml?: string,
): boolean {
const state = stateStore.get(sessionId)
if (!state) return false
state.exportData = undefined
state.exportXml = projectionXml
state.exportFormat = format
return true
}
export function requestSync(sessionId: string): boolean { export function requestSync(sessionId: string): boolean {
const state = stateStore.get(sessionId) const state = stateStore.get(sessionId)
if (state) { if (state) {
@@ -286,6 +312,7 @@ function handleStateApi(
version: state?.version || 0, version: state?.version || 0,
syncRequested: !!state?.syncRequested, syncRequested: !!state?.syncRequested,
exportFormat: state?.exportFormat || null, exportFormat: state?.exportFormat || null,
exportXml: state?.exportXml || null,
}), }),
) )
} else if (req.method === "POST") { } else if (req.method === "POST") {
@@ -305,6 +332,7 @@ function handleStateApi(
if (state) { if (state) {
state.exportData = data.exportData state.exportData = data.exportData
state.exportFormat = undefined state.exportFormat = undefined
state.exportXml = undefined
log.debug( log.debug(
`Export data received for session=${sessionId}`, `Export data received for session=${sessionId}`,
) )
@@ -675,6 +703,8 @@ function getHtmlPage(sessionId: string): string {
let pendingSvgExport = null; let pendingSvgExport = null;
let pendingAiSvg = false; let pendingAiSvg = false;
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return; if (e.origin !== '${DRAWIO_ORIGIN}') return;
@@ -684,6 +714,10 @@ function getHtmlPage(sessionId: string): string {
isReady = true; isReady = true;
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) { } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Ignore autosave while a single-page projection is on screen
// for a page-targeted export — otherwise we'd push the
// transient projection back as the canonical session state.
if (projectionExportActive) return;
// Request SVG export, then push state with SVG // Request SVG export, then push state with SVG
pendingSvgExport = msg.xml; pendingSvgExport = msg.xml;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*'); iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
@@ -704,6 +738,9 @@ function getHtmlPage(sessionId: string): string {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d }) body: JSON.stringify({ sessionId, exportData: d })
}).catch(() => {}); }).catch(() => {});
// Page-targeted export: restore the user's real
// multi-page document now that we have the image.
restoreFromProjection();
return; return;
} }
} }
@@ -761,6 +798,22 @@ function getHtmlPage(sessionId: string): string {
} }
} }
// Restore the user's real document after a page-targeted projection
// export. If we never captured one (lastXml was null at projection
// start), fall back to forcing a reload from the server on the next
// poll by rewinding currentVersion — never leave the iframe stuck on
// the transient projection.
function restoreFromProjection() {
if (!projectionExportActive) return;
projectionExportActive = false;
if (projectionRestoreXml) {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
projectionRestoreXml = null;
} else {
currentVersion = -1; // force the next poll to reload from server
}
}
async function pushState(xml, svg = '') { async function pushState(xml, svg = '') {
if (!sessionId) return; if (!sessionId) return;
try { try {
@@ -786,20 +839,54 @@ function getHtmlPage(sessionId: string): string {
pendingSyncExport = true; pendingSyncExport = true;
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*'); iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
} }
// Load new diagram from server (before export, so we export latest) // Load new diagram from server (before export, so we export latest).
if (s.version > currentVersion && s.xml) { // While a page-targeted projection is on screen, skip the reload
// so it doesn't fight the projection — and leave currentVersion
// unadvanced so this bump is re-detected and applied once the
// real document is restored.
if (s.version > currentVersion && s.xml && !projectionExportActive) {
currentVersion = s.version; currentVersion = s.version;
loadDiagram(s.xml, true); loadDiagram(s.xml, true);
} }
// Handle export request from MCP server (png/svg) - after version update // Handle export request from MCP server (png/svg).
//
// Plain export: capture whatever tab is currently displayed.
//
// Page-targeted export: the server sends a single-page <mxfile>
// projection in s.exportXml. We load it into the iframe, let
// draw.io render it, export, then reload the user's real
// document — all browser-side. The canonical session state is
// never mutated, so there is no server-side restore race and no
// dependence on poll timing. autosave is suppressed while the
// projection is showing (see projectionExportActive guard).
if (s.exportFormat && !pendingMcpExport && isReady) { if (s.exportFormat && !pendingMcpExport && isReady) {
pendingMcpExport = s.exportFormat; pendingMcpExport = s.exportFormat;
const exportOpts = s.exportFormat === 'png' const fireExport = () => {
const exportOpts = pendingMcpExport === 'png'
? { action: 'export', format: 'png', scale: 2 } ? { action: 'export', format: 'png', scale: 2 }
: { action: 'export', format: 'svg' }; : { action: 'export', format: 'svg' };
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*'); iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
// Timeout: reset if draw.io never responds };
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000); if (s.exportXml) {
// Stash the real document so we can restore after export.
projectionRestoreXml = lastXml;
projectionExportActive = true;
// Load the projection without touching lastXml/server state.
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
// Let draw.io render the loaded page before exporting
// (same proven settle delay as the AI-preview path).
setTimeout(fireExport, 600);
} else {
fireExport();
}
// Timeout: reset if draw.io never responds, and restore the
// real document if a projection was left showing.
setTimeout(() => {
if (pendingMcpExport) {
pendingMcpExport = null;
restoreFromProjection();
}
}, 10000);
} }
} catch {} } catch {}
} }
@@ -839,7 +926,11 @@ function getHtmlPage(sessionId: string): string {
saveConfirmBtn.textContent = 'Exporting...'; saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') { if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports) // Use lastXml directly instead of requesting export (avoids race with SVG exports).
// session.xml is canonically <mxfile> after the multi-page refactor,
// so no wrapper injection is needed. The legacy fallback below
// remains only for documents that somehow slipped past
// normalisation (e.g. an older session loaded from external state).
let xmlData = lastXml || ''; let xmlData = lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) { if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>'; xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
/**
* File-loading helpers for the load_diagram tool.
*
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
* <mxGraphModel> either as plain XML or — draw.io's default save format —
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
* diagram's text content. The rest of the server assumes plain XML inside
* every <diagram>, so loading decompresses all pages up front.
*/
import { inflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import {
isMxFile,
isMxGraphModel,
normalizeToMxfile,
parseMxfile,
serializeMxfile,
} from "./pages.js"
export type LoadResult =
| { ok: true; xml: string }
| { ok: false; error: string }
/**
* Decode one compressed page body (base64 → raw deflate → URI-decode).
* Returns null if the text isn't in that format.
*/
export function decompressPageContent(compressed: string): string | null {
try {
const inflated = inflateRawSync(
Buffer.from(compressed.trim(), "base64"),
).toString("utf-8")
try {
return decodeURIComponent(inflated)
} catch {
// Not URI-encoded (older files) — the inflated text is the XML.
return inflated
}
} catch {
return null
}
}
/**
* Parse the content of a .drawio file into the canonical session shape:
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
* any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
const trimmed = content.trim()
if (!trimmed) return { ok: false, error: "File is empty." }
if (isMxGraphModel(trimmed)) {
const normalized = normalizeToMxfile(trimmed)
return normalized
? { ok: true, xml: normalized }
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
}
if (!isMxFile(trimmed)) {
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
}
}
const doc = parseMxfile(trimmed)
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
let decompressedAny = false
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
if (d.querySelector("mxGraphModel")) continue
const text = (d.textContent || "").trim()
if (!text) continue // an empty page is valid
const pageLabel =
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
const xml = decompressPageContent(text)
if (!xml || !isMxGraphModel(xml)) {
return {
ok: false,
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
}
}
const inner = new DOMParser().parseFromString(xml, "text/xml")
if (
inner.querySelector("parsererror") ||
inner.documentElement?.tagName !== "mxGraphModel"
) {
return {
ok: false,
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
}
}
d.textContent = ""
d.appendChild(
doc.importNode(inner.documentElement as unknown as Node, true),
)
decompressedAny = true
}
// Nothing changed — keep the file's own serialisation.
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
}

View File

@@ -0,0 +1,316 @@
/**
* Multi-page (mxfile) helpers for draw.io diagrams.
*
* The on-disk and embed-protocol shape of a draw.io document is:
*
* <mxfile host="...">
* <diagram id="..." name="...">
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
* </diagram>
* ...one or more <diagram> children...
* </mxfile>
*
* This module centralises page CRUD so that index.ts, xml-validation.ts,
* and diagram-operations.ts can all agree on:
* - what "the canonical in-memory shape" is (always mxfile),
* - how to find a page (id, name, or index),
* - how to add/rename/delete pages without re-parsing ad-hoc.
*/
import { DOMParser } from "linkedom"
export interface PageInfo {
id: string
name: string
index: number
cellCount: number
}
/** Selector used by all multi-page-aware tools. All fields optional. */
export interface PageSelector {
page_id?: string
page_name?: string
page_index?: number
}
/** True if the selector targets a specific page (any field set). */
export function hasPageSelector(s?: PageSelector | null): boolean {
if (!s) return false
return (
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
)
}
/**
* Generate a short page id similar in shape to drawio's auto-assigned ids.
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
* uses short ids; collisions are still astronomically unlikely for one session.
*/
export function generatePageId(): string {
const a = Math.random().toString(36).substring(2, 10)
const b = Math.random().toString(36).substring(2, 6)
return `${a}-${b}`
}
/** Cheap regex check — does the XML start with an <mxfile> root? */
export function isMxFile(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
}
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
export function isMxGraphModel(xml: string): boolean {
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
/**
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
* only permits the declaration at the very start of a document, so embedding
* a declaration inside another element produces invalid XML. Callers must
* strip before splicing a fragment into a wrapper.
*/
function stripXmlDeclaration(xml: string): string {
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
}
/**
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
* If the input is already an mxfile, returns it unchanged.
* If the input is neither shape, returns null so the caller can surface a clear error.
*
* Strips any leading <?xml ?> declaration before embedding — a declaration is
* only valid at the very start of a document, never inside a <diagram>.
*/
export function normalizeToMxfile(
xml: string,
opts: { pageId?: string; pageName?: string; host?: string } = {},
): string | null {
const trimmed = xml.trim()
if (!trimmed) return null
if (isMxFile(trimmed)) return trimmed
if (!isMxGraphModel(trimmed)) return null
const pageId = opts.pageId || generatePageId()
const pageName = opts.pageName || "Page-1"
const host = opts.host || "app.diagrams.net"
const inner = stripXmlDeclaration(trimmed)
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
}
/**
* Parse an mxfile XML string. Returns null on parse error or if the root
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
*/
export function parseMxfile(xml: string): Document | null {
try {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (doc.querySelector("parsererror")) return null
if (doc.documentElement?.tagName !== "mxfile") return null
return doc as unknown as Document
} catch {
return null
}
}
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
export function serializeMxfile(doc: Document): string {
const serializer = new XMLSerializer()
return serializer.serializeToString(doc)
}
export type PageProjection =
| { ok: true; xml: string; index: number; name: string }
| { ok: false; reason: "parse" | "notfound" }
/**
* Project a single page out of an mxfile string into a standalone one-page
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
* share one parse → find → serialise path.
*
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
* or { ok:false, reason:"notfound" } if the selector matches no page.
*/
export function projectPage(
xml: string,
selector: PageSelector,
): PageProjection {
const doc = parseMxfile(xml)
if (!doc) return { ok: false, reason: "parse" }
const found = findPageElement(doc, selector)
if (!found) return { ok: false, reason: "notfound" }
const serializer = new XMLSerializer()
return {
ok: true,
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
index: found.index,
name: found.element.getAttribute("name") || "",
}
}
/** Walk every <diagram> child of <mxfile> and return summary info. */
export function listPagesFromDoc(doc: Document): PageInfo[] {
const diagrams = doc.querySelectorAll("diagram")
const result: PageInfo[] = []
diagrams.forEach((d, idx) => {
const root = d.querySelector("root")
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
result.push({
id: d.getAttribute("id") || "",
name: d.getAttribute("name") || `Page-${idx + 1}`,
index: idx,
cellCount,
})
})
return result
}
/**
* Resolve a page selector to its <diagram> element.
* Resolution order: page_id → page_name → page_index → default (first page).
*
* When no selector field is set we return the first page — the "active page
* by convention" mentioned in §3.4 of the design doc.
*/
export function findPageElement(
doc: Document,
selector?: PageSelector,
): { element: Element; index: number } | null {
const diagrams = Array.from(doc.querySelectorAll("diagram"))
if (diagrams.length === 0) return null
if (!hasPageSelector(selector)) {
return { element: diagrams[0], index: 0 }
}
if (selector?.page_id) {
for (let i = 0; i < diagrams.length; i++) {
if (diagrams[i].getAttribute("id") === selector.page_id) {
return { element: diagrams[i], index: i }
}
}
return null
}
if (selector?.page_name) {
for (let i = 0; i < diagrams.length; i++) {
if (diagrams[i].getAttribute("name") === selector.page_name) {
return { element: diagrams[i], index: i }
}
}
return null
}
if (selector && selector.page_index !== undefined) {
const idx = selector.page_index
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
return { element: diagrams[idx], index: idx }
}
return null
}
return null
}
/**
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
*
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
* end up nested inside <diagram>, which is malformed. We reject the mxfile
* shape explicitly and strip any <?xml ?> declaration (only valid at
* document start, never inside <diagram>).
*
* Returns the new PageInfo. Throws if the requested id collides or the xml
* shape is wrong.
*/
export function addPageToDoc(
doc: Document,
opts: { id?: string; name?: string; xml?: string } = {},
): PageInfo {
const existing = listPagesFromDoc(doc)
const id = opts.id || generatePageId()
if (existing.some((p) => p.id === id)) {
throw new Error(`Page id "${id}" already exists`)
}
const name = opts.name || `Page-${existing.length + 1}`
let inner: string
if (opts.xml?.trim()) {
const trimmed = stripXmlDeclaration(opts.xml.trim())
if (isMxFile(trimmed)) {
throw new Error(
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
)
}
if (!isMxGraphModel(trimmed)) {
throw new Error(
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
)
}
inner = trimmed
} else {
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
}
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
if (tempDoc.querySelector("parsererror")) {
throw new Error(
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
)
}
const newDiagram = tempDoc.querySelector("diagram")
if (!newDiagram) {
throw new Error("Failed to construct <diagram> element for new page")
}
const imported = doc.importNode(newDiagram, true) as Element
doc.documentElement.appendChild(imported)
return {
id,
name,
index: existing.length,
cellCount: imported.querySelectorAll("mxCell").length,
}
}
/** Rename the page matched by selector. Returns true on success. */
export function renamePageInDoc(
doc: Document,
selector: PageSelector,
newName: string,
): boolean {
const found = findPageElement(doc, selector)
if (!found) return false
found.element.setAttribute("name", newName)
return true
}
/**
* Delete a page. Refuses to delete the last remaining page — the embed needs
* at least one diagram to render anything, and silently recreating one would
* be surprising behaviour for an MCP caller.
*/
export function deletePageFromDoc(
doc: Document,
selector: PageSelector,
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
const pages = listPagesFromDoc(doc)
if (pages.length <= 1) {
return { ok: false, reason: "Cannot delete the only remaining page" }
}
const found = findPageElement(doc, selector)
if (!found) {
return { ok: false, reason: "Page not found" }
}
const id = found.element.getAttribute("id") || ""
const index = found.index
found.element.parentNode?.removeChild(found.element)
return { ok: true, deletedId: id, deletedIndex: index }
}

View File

@@ -119,8 +119,74 @@ function checkDuplicateAttributes(xml: string): string | null {
return null return null
} }
/** Check for duplicate IDs in XML */ /**
* Check for duplicate IDs in XML.
*
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
* IDs are unique **within a page**, not across the whole document — drawio
* legitimately reuses "0" and "1" for the root cells of every page. So we
* scope the cell-ID uniqueness check per <diagram>, and additionally check
* that the <diagram> ids themselves are unique.
*
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
* and for XML that won't DOM-parse.
*/
function checkDuplicateIds(xml: string): string | null { function checkDuplicateIds(xml: string): string | null {
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
// bare <mxGraphModel> inputs (the overwhelming majority of historic
// traffic), the cheap regex fallback at the bottom is enough. A quick
// string check avoids paying the DOMParser cost on every call.
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
if (mightBeMxFile)
try {
const doc = new DOMParser().parseFromString(xml, "text/xml")
if (!doc.querySelector("parsererror")) {
const rootEl = doc.documentElement
if (rootEl && rootEl.tagName === "mxfile") {
const diagrams = doc.querySelectorAll("diagram")
// 1) <diagram> ids must be unique across the file.
const diagramIds = new Map<string, number>()
diagrams.forEach((d) => {
const id = d.getAttribute("id")
if (id)
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
})
const dupDiagrams = Array.from(diagramIds.entries())
.filter(([, c]) => c > 1)
.map(([id]) => `'${id}'`)
if (dupDiagrams.length > 0) {
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
}
// 2) Within each page, mxCell ids must be unique.
for (let i = 0; i < diagrams.length; i++) {
const diagram = diagrams[i]
const pageId =
diagram.getAttribute("id") || `(index ${i})`
const cells = diagram.querySelectorAll("mxCell")
const cellIds = new Map<string, number>()
cells.forEach((c) => {
const id = c.getAttribute("id")
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
})
const dups = Array.from(cellIds.entries())
.filter(([, c]) => c > 1)
.map(([id, count]) => `'${id}' (${count}x)`)
if (dups.length > 0) {
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
}
}
return null
}
}
} catch {
// fall through to regex
}
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
const ids = new Map<string, number>() const ids = new Map<string, number>()
let idMatch let idMatch
@@ -770,7 +836,14 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`) fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
} }
// 22. Fix duplicate IDs by appending suffix // 22. Fix duplicate IDs by appending suffix.
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
// across pages legitimately (every page has its own <root> with id="0"/"1"
// sentinel cells). Renaming them would break drawio's parent references.
// For mxfile inputs, duplicate-id validation is page-scoped in
// checkDuplicateIds() and a true duplicate produces a hard error rather
// than a silent rename.
if (!/<mxfile[\s>]/i.test(fixed)) {
const seenIds = new Map<string, number>() const seenIds = new Map<string, number>()
const duplicateIds: string[] = [] const duplicateIds: string[] = []
@@ -787,7 +860,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
if (duplicateIds.length > 0) { if (duplicateIds.length > 0) {
const idCounters = new Map<string, number>() const idCounters = new Map<string, number>()
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => { fixed = fixed.replace(
/\bid\s*=\s*["']([^"']+)["']/gi,
(match, id) => {
if (!duplicateIds.includes(id)) return match if (!duplicateIds.includes(id)) return match
const count = idCounters.get(id) || 0 const count = idCounters.get(id) || 0
@@ -797,9 +872,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
const newId = `${id}_dup${count}` const newId = `${id}_dup${count}`
return match.replace(id, newId) return match.replace(id, newId)
}) },
)
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`) fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
} }
}
// 23. Fix empty id attributes // 23. Fix empty id attributes
let emptyIdCount = 0 let emptyIdCount = 0

View File

@@ -0,0 +1,132 @@
/**
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
*
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
* allowed when the model has seen the current browser state, no matter how
* long ago — and rejected when the browser state moved since. "Seen" is
* judged structurally, so draw.io's re-serialisation of the same content
* (attribute order, whitespace, viewport attributes, wrapper shape) never
* reads as a user edit.
*/
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
})
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
// The same document as draw.io re-serialises it on autosave: different host,
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
// cell attributes, pretty-printed whitespace.
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
<diagram id="regenerated-id" name="Page-1">
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>`
// A real user edit: box1 moved to a different position.
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
// Bare mxGraphModel with identical page content to XML_A.
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
describe("checkEditGate", () => {
it("rejects when no diagram context was ever established", () => {
expect(checkEditGate("", XML_A)).toEqual({
ok: false,
reason: "no-context",
})
})
it("allows when the browser state is exactly what the model saw", () => {
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
})
it("allows when the browser state is a re-serialisation of the same content", () => {
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
})
it("rejects when a cell actually changed", () => {
expect(checkEditGate(XML_A, XML_B)).toEqual({
ok: false,
reason: "stale",
})
})
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
const movedAndReserialized = XML_A_RESERIALIZED.replace(
'x="40" y="40"',
'x="300" y="200"',
)
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
ok: false,
reason: "stale",
})
})
it("allows when the store has no live entry to compare against", () => {
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
})
// A bare <mxGraphModel> push carries no page name, so the gate must not
// compare the invented "Page-1" wrapper name against the real one.
it("allows a bare mxGraphModel push when the page has a custom name", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
})
it("still rejects a bare mxGraphModel push whose cells changed", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
ok: false,
reason: "stale",
})
})
})
describe("contentFingerprint", () => {
it("is invariant under draw.io re-serialisation", () => {
expect(contentFingerprint(XML_A)).toBe(
contentFingerprint(XML_A_RESERIALIZED),
)
})
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
})
it("changes when a cell attribute changes", () => {
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
})
it("changes when a page is renamed", () => {
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
})
it("changes when a page is added", () => {
const twoPages = XML_A.replace(
"</mxfile>",
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
)
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
})
it("falls back to the raw string for unparseable input", () => {
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
})
})

View File

@@ -0,0 +1,126 @@
/**
* Unit tests for load_diagram's file parsing (load-diagram.ts).
*
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
* as draw.io's compressed default (encodeURIComponent → raw deflate →
* base64 text content). The loader must produce the canonical session
* shape: an <mxfile> whose every page is plain XML.
*/
import { deflateRawSync } from "node:zlib"
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
// Install the DOM polyfills exactly as index.ts does at runtime.
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) return node.outerHTML
if (node.documentElement) return node.documentElement.outerHTML
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
})
import {
decompressPageContent,
parseDrawioFileContent,
} from "../src/load-diagram.js"
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
/** Compress a page body exactly the way draw.io does when saving. */
function drawioCompress(xml: string): string {
return deflateRawSync(
Buffer.from(encodeURIComponent(xml), "utf-8"),
).toString("base64")
}
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
describe("decompressPageContent", () => {
it("round-trips draw.io's compressed format", () => {
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
})
it("handles non-URI-encoded legacy payloads", () => {
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
"base64",
)
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
})
it("returns null for garbage", () => {
expect(decompressPageContent("not base64 deflate")).toBeNull()
})
})
describe("parseDrawioFileContent", () => {
it("passes a plain-XML mxfile through unchanged", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
})
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
const r = parseDrawioFileContent(MODEL_XML)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxfile")
expect(r.xml).toContain('value="Hello"')
}
})
it("decompresses a compressed mxfile into plain XML pages", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxGraphModel")
expect(r.xml).toContain('value="Hello"')
// The compressed blob must be gone.
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
}
})
it("decompresses only the compressed pages of a mixed file", () => {
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
const r = parseDrawioFileContent(mixed)
expect(r.ok).toBe(true)
if (r.ok) {
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const diagrams = Array.from(
doc.querySelectorAll("diagram"),
) as Element[]
expect(diagrams).toHaveLength(2)
for (const d of diagrams) {
expect(d.querySelector("mxGraphModel")).not.toBeNull()
}
}
})
it("keeps empty pages as-is", () => {
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
const r = parseDrawioFileContent(withEmpty)
expect(r).toEqual({ ok: true, xml: withEmpty })
})
it("rejects empty files", () => {
const r = parseDrawioFileContent(" ")
expect(r.ok).toBe(false)
})
it("rejects non-drawio content", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
})
it("rejects a page whose content is neither XML nor compressed", () => {
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
const r = parseDrawioFileContent(bad)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain('"Broken"')
})
})

View File

@@ -0,0 +1,545 @@
/**
* Unit tests for multi-page (mxfile) support.
*
* Pinned to the user-visible contract described in
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
*
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
* AC2. get_diagram returns the full <mxfile> regardless of page count.
* AC3. edit_diagram accepts an optional page selector.
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
*
* These tests pin the helpers (pages.ts), the validator update
* (xml-validation.ts), and the page-targeted edit logic
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
*/
import { DOMParser } from "linkedom"
import { beforeAll, describe, expect, it } from "vitest"
// Install the DOM polyfill exactly as index.ts does at runtime — the
// helpers under test rely on it.
beforeAll(() => {
;(globalThis as any).DOMParser = DOMParser
class XMLSerializerPolyfill {
serializeToString(node: any): string {
if (node.outerHTML !== undefined) return node.outerHTML
if (node.documentElement) return node.documentElement.outerHTML
return ""
}
}
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
})
import { applyDiagramOperations } from "../src/diagram-operations.js"
import {
addPageToDoc,
deletePageFromDoc,
findPageElement,
generatePageId,
hasPageSelector,
isMxFile,
isMxGraphModel,
listPagesFromDoc,
normalizeToMxfile,
parseMxfile,
projectPage,
renamePageInDoc,
serializeMxfile,
} from "../src/pages.js"
import { validateAndFixXml } from "../src/xml-validation.js"
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
describe("pages.ts — shape detection", () => {
it("isMxFile detects a multi-page mxfile", () => {
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
})
it("isMxFile rejects a bare mxGraphModel", () => {
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
})
it("isMxGraphModel detects a bare model", () => {
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
})
it("isMxFile tolerates an XML declaration prefix", () => {
expect(
isMxFile(
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
),
).toBe(true)
})
})
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
pageId: "p1",
pageName: "Page-1",
})
expect(out).not.toBeNull()
expect(out).toMatch(/^<mxfile/)
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
expect(out).toContain("<mxGraphModel>")
})
it("returns mxfile inputs unchanged", () => {
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
expect(out).toBe(TWO_PAGE_MXFILE)
})
it("returns null for neither shape", () => {
expect(normalizeToMxfile("<random/>")).toBeNull()
expect(normalizeToMxfile("")).toBeNull()
})
it("generated page ids look reasonable", () => {
for (let i = 0; i < 50; i++) {
const id = generatePageId()
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
}
})
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
// declaration prefix, but the wrapper used to embed it inside
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
// document start). The result must round-trip through parseMxfile
// and the declaration must be gone from inside <diagram>.
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
const out = normalizeToMxfile(withDecl, {
pageId: "p1",
pageName: "Page-1",
})
expect(out).not.toBeNull()
expect(out).toMatch(/^<mxfile/)
// No <?xml inside the body of the wrapped document.
expect(out!.indexOf("<?xml")).toBe(-1)
// And it must still parse cleanly.
const doc = parseMxfile(out!)
expect(doc).not.toBeNull()
expect(listPagesFromDoc(doc!)).toHaveLength(1)
})
})
describe("pages.ts — addPageToDoc input validation", () => {
it("rejects opts.xml shaped as a full <mxfile>", () => {
// Regression for the Copilot-flagged bug: an mxfile passed as
// starting page xml would end up nested inside <diagram>, corrupting
// the document. Must throw with a clear message.
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
).toThrowError(/bare <mxGraphModel>/i)
})
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
).toThrowError(/bare <mxGraphModel>/i)
})
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
expect(info.cellCount).toBeGreaterThanOrEqual(3)
// Serialised document must not have <?xml ?> inside <diagram>.
const out = serializeMxfile(doc)
// The mxfile may have one <?xml ?> at the very start (the doc decl),
// but no further occurrence inside <diagram>.
const matches = out.match(/<\?xml/g) || []
expect(matches.length).toBeLessThanOrEqual(1)
})
})
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
it("lists both pages in a two-page mxfile", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const pages = listPagesFromDoc(doc)
expect(pages).toHaveLength(2)
expect(pages[0]).toMatchObject({
id: "page-transformer",
name: "Transformer",
index: 0,
})
expect(pages[1]).toMatchObject({
id: "page-cnn",
name: "CNN",
index: 1,
})
// Cell count is per-page (3 cells per page including the two root sentinels).
expect(pages[0].cellCount).toBe(3)
expect(pages[1].cellCount).toBe(3)
})
it("findPageElement defaults to the first page when selector is empty", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const found = findPageElement(doc)
expect(found?.index).toBe(0)
expect(found?.element.getAttribute("id")).toBe("page-transformer")
})
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
// id beats name beats index
const winner = findPageElement(doc, {
page_id: "page-cnn",
page_name: "Transformer",
page_index: 0,
})
expect(winner?.index).toBe(1)
})
it("findPageElement returns null for an unknown selector", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
})
it("hasPageSelector correctly detects empty vs populated selectors", () => {
expect(hasPageSelector()).toBe(false)
expect(hasPageSelector({})).toBe(false)
expect(hasPageSelector({ page_id: "x" })).toBe(true)
expect(hasPageSelector({ page_index: 0 })).toBe(true)
})
})
describe("pages.ts — addPageToDoc", () => {
it("appends a third page and returns its info", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const info = addPageToDoc(doc, { name: "Sequence" })
expect(info.name).toBe("Sequence")
expect(info.index).toBe(2)
expect(info.id).toMatch(/.+/)
const pages = listPagesFromDoc(doc)
expect(pages).toHaveLength(3)
expect(pages[2].name).toBe("Sequence")
})
it("rejects a duplicate explicit id", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(() =>
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
).toThrowError(/already exists/)
})
it("uses a sensible default name when none is supplied", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const info = addPageToDoc(doc, {})
expect(info.name).toBe("Page-3")
})
it("accepts an inline starting mxGraphModel", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
expect(info.cellCount).toBeGreaterThanOrEqual(3)
})
})
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
it("renames an existing page by name", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
expect(ok).toBe(true)
const pages = listPagesFromDoc(doc)
expect(pages[1].name).toBe("CNN-v2")
})
it("rename returns false when target page is missing", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
})
it("deletes a page and removes the <diagram> element from the doc", () => {
const doc = parseMxfile(TWO_PAGE_MXFILE)!
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
expect(outcome.ok).toBe(true)
expect(outcome.deletedId).toBe("page-cnn")
expect(listPagesFromDoc(doc)).toHaveLength(1)
})
it("refuses to delete the only remaining page", () => {
// Build a single-page doc to test the guard.
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
const doc = parseMxfile(single)!
const outcome = deletePageFromDoc(doc, { page_index: 0 })
expect(outcome.ok).toBe(false)
expect(outcome.reason).toMatch(/only remaining page/)
})
})
describe("xml-validation.ts — multi-page support", () => {
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
const result = validateAndFixXml(TWO_PAGE_MXFILE)
expect(result.valid).toBe(true)
expect(result.error).toBeNull()
})
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
// This is the regression the planning doc explicitly called out:
// before this work, the legacy regex-based duplicate-id check rejected
// any multi-page document because cells "0" and "1" appear in every page.
const result = validateAndFixXml(TWO_PAGE_MXFILE)
expect(result.valid).toBe(true)
})
it("rejects duplicate cell ids WITHIN a single page", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
expect(result.valid).toBe(false)
expect(result.error).toMatch(/duplicate cell ID/i)
})
it("rejects duplicate <diagram> ids across the file", () => {
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(bad)
expect(result.valid).toBe(false)
expect(result.error).toMatch(/duplicate <diagram> id/i)
})
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
expect(result.valid).toBe(true)
})
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
// but contains valid multi-page 0/1 cells that must NOT be renamed.
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const result = validateAndFixXml(malformedButMultiPage)
// The doc has an unescaped & — autoFix will repair that. After repair
// it should be valid AND must not have renamed the 0/1 cells.
const finalXml = result.fixed || malformedButMultiPage
expect(finalXml).not.toMatch(/id="0_dup/)
expect(finalXml).not.toMatch(/id="1_dup/)
})
})
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
const { result, errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[
{
operation: "add",
cell_id: "conv-2",
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
},
],
{ page_id: "page-cnn" },
)
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
// Transformer untouched (still 3 cells), CNN gained one cell.
expect(pages[0].cellCount).toBe(3)
expect(pages[1].cellCount).toBe(4)
expect(result).toContain(`id="conv-2"`)
})
it("defaults to the first page when no selector is given", () => {
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
{
operation: "add",
cell_id: "shape-x",
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
},
])
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
expect(pages[1].cellCount).toBe(3) // CNN untouched
})
it("errors clearly when the page is not found", () => {
const { errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[
{
operation: "delete",
cell_id: "2",
},
],
{ page_id: "does-not-exist" },
)
expect(errors).toHaveLength(1)
expect(errors[0].message).toMatch(/Page.*not found/i)
// Page-level errors carry an empty cellId — edit_diagram relies on
// this to distinguish "nothing applied" from per-cell warnings and
// return a hard error instead of a false success.
expect(errors[0].cellId).toBe("")
})
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
// Both pages have a cell with id="2". A delete on CNN's "2" must not
// remove Transformer's "2".
const { result, errors } = applyDiagramOperations(
TWO_PAGE_MXFILE,
[{ operation: "delete", cell_id: "2" }],
{ page_id: "page-cnn" },
)
expect(errors).toHaveLength(0)
const doc = parseMxfile(result)!
const pages = listPagesFromDoc(doc)
// CNN lost its only non-sentinel cell, Transformer keeps its three.
expect(pages[1].cellCount).toBe(2)
expect(pages[0].cellCount).toBe(3)
})
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
{
operation: "add",
cell_id: "new",
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
},
])
expect(errors).toHaveLength(0)
expect(result).toContain(`id="new"`)
})
it("page selector on a bare mxGraphModel returns a clear error", () => {
const { errors } = applyDiagramOperations(
BARE_MODEL_ONE_CELL,
[{ operation: "delete", cell_id: "2" }],
{ page_id: "page-1" },
)
expect(errors).toHaveLength(1)
expect(errors[0].message).toMatch(/not multi-page/i)
})
})
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
// The previous implementation tried to drive drawio's iframe with an
// `action: 'selectPage'` postMessage, which the embed protocol silently
// ignores. The result was that PNG/SVG exports targeted the currently
// active tab regardless of the page selector — two visually different
// pages would yield byte-identical PNGs.
//
// The current implementation builds a single-page <mxfile> projection via
// the shared pages.ts:projectPage helper and hands it to the browser
// bridge to load BEFORE triggering export. These tests pin that helper so
// a future refactor can't silently re-introduce the multi-page drift.
function projectSinglePage(fullMxfile: string, sel: any): string {
const result = projectPage(fullMxfile, sel)
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
return result.xml
}
it("returns a parse error for a non-mxfile source", () => {
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("parse")
})
it("returns a notfound error for an unknown page", () => {
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe("notfound")
})
it("projects only the requested page when targeted by id", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-cnn",
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].id).toBe("page-cnn")
expect(pages[0].name).toBe("CNN")
// The projection must NOT contain the Transformer page anywhere.
expect(projected).not.toContain('id="page-transformer"')
expect(projected).not.toContain('name="Transformer"')
})
it("projects only the requested page when targeted by name", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_name: "Transformer",
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].name).toBe("Transformer")
expect(projected).not.toContain('id="page-cnn"')
})
it("projects only the requested page when targeted by index", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_index: 1,
})
const pages = listPagesFromDoc(parseMxfile(projected)!)
expect(pages).toHaveLength(1)
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
expect(pages[0].id).toBe("page-cnn")
})
it("two different page selectors produce visually distinct projections", () => {
// The regression: under the old selectPage bug, two exports would
// return the same active tab. With the projection approach, the
// payload that drawio renders is provably different.
const a = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-transformer",
})
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
expect(a).not.toBe(b)
expect(a).toContain('"Encoder"')
expect(a).not.toContain('"Conv1"')
expect(b).toContain('"Conv1"')
expect(b).not.toContain('"Encoder"')
})
it("the projection parses to a valid one-page mxfile", () => {
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
page_id: "page-cnn",
})
// Validator accepts it.
expect(validateAndFixXml(projected).valid).toBe(true)
// And it has a real <root> with the cells from the source page.
const doc = parseMxfile(projected)!
const root = doc.querySelector("root")
expect(root).not.toBeNull()
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
expect(conv1).not.toBeNull()
})
})
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
// Step 1 — caller passes a single-page mxfile.
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
pageId: "page-transformer",
pageName: "Transformer",
})
expect(step1).not.toBeNull()
let xml = step1 as string
const validate1 = validateAndFixXml(xml)
expect(validate1.valid).toBe(true)
// Step 2 — equivalent of add_page("CNN") with a starting model.
const doc = parseMxfile(xml)!
addPageToDoc(doc, {
id: "page-cnn",
name: "CNN",
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
})
xml = serializeMxfile(doc)
// Now: two pages, both valid, with the right names.
const pages = listPagesFromDoc(parseMxfile(xml)!)
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
expect(validateAndFixXml(xml).valid).toBe(true)
})
})

View File

@@ -0,0 +1,142 @@
/**
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
* and drive it the way a real MCP client does — initialize handshake,
* tools/list — to catch registration/schema regressions that the unit tests
* (which import helpers directly) can't see.
*
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
* dist/index.js and was therefore never run in CI (CI doesn't build this
* package before testing). Running from source via tsx means it executes as
* part of the normal `vitest run`.
*
* We deliberately do NOT call start_session — it would open a real browser
* window via open(). The browser bridge is covered by the Playwright e2e suite.
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const entry = path.resolve(__dirname, "..", "src", "index.ts")
const tsxBin = path.resolve(
__dirname,
"..",
"node_modules",
".bin",
process.platform === "win32" ? "tsx.cmd" : "tsx",
)
const EXPECTED_TOOLS = [
"start_session",
"create_new_diagram",
"load_diagram",
"edit_diagram",
"get_diagram",
"export_diagram",
"list_pages",
"add_page",
"rename_page",
"delete_page",
]
let proc: ChildProcessWithoutNullStreams
let stdoutBuf = ""
const pending = new Map<
number,
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
>()
let nextId = 1
function send(method: string, params: unknown, isNotification = false) {
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
if (!isNotification) msg.id = nextId++
proc.stdin.write(`${JSON.stringify(msg)}\n`)
if (isNotification) return Promise.resolve(undefined)
return new Promise<any>((resolve, reject) => {
const id = msg.id as number
const timeout = setTimeout(() => {
pending.delete(id)
reject(new Error(`Timed out waiting for response to ${method}`))
}, 15000)
pending.set(id, { resolve, reject, timeout })
})
}
beforeAll(async () => {
proc = spawn(tsxBin, [entry], {
stdio: ["pipe", "pipe", "pipe"],
}) as ChildProcessWithoutNullStreams
proc.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString()
const lines = stdoutBuf.split("\n")
stdoutBuf = lines.pop() || ""
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
let msg: any
try {
msg = JSON.parse(trimmed)
} catch {
// Non-JSON-RPC log line — ignore.
continue
}
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
if (p) {
clearTimeout(p.timeout)
pending.delete(msg.id)
p.resolve(msg)
}
}
})
const initResp = await send("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "wiring-test", version: "0.0.0" },
})
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
expect(initResp.result?.serverInfo?.name).toBeTruthy()
await send("notifications/initialized", {}, true)
}, 30000)
afterAll(() => {
proc?.kill("SIGTERM")
})
describe("MCP server wiring", () => {
it("registers all nine multi-page tools", async () => {
const resp = await send("tools/list", {})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
const names: string[] = (resp.result?.tools ?? []).map(
(t: { name: string }) => t.name,
)
for (const expected of EXPECTED_TOOLS) {
expect(names, `missing tool: ${expected}`).toContain(expected)
}
})
it("advertises page-selector params on edit_diagram", async () => {
const resp = await send("tools/list", {})
const edit = resp.result.tools.find(
(t: { name: string }) => t.name === "edit_diagram",
)
const props = edit?.inputSchema?.properties ?? {}
expect(props.page_id).toBeTruthy()
expect(props.page_name).toBeTruthy()
expect(props.page_index).toBeTruthy()
})
it("advertises name/id/xml on add_page", async () => {
const resp = await send("tools/list", {})
const addPage = resp.result.tools.find(
(t: { name: string }) => t.name === "add_page",
)
const props = addPage?.inputSchema?.properties ?? {}
expect(props.name).toBeTruthy()
expect(props.id).toBeTruthy()
expect(props.xml).toBeTruthy()
})
})

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
// The package source uses Node16 module resolution with explicit .js
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
// transparently, so no extra alias config is needed.
},
})

View File

@@ -0,0 +1,398 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
ADMIN_PROVIDERS_KEY,
adminProvidersToConfig,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
type StoredAdminProvider,
validateAdminProviders,
} from "@/lib/admin/providers"
import { _resetForTests, saveSettings } from "@/lib/admin/settings"
import { loadRawServerModelsConfig } from "@/lib/server-model-config"
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-"))
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
_resetForTests()
})
afterEach(() => {
_resetForTests()
delete process.env.SETTINGS_FILE
delete process.env.AI_MODELS_CONFIG
fs.rmSync(tmpDir, { recursive: true, force: true })
})
function provider(
overrides: Partial<StoredAdminProvider> = {},
): StoredAdminProvider {
return {
id: "p1",
provider: "openai",
apiKey: "sk-test",
models: ["gpt-5.2"],
...overrides,
}
}
describe("deriveEnvUpdates", () => {
it("writes credentials to ADMIN_-prefixed env vars (never shadows standard vars)", () => {
const updates = deriveEnvUpdates([provider()], [])
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
expect(updates.OPENAI_API_KEY).toBeUndefined()
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
// AI_MODELS_CONFIG is no longer written (merged at read time)
expect(updates.AI_MODELS_CONFIG).toBeNull()
})
it("suffixes env vars for a second instance of the same provider", () => {
const updates = deriveEnvUpdates(
[
provider({ id: "p1", name: "First" }),
provider({
id: "p2",
name: "Second",
apiKey: "sk-second",
models: ["gpt-5-mini"],
}),
],
[],
)
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
})
it("maps bedrock credentials to AWS env vars", () => {
const updates = deriveEnvUpdates(
[
provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA123",
awsSecretAccessKey: "secret",
awsRegion: "us-west-2",
models: ["claude-x"],
}),
],
[],
)
expect(updates.AWS_ACCESS_KEY_ID).toBe("AKIA123")
expect(updates.AWS_SECRET_ACCESS_KEY).toBe("secret")
expect(updates.AWS_REGION).toBe("us-west-2")
})
it("clears keys owned by the previous list when providers are removed", () => {
const prev = [provider()]
const updates = deriveEnvUpdates([], prev)
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
expect(updates.AI_MODELS_CONFIG).toBeNull()
expect(updates.ADMIN_PROVIDERS).toBeNull()
})
it("sets AI_PROVIDER/AI_MODEL only when a default is flagged", () => {
const noDefault = deriveEnvUpdates([provider()], [])
expect(noDefault.AI_PROVIDER).toBeNull()
expect(noDefault.AI_MODEL).toBeNull()
const updates = deriveEnvUpdates(
[
provider({ id: "p1" }),
provider({
id: "p2",
provider: "deepseek",
models: ["deepseek-chat"],
isDefault: true,
}),
],
[],
)
expect(updates.AI_PROVIDER).toBe("deepseek")
expect(updates.AI_MODEL).toBe("deepseek-chat")
})
})
describe("adminProvidersToConfig", () => {
it("builds a config with ADMIN_-prefixed apiKeyEnv wiring", () => {
const config = adminProvidersToConfig([provider()])
expect(config.providers).toHaveLength(1)
expect(config.providers[0].models).toEqual(["gpt-5.2"])
expect(config.providers[0].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY")
})
it("wires suffixed env vars for a second instance", () => {
const config = adminProvidersToConfig([
provider({ id: "p1", name: "First" }),
provider({
id: "p2",
name: "Second",
apiKey: "sk-second",
models: ["gpt-5-mini"],
}),
])
expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2")
})
it("skips providers without models and carries the default flag", () => {
const config = adminProvidersToConfig([
provider({ id: "p1", models: [] }),
provider({ id: "p2", name: "D", isDefault: true }),
])
expect(config.providers).toHaveLength(1)
expect(config.providers[0].default).toBe(true)
})
})
describe("mergeSecrets", () => {
it("keeps stored secret when client sends an isSet marker", () => {
const stored = [provider({ apiKey: "sk-original" })]
const merged = mergeSecrets(
[
{
...provider(),
apiKey: { isSet: true, hint: "…test" },
},
],
stored,
)
expect(merged[0].apiKey).toBe("sk-original")
})
it("replaces secret when client sends a plaintext string", () => {
const stored = [provider({ apiKey: "sk-original" })]
const merged = mergeSecrets(
[{ ...provider(), apiKey: "sk-new" }],
stored,
)
expect(merged[0].apiKey).toBe("sk-new")
})
it("clears secret when client sends undefined", () => {
const stored = [provider({ apiKey: "sk-original" })]
const merged = mergeSecrets(
[{ ...provider(), apiKey: undefined }],
stored,
)
expect(merged[0].apiKey).toBeUndefined()
})
})
describe("loadRawServerModelsConfig merge", () => {
it("combines env AI_MODELS_CONFIG with panel providers", async () => {
process.env.AI_MODELS_CONFIG = JSON.stringify({
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-from-env"],
default: true,
},
],
})
saveSettings(deriveEnvUpdates([provider({ name: "Panel" })], []))
const merged = await loadRawServerModelsConfig()
expect(merged?.providers.map((p) => p.name)).toEqual([
"Env OpenAI",
"Panel",
])
// Env default kept because panel set none
expect(merged?.providers[0].default).toBe(true)
})
it("panel default overrides the env default", async () => {
process.env.AI_MODELS_CONFIG = JSON.stringify({
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-from-env"],
default: true,
},
],
})
saveSettings(
deriveEnvUpdates(
[provider({ name: "Panel", isDefault: true })],
[],
),
)
const merged = await loadRawServerModelsConfig()
expect(merged?.providers[0].default).toBeFalsy()
expect(merged?.providers[1].default).toBe(true)
})
it("returns only env config when the panel has no providers", async () => {
process.env.AI_MODELS_CONFIG = JSON.stringify({
providers: [
{
name: "Env Only",
provider: "openai",
models: ["gpt-from-env"],
},
],
})
const merged = await loadRawServerModelsConfig()
expect(merged?.providers.map((p) => p.name)).toEqual(["Env Only"])
})
})
describe("validateAdminProviders", () => {
it("rejects names clashing with env-configured providers", () => {
expect(
validateAdminProviders([provider({ name: "Env OpenAI" })], {
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-x"],
},
],
}),
).toMatch(/already defined/)
})
it("rejects a global-credential provider already in the env config", () => {
expect(
validateAdminProviders(
[
provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA-panel",
awsSecretAccessKey: "panel-secret",
awsRegion: "us-east-1",
models: ["claude-x"],
}),
],
{
providers: [
{
name: "Env Bedrock",
provider: "bedrock",
models: ["claude-env"],
},
],
},
),
).toMatch(/shares global credentials/)
})
it("allows a normal provider type alongside the same env type", () => {
expect(
validateAdminProviders([provider({ name: "Panel OpenAI" })], {
providers: [
{
name: "Env OpenAI",
provider: "openai",
models: ["gpt-x"],
},
],
}),
).toBeNull()
})
it("rejects two bedrock instances", () => {
const list = [
provider({ id: "p1", provider: "bedrock" }),
provider({ id: "p2", provider: "bedrock" }),
]
expect(validateAdminProviders(list)).toMatch(/Only one/)
})
it("rejects duplicate display names", () => {
const list = [
provider({ id: "p1", name: "Same" }),
provider({ id: "p2", name: "Same" }),
]
expect(validateAdminProviders(list)).toMatch(/unique/)
})
it("rejects multiple defaults", () => {
const list = [
provider({ id: "p1", isDefault: true }),
provider({ id: "p2", name: "Other", isDefault: true }),
]
expect(validateAdminProviders(list)).toMatch(/default/)
})
it("accepts a valid list", () => {
const list = [
provider({ id: "p1", isDefault: true }),
provider({ id: "p2", name: "Backup" }),
]
expect(validateAdminProviders(list)).toBeNull()
})
})
describe("loadAdminProviders", () => {
it("returns [] when nothing is stored", () => {
expect(loadAdminProviders()).toEqual([])
})
it("loads valid stored providers", () => {
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([provider()]) })
expect(loadAdminProviders()).toHaveLength(1)
})
it("round-trips a bedrock provider with multiple string secrets", () => {
const bedrock = provider({
provider: "bedrock",
apiKey: undefined,
awsAccessKeyId: "AKIA123",
awsSecretAccessKey: "secret",
awsRegion: "us-west-2",
models: ["claude-x"],
})
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([bedrock]) })
const loaded = loadAdminProviders()
expect(loaded).toHaveLength(1)
expect(loaded[0].awsAccessKeyId).toBe("AKIA123")
expect(loaded[0].awsSecretAccessKey).toBe("secret")
})
it("drops malformed entries and keeps valid ones", () => {
saveSettings({
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
provider({ id: "good" }),
{ id: "missing-fields" }, // no provider/models
{ provider: "openai", models: ["x"] }, // no id
"not-an-object",
]),
})
const loaded = loadAdminProviders()
expect(loaded).toHaveLength(1)
expect(loaded[0].id).toBe("good")
})
it("returns [] when the stored value is not an array", () => {
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify({ nope: true }) })
expect(loadAdminProviders()).toEqual([])
})
it("returns [] on invalid JSON", () => {
saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" })
expect(loadAdminProviders()).toEqual([])
})
it("drops entries whose secret is an {isSet} marker, not a string", () => {
// A hand-edited file could hold a transit-only marker object; if it
// slipped through, maskSecret() would throw on a non-string value.
saveSettings({
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
{ ...provider(), apiKey: { isSet: true, hint: "…1234" } },
]),
})
const loaded = loadAdminProviders()
expect(loaded).toEqual([])
// Masking the loaded list must not throw
expect(() => maskAdminProviders(loaded)).not.toThrow()
})
})

View File

@@ -0,0 +1,158 @@
import fs from "fs"
import os from "os"
import path from "path"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
_resetForTests,
applyToEnv,
getEnvFallback,
getValueSource,
isSettingsWritable,
loadSettings,
saveSettings,
} from "@/lib/admin/settings"
let tmpDir: string
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-settings-"))
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
_resetForTests()
})
afterEach(() => {
_resetForTests()
delete process.env.SETTINGS_FILE
fs.rmSync(tmpDir, { recursive: true, force: true })
delete process.env.TEST_ADMIN_VAR
})
describe("loadSettings", () => {
it("returns empty object when file does not exist", () => {
expect(loadSettings()).toEqual({})
})
it("reads values from the settings file", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: { TEST_ADMIN_VAR: "abc" } }),
)
expect(loadSettings()).toEqual({ TEST_ADMIN_VAR: "abc" })
})
it("drops non-string values from a corrupted file", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({
version: 1,
values: {
GOOD: "ok",
NUM: 5,
OBJ: { nested: true },
ARR: [1, 2],
NULL: null,
},
}),
)
expect(loadSettings()).toEqual({ GOOD: "ok" })
})
it("returns empty object when values is null", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: null }),
)
expect(loadSettings()).toEqual({})
})
it("returns empty object when values is an array (no numeric keys)", () => {
fs.writeFileSync(
process.env.SETTINGS_FILE!,
JSON.stringify({ version: 1, values: ["a", "b"] }),
)
// Without the Array.isArray guard this would yield { "0": "a", ... }
expect(loadSettings()).toEqual({})
})
})
describe("applyToEnv / saveSettings", () => {
it("overlays file values onto process.env", () => {
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
})
it("file value wins over pre-existing env value", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(process.env.TEST_ADMIN_VAR).toBe("from-file")
})
it("deleting a key restores the original env value", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBe("from-env")
})
it("deleting a key unsets env when there was no original value", () => {
saveSettings({ TEST_ADMIN_VAR: "from-file" })
saveSettings({ TEST_ADMIN_VAR: null })
expect(process.env.TEST_ADMIN_VAR).toBeUndefined()
})
it("persists across cache reset (file round-trip)", () => {
saveSettings({ TEST_ADMIN_VAR: "persisted" })
_resetForTests()
applyToEnv()
expect(process.env.TEST_ADMIN_VAR).toBe("persisted")
})
})
describe("getValueSource / getEnvFallback", () => {
it("reports file source when key is in settings", () => {
saveSettings({ TEST_ADMIN_VAR: "x" })
expect(getValueSource("TEST_ADMIN_VAR")).toBe("file")
})
it("reports env source when only env is set", () => {
process.env.TEST_ADMIN_VAR = "from-env"
applyToEnv()
expect(getValueSource("TEST_ADMIN_VAR")).toBe("env")
})
it("reports default when neither is set", () => {
expect(getValueSource("TEST_ADMIN_VAR")).toBe("default")
})
it("returns the shadowed env value as fallback", () => {
process.env.TEST_ADMIN_VAR = "from-env"
saveSettings({ TEST_ADMIN_VAR: "from-file" })
expect(getEnvFallback("TEST_ADMIN_VAR")).toBe("from-env")
})
})
describe("isSettingsWritable", () => {
it("returns true for a writable temp dir", () => {
expect(isSettingsWritable()).toBe(true)
})
it("returns false for an unwritable path", () => {
_resetForTests()
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
expect(isSettingsWritable()).toBe(false)
})
})
describe("settings file on disk", () => {
it("writes valid JSON with restrictive permissions", () => {
saveSettings({ TEST_ADMIN_VAR: "secret" })
const filePath = process.env.SETTINGS_FILE!
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"))
expect(parsed).toEqual({
version: 1,
values: { TEST_ADMIN_VAR: "secret" },
})
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
})
})

View File

@@ -1,10 +1,35 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { import {
getAIModel, getAIModel,
isAihubmixStandardBaseURL,
resolveBaseURL, resolveBaseURL,
supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import { extractAihubmixModelIds } from "@/lib/aihubmix-models"
describe("extractAihubmixModelIds", () => {
it("extracts unique chat model IDs from the AIHubMix model list payload", () => {
const models = extractAihubmixModelIds({
data: [
{ model_id: "claude-sonnet-4-5-20250929", types: "llm" },
{ model_id: "gpt-5.1", types: "llm" },
{ model_id: "gpt-5.1", types: "llm" },
{ model_id: "gpt-image-2", types: "image_generation,llm" },
{ model_id: "cohere-rerank-v4.0", types: "rerank" },
{ model_id: "", types: "llm" },
{ types: "llm" },
],
})
expect(models).toEqual(["claude-sonnet-4-5-20250929", "gpt-5.1"])
})
it("returns an empty list for malformed payloads", () => {
expect(extractAihubmixModelIds({ data: null })).toEqual([])
expect(extractAihubmixModelIds({})).toEqual([])
expect(extractAihubmixModelIds(null)).toEqual([])
})
})
describe("resolveBaseURL", () => { describe("resolveBaseURL", () => {
const SERVER_BASE_URL = "https://server-proxy.example.com" const SERVER_BASE_URL = "https://server-proxy.example.com"
@@ -157,89 +182,6 @@ describe("supportsPromptCaching", () => {
}) })
}) })
describe("supportsImageInput", () => {
it("returns true for models with vision capability", () => {
expect(supportsImageInput("gpt-4-vision")).toBe(true)
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("deepseek-vl")).toBe(true)
})
it("returns false for Kimi K2 models without vision", () => {
expect(supportsImageInput("kimi-k2")).toBe(false)
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
})
it("returns true for Kimi K2.5 models (supports vision)", () => {
expect(supportsImageInput("kimi-k2.5")).toBe(true)
expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true)
})
it("returns false for Moonshot v1 text models", () => {
expect(supportsImageInput("moonshot-v1-8k")).toBe(false)
expect(supportsImageInput("moonshot-v1-32k")).toBe(false)
expect(supportsImageInput("moonshot-v1-128k")).toBe(false)
})
it("returns false for MiniMax M2 text models", () => {
expect(supportsImageInput("MiniMax-M2.7")).toBe(false)
expect(supportsImageInput("MiniMax-M2.7-highspeed")).toBe(false)
expect(supportsImageInput("MiniMax-M2")).toBe(false)
})
it("returns true for MiniMax M3 (supports image input)", () => {
expect(supportsImageInput("MiniMax-M3")).toBe(true)
})
it("returns false for DeepSeek text models", () => {
expect(supportsImageInput("deepseek-chat")).toBe(false)
expect(supportsImageInput("deepseek-coder")).toBe(false)
})
it("returns false for Qwen text models", () => {
expect(supportsImageInput("qwen-turbo")).toBe(false)
expect(supportsImageInput("qwen-plus")).toBe(false)
expect(supportsImageInput("qwen3-max")).toBe(false)
})
it("returns true for Qwen vision models", () => {
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("Qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5")).toBe(true)
expect(supportsImageInput("qwen3.5-plus")).toBe(true)
expect(supportsImageInput("qwen3.5-flash")).toBe(true)
expect(supportsImageInput("qwen3-vl-plus")).toBe(true)
expect(supportsImageInput("qwen3-vl-flash")).toBe(true)
})
it("returns true for QvQ (Qwen Visual QA) models including OpenRouter-prefixed names", () => {
expect(supportsImageInput("qvq-72b-preview")).toBe(true)
expect(supportsImageInput("qvq-max")).toBe(true)
expect(supportsImageInput("qwen/qvq-72b-preview")).toBe(true)
expect(supportsImageInput("qwen/qvq-max")).toBe(true)
})
it("returns false for GLM text models", () => {
expect(supportsImageInput("glm-4")).toBe(false)
expect(supportsImageInput("glm-4-plus")).toBe(false)
expect(supportsImageInput("glm-4-flash")).toBe(false)
expect(supportsImageInput("glm-4-long")).toBe(false)
expect(supportsImageInput("glm-4.7")).toBe(false)
expect(supportsImageInput("glm-5")).toBe(false)
})
it("returns true for GLM vision models", () => {
expect(supportsImageInput("glm-4v")).toBe(true)
expect(supportsImageInput("glm-4v-9b")).toBe(true)
expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true)
})
it("returns true for Claude and GPT models by default", () => {
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
expect(supportsImageInput("gpt-4o")).toBe(true)
expect(supportsImageInput("gemini-pro")).toBe(true)
})
})
vi.mock("ollama-ai-provider-v2", () => { vi.mock("ollama-ai-provider-v2", () => {
const mockModel = { modelId: "test-model" } const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel) const mockProviderFn = vi.fn(() => mockModel)
@@ -256,6 +198,128 @@ vi.mock("@ai-sdk/deepseek", () => {
return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek } return { createDeepSeek: mockCreateDeepSeek, deepseek: mockDeepseek }
}) })
vi.mock("@aihubmix/ai-sdk-provider", () => {
const mockModel = { modelId: "test-model" }
const mockProviderFn = vi.fn(() => mockModel)
const mockCreateAihubmix = vi.fn(() => mockProviderFn)
const mockAihubmix = vi.fn(() => mockModel)
return { aihubmix: mockAihubmix, createAihubmix: mockCreateAihubmix }
})
vi.mock("@ai-sdk/openai", () => {
const mockModel = { modelId: "test-model" }
const mockChat = vi.fn(() => mockModel)
const mockProviderFn = vi.fn(() => mockModel) as any
mockProviderFn.chat = mockChat
const mockCreateOpenAI = vi.fn(() => mockProviderFn)
const mockOpenai = vi.fn(() => mockModel)
return { createOpenAI: mockCreateOpenAI, openai: mockOpenai }
})
describe("AIHubMix provider", () => {
let createAihubmixMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.AIHUBMIX_API_KEY = process.env.AIHUBMIX_API_KEY
savedEnv.AIHUBMIX_BASE_URL = process.env.AIHUBMIX_BASE_URL
delete process.env.AIHUBMIX_BASE_URL
const mod = await import("@aihubmix/ai-sdk-provider")
createAihubmixMock = mod.createAihubmix as ReturnType<typeof vi.fn>
createAihubmixMock.mockClear()
})
afterEach(() => {
process.env.AIHUBMIX_API_KEY = savedEnv.AIHUBMIX_API_KEY
process.env.AIHUBMIX_BASE_URL = savedEnv.AIHUBMIX_BASE_URL
})
it("uses AIHUBMIX_API_KEY for server configured AIHubMix", () => {
process.env.AIHUBMIX_API_KEY = "server-aihubmix-key"
getAIModel({
provider: "aihubmix",
modelId: "claude-sonnet-4-5-20250929",
})
expect(createAihubmixMock).toHaveBeenCalledWith({
apiKey: "server-aihubmix-key",
appCode: "MSBS9675",
})
})
it("uses client BYOK API key for AIHubMix", () => {
getAIModel({
provider: "aihubmix",
apiKey: "client-aihubmix-key",
modelId: "gpt-5.1",
})
expect(createAihubmixMock).toHaveBeenCalledWith({
apiKey: "client-aihubmix-key",
appCode: "MSBS9675",
})
})
it("recognizes AIHubMix standard endpoints", () => {
expect(isAihubmixStandardBaseURL(undefined)).toBe(true)
expect(isAihubmixStandardBaseURL("https://aihubmix.com")).toBe(true)
expect(isAihubmixStandardBaseURL("https://aihubmix.com/v1/")).toBe(true)
expect(isAihubmixStandardBaseURL("https://proxy.example.com/v1")).toBe(
false,
)
})
})
describe("Atlas Cloud provider", () => {
let createOpenAIMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {}
beforeEach(async () => {
savedEnv.ATLASCLOUD_API_KEY = process.env.ATLASCLOUD_API_KEY
savedEnv.ATLASCLOUD_BASE_URL = process.env.ATLASCLOUD_BASE_URL
delete process.env.ATLASCLOUD_BASE_URL
const mod = await import("@ai-sdk/openai")
createOpenAIMock = mod.createOpenAI as ReturnType<typeof vi.fn>
createOpenAIMock.mockClear()
})
afterEach(() => {
process.env.ATLASCLOUD_API_KEY = savedEnv.ATLASCLOUD_API_KEY
process.env.ATLASCLOUD_BASE_URL = savedEnv.ATLASCLOUD_BASE_URL
})
it("uses Atlas Cloud default endpoint with ATLASCLOUD_API_KEY", () => {
process.env.ATLASCLOUD_API_KEY = "server-atlas-key"
getAIModel({
provider: "atlascloud",
modelId: "qwen/qwen3.5-flash",
})
expect(createOpenAIMock).toHaveBeenCalledWith({
apiKey: "server-atlas-key",
baseURL: "https://api.atlascloud.ai/v1",
})
})
it("uses custom Atlas Cloud base URL when provided", () => {
getAIModel({
provider: "atlascloud",
apiKey: "client-atlas-key",
baseUrl: "https://proxy.example.com/v1",
modelId: "deepseek-ai/deepseek-v4-pro",
})
expect(createOpenAIMock).toHaveBeenCalledWith({
apiKey: "client-atlas-key",
baseURL: "https://proxy.example.com/v1",
})
})
})
describe("Kimi provider uses createDeepSeek for reasoning_content support", () => { describe("Kimi provider uses createDeepSeek for reasoning_content support", () => {
let createDeepSeekMock: ReturnType<typeof vi.fn> let createDeepSeekMock: ReturnType<typeof vi.fn>
const savedEnv: Record<string, string | undefined> = {} const savedEnv: Record<string, string | undefined> = {}

View File

@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vitest" import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { _resetForTests } from "@/lib/admin/settings"
import { import {
loadFlattenedServerModels, loadFlattenedServerModels,
type ServerModelsConfig, type ServerModelsConfig,
@@ -7,11 +8,20 @@ import {
const ORIGINAL_ENV = { ...process.env } const ORIGINAL_ENV = { ...process.env }
beforeEach(() => {
// Isolate from any local data/settings.json (admin panel providers
// are merged into the server models config)
process.env.SETTINGS_FILE = "/nonexistent/settings.json"
_resetForTests()
})
afterEach(() => { afterEach(() => {
_resetForTests()
process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER
process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL
process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH
process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG
delete process.env.SETTINGS_FILE
}) })
describe("ServerModelsConfigSchema", () => { describe("ServerModelsConfigSchema", () => {
@@ -29,6 +39,22 @@ describe("ServerModelsConfigSchema", () => {
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow() expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
}) })
it("accepts Atlas Cloud provider names", () => {
const config: ServerModelsConfig = {
providers: [
{
name: "Atlas Cloud Server",
provider: "atlascloud",
models: ["qwen/qwen3.5-flash"],
apiKeyEnv: "ATLASCLOUD_API_KEY",
baseUrlEnv: "ATLASCLOUD_BASE_URL",
},
],
}
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
})
it("rejects invalid provider names", () => { it("rejects invalid provider names", () => {
const invalidConfig = { const invalidConfig = {
providers: [ providers: [
@@ -149,6 +175,44 @@ describe("loadFlattenedServerModels", () => {
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
}) })
it("falls back to comma-separated AI_MODEL when no other config is set", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini, gpt-4o"
const models = await loadFlattenedServerModels()
// Trims, deduplicates, and preserves order
expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"])
expect(models.every((m) => m.provider === "openai")).toBe(true)
// First model is marked default (provider has default: true)
const defaults = models.filter((m) => m.isDefault)
expect(defaults.length).toBe(1)
expect(defaults[0].modelId).toBe("gpt-4o")
})
it("does not synthesize when AI_MODEL has no comma", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
process.env.AI_PROVIDER = "openai"
process.env.AI_MODEL = "gpt-4o"
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("does not synthesize when AI_PROVIDER is unset", async () => {
process.env.AI_MODELS_CONFIG = ""
process.env.AI_MODELS_CONFIG_PATH = `non-existent-config-${Date.now()}.json`
delete process.env.AI_PROVIDER
process.env.AI_MODEL = "gpt-4o, gpt-4o-mini"
const models = await loadFlattenedServerModels()
expect(models).toEqual([])
})
it("preserves apiKeyEnv array in flattened models for load balancing", async () => { it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
const config: ServerModelsConfig = { const config: ServerModelsConfig = {
providers: [ providers: [

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { isPrivateUrl } from "@/lib/ssrf-protection"
// Mock DNS so tests are deterministic and never hit the network.
const lookupMock = vi.hoisted(() => vi.fn())
vi.mock("node:dns/promises", () => ({
default: { lookup: lookupMock },
lookup: lookupMock,
}))
describe("isPrivateUrl", () => {
beforeEach(() => {
lookupMock.mockReset()
})
it("blocks private IPv6 URLs (string-only fast path, no DNS)", async () => {
expect(await isPrivateUrl("http://[::1]/")).toBe(true)
expect(await isPrivateUrl("http://[0:0:0:0:0:0:0:1]/")).toBe(true)
expect(await isPrivateUrl("http://[::]/")).toBe(true)
expect(await isPrivateUrl("http://[::ffff:127.0.0.1]/")).toBe(true)
expect(await isPrivateUrl("http://[fc00::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fd12:3456:789a::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fe80::1]/")).toBe(true)
expect(await isPrivateUrl("http://[fe9f::1]/")).toBe(true)
expect(await isPrivateUrl("http://[febf::1]/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("blocks literal private IPv4 without DNS", async () => {
expect(await isPrivateUrl("http://127.0.0.1/")).toBe(true)
expect(await isPrivateUrl("http://10.0.0.5/")).toBe(true)
expect(await isPrivateUrl("http://192.168.1.1/")).toBe(true)
expect(await isPrivateUrl("http://169.254.169.254/")).toBe(true)
expect(await isPrivateUrl("http://0.0.0.0/")).toBe(true)
// 100.64.0.0/10 CGNAT (RFC 6598), routable in some cloud internal nets
expect(await isPrivateUrl("http://100.64.0.1/")).toBe(true)
expect(await isPrivateUrl("http://100.127.255.255/")).toBe(true)
expect(lookupMock).not.toHaveBeenCalled()
})
it("treats CGNAT boundaries correctly", async () => {
// 100.63.x and 100.128.x are outside 100.64.0.0/10 → public
lookupMock.mockResolvedValue([{ address: "100.63.255.255", family: 4 }])
expect(await isPrivateUrl("http://just-below.example/")).toBe(false)
lookupMock.mockResolvedValue([{ address: "100.128.0.1", family: 4 }])
expect(await isPrivateUrl("http://just-above.example/")).toBe(false)
})
it("blocks a hostname that resolves to a private IPv6 address", async () => {
lookupMock.mockResolvedValue([{ address: "fd00::1", family: 6 }])
expect(await isPrivateUrl("http://v6.example.com/")).toBe(true)
})
it("allows public URLs that resolve to public IPs", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }])
expect(await isPrivateUrl("https://example.com/article")).toBe(false)
})
it("blocks public-looking hostnames that resolve to a private IP (DNS-rebinding-style bypass)", async () => {
// e.g. 127-0-0-1.sslip.io resolves to 127.0.0.1
lookupMock.mockResolvedValue([{ address: "127.0.0.1", family: 4 }])
expect(await isPrivateUrl("http://127-0-0-1.sslip.io/")).toBe(true)
})
it("blocks when any resolved address is private", async () => {
lookupMock.mockResolvedValue([
{ address: "93.184.216.34", family: 4 },
{ address: "10.1.2.3", family: 4 },
])
expect(await isPrivateUrl("http://mixed.example.com/")).toBe(true)
})
it("blocks when DNS resolution fails", async () => {
lookupMock.mockRejectedValue(new Error("ENOTFOUND"))
expect(await isPrivateUrl("http://does-not-resolve.example/")).toBe(
true,
)
})
})