Compare commits

...
Author SHA1 Message Date
dayuan.jiang 149ed34b9e feat(chat): show a quota hint when the server's key is refused
On the server's own credentials a provider 403 now gets its own code,
server_key_forbidden, with a hint in all four languages: today's free
quota is used up, it resets tomorrow, and users can add their own key
in model settings. The generic "The provider returned an error." line
is left out for this code.

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

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

Replace it with a per-session export job queue:

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

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

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

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

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

Fix: exclude strings starting with 'data:' from the fallback condition, so only
raw base64 strings (without a data URL prefix) match as PNG, while SVG data URLs
are correctly rejected.
2026-04-07 09:44:27 +08:00
Octopus 73788abff9 fix: remove redundant status(modified:false) call to restore undo/redo (fixes #779) 2026-04-04 09:54:20 +08:00
Xinyang Gao-PC de0ba6b718 Merge branch 'feature/add-more-themes' of github.com:xinyang20/next-ai-draw-io into feature/add-more-themes 2026-01-12 22:19:38 +08:00
Xinyang Gao-PC a8ff1ea2bc fix:add missing DRAWIO_THEMES import in settings-dialog 2026-01-12 22:17:18 +08:00
Xinyang Gao b723406a48 Merge branch 'main' into feature/add-more-themes 2026-01-12 22:15:40 +08:00
Xinyang Gao-PC 783cb4a6f1 fix:resolve the Copilot-suggested changes 2026-01-04 20:13:47 +08:00
Xinyang Gao-PC 801974a18b feat: add all Draw.io themes to settings panel (#499) 2026-01-04 19:41:49 +08:00
Your Name 5f71b2783f style: enhance UI with modern glassmorphism, animations and refined spacing 2025-12-19 15:11:42 +08:00
Your Name 691c7e34bf feat: image capability detection and user-friendly errors; warn and filter images for unsupported models 2025-12-19 14:12:31 +08:00
Your Name 0d50a179aa fix: skip image parts for DeepSeek to avoid unknown variant "image_url" 2025-12-19 12:07:06 +08:00
15 changed files with 571 additions and 1342 deletions
+24 -21
View File
@@ -77,6 +77,7 @@ export default function ExamplePanel({
minimal?: boolean
}) {
const dict = useDictionary()
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.")
@@ -125,29 +126,31 @@ export default function ExamplePanel({
<div className={minimal ? "" : "py-6 px-2 animate-fade-in"}>
{!minimal && (
<>
{/* MCP Server Notice */}
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
{/* MCP Server Notice, hidden on self-hosted deployments */}
{!isSelfHosted && (
<a
href="https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 p-3 rounded-xl bg-gradient-to-r from-purple-500/10 to-blue-500/10 border border-purple-500/20 hover:border-purple-500/40 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Terminal className="w-4 h-4 text-purple-500" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground group-hover:text-purple-500 transition-colors">
{dict.examples.mcpServer}
</span>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
<p className="text-xs text-muted-foreground">
{dict.examples.mcpDescription}
</p>
</div>
</div>
</a>
</a>
)}
{/* Welcome section */}
<div className="text-center mb-6">
+6 -3
View File
@@ -427,13 +427,16 @@ export default function ChatPanel({
let openModelConfig = false
if (data?.type === "provider") {
const hints = dict.errors.llm as Record<string, string>
text = hints[data.code]
? `${hints[data.code]}\n\n${data.message}`
: data.message
const hint = hints[data.code]
text =
hint && data.message
? `${hint}\n\n${data.message}`
: hint || data.message
openModelConfig = [
"invalid_api_key",
"forbidden",
"model_not_found",
"server_key_forbidden",
].includes(data.code)
} else if (typeof data?.error === "string") {
text = data.error
+2 -1
View File
@@ -1,6 +1,6 @@
# AI Provider Configuration
# AI_PROVIDER: Which provider to use
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, gateway, novita
# Options: bedrock, openai, anthropic, google, vertexai, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud
# Default: bedrock
AI_PROVIDER=bedrock
@@ -168,6 +168,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# which triggers the client UI to display messages suggesting self-hosting or sponsorship.
# This switch allows self-hosted users to provide custom messages in response to a 429 code,
# in messageTokenSelfHosted, messageApiSelfHosted, and tipSelfHosted translation strings.
# It also turns off the MCP server advertisement in the chat examples.
# NEXT_PUBLIC_SELFHOSTED=true
# Minimax Configuration (Optional)
+6 -1
View File
@@ -125,7 +125,12 @@ let writableCache: boolean | null = null
export function isSettingsWritable(): boolean {
if (writableCache !== null) return writableCache
try {
const dir = path.dirname(getSettingsPath())
const filePath = getSettingsPath()
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
writableCache = false
return writableCache
}
const dir = path.dirname(filePath)
fs.mkdirSync(dir, { recursive: true })
fs.accessSync(dir, fs.constants.W_OK)
writableCache = true
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
"server_key_forbidden": "Today's free quota is used up. It resets tomorrow. You can also add your own API key in model settings to keep going.",
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
"insufficient_quota": "The provider account has no credit or quota left.",
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
"server_key_forbidden": "本日の無料枠を使い切りました。明日になると自動的に回復します。モデル設定でご自身の API キーを入力すると、引き続きご利用いただけます。",
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
"server_key_forbidden": "今天的免費額度已經用完,明天會自動恢復。您也可以在模型設定中填寫自己的 API Key 繼續使用。",
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
+1
View File
@@ -192,6 +192,7 @@
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
"server_key_forbidden": "今天的免费额度已经用完,明天会自动恢复。您也可以在模型设置里填写自己的 API Key 继续使用。",
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
"insufficient_quota": "服务商账户的余额或额度已经用完。",
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
+9 -1
View File
@@ -24,6 +24,8 @@ export type LLMErrorCode =
| "provider_unavailable"
| "cannot_connect"
| "timeout"
// A 403 on the server's own key, e.g. a daily spend cap blocked it
| "server_key_forbidden"
| "unknown"
export interface LLMError {
@@ -130,7 +132,13 @@ export function streamErrorText(error: unknown, hideDetails = false): string {
const classified = classifyLLMError(error)
if (hideDetails) {
console.error("[chat] Provider error:", error)
classified.message = "The provider returned an error."
if (classified.code === "forbidden") {
// The hint says all the user can do; there is no message to add
classified.code = "server_key_forbidden"
classified.message = ""
} else {
classified.message = "The provider returned an error."
}
}
return JSON.stringify(classified)
}
+466 -1308
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -67,7 +67,6 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.300",
"base-64": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+27
View File
@@ -75,3 +75,30 @@ test("a provider rate limit is not shown as this site's quota", async ({
// The site's own tokens-per-minute toast
await expect(page.getByText("Rate limit reached")).toHaveCount(0)
})
test("a refused server key shows only the quota hint and a settings button", async ({
page,
}) => {
// What the chat route streams when the server's key gets a 403, e.g.
// after a daily spend cap blocked it
const errorText = JSON.stringify({
type: "provider",
code: "server_key_forbidden",
message: "",
})
await chatWith(page, {
status: 200,
contentType: "text/event-stream",
body: `data: {"type":"start"}\n\ndata: ${JSON.stringify({ type: "error", errorText })}\n\ndata: [DONE]\n\n`,
})
await expect(
page.getByText("Today's free quota is used up", { exact: false }),
).toBeVisible({ timeout: 15000 })
await expect(page.getByText("The provider returned an error")).toHaveCount(
0,
)
await page.getByRole("button", { name: "Open model settings" }).click()
await expect(
page.getByRole("dialog", { name: "AI Model Configuration" }),
).toBeVisible()
})
+6 -4
View File
@@ -154,9 +154,9 @@ describe("isSettingsWritable", () => {
expect(isSettingsWritable()).toBe(true)
})
it("returns false for an unwritable path", () => {
it("returns false when the settings path is a directory", () => {
_resetForTests()
process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json"
process.env.SETTINGS_FILE = tmpDir
expect(isSettingsWritable()).toBe(false)
})
})
@@ -170,7 +170,9 @@ describe("settings file on disk", () => {
version: 1,
values: { TEST_ADMIN_VAR: "secret" },
})
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
if (process.platform !== "win32") {
const mode = fs.statSync(filePath).mode & 0o777
expect(mode).toBe(0o600)
}
})
})
+2 -1
View File
@@ -139,7 +139,8 @@ describe("provider error texts in the stream", () => {
)
const message = await streamedError({})
expect(message).not.toMatch(/org-operator/)
expect(message).toBe("The provider returned an error.")
// A 403 on the server's key gets its own hint and no message
expect(message).toBe("")
})
})
+19 -1
View File
@@ -215,11 +215,29 @@ describe("streamErrorText", () => {
"User: arn:aws:sts::123456789012:assumed-role/app/s is not authorized to perform: bedrock:InvokeModel",
)
const hidden = JSON.parse(streamErrorText(error, true))
expect(hidden.code).toBe("forbidden")
expect(hidden.message).not.toMatch(/arn:aws|123456789012/)
expect(JSON.parse(streamErrorText(error)).message).toMatch(
/not authorized/,
)
const throttled = JSON.parse(
streamErrorText(apiError(429, "Too many tokens"), true),
)
expect(throttled).toEqual({
type: "provider",
code: "rate_limited",
message: "The provider returned an error.",
})
})
it("names a 403 on the server's keys, e.g. a spend cap blocked them", () => {
const error = apiError(403, "explicit deny in an identity-based policy")
expect(JSON.parse(streamErrorText(error, true))).toEqual({
type: "provider",
code: "server_key_forbidden",
message: "",
})
// On the user's own key it stays a plain refusal
expect(JSON.parse(streamErrorText(error)).code).toBe("forbidden")
})
it("classifies a provider error", () => {