Compare commits

..
Author SHA1 Message Date
dayuan jiang e7d104671a chore: align biome schema version with CLI latest
Bumps biome.json $schema from 2.4.4 to 2.4.14 so the repo schema
matches the version the 'Auto Format' workflow installs via
@biomejs/biome@latest. Also applies the one auto-fix the newer
version produces (export ordering in electron/electron.d.ts).

Fixes the spurious 'This PR has formatting issues' CI failure that
was blocking fork PRs unrelated to formatting.
2026-05-07 22:36:32 +09:00
189 changed files with 9116 additions and 26010 deletions
-5
View File
@@ -33,11 +33,6 @@
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
},
{
"matchPackageNames": ["@biomejs/biome"],
"groupName": "Biome",
"automerge": false
}
],
"vulnerabilityAlerts": {
+1 -3
View File
@@ -23,9 +23,7 @@ jobs:
node-version: '24'
- name: Run Biome format
# Pin to the version in package.json so CI matches local/pre-commit
# (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/[email protected] check --write --no-errors-on-unmatched .
run: npx @biomejs/biome@latest check --write --no-errors-on-unmatched .
- name: Check for changes
id: changes
+1 -2
View File
@@ -58,8 +58,6 @@ jobs:
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
@@ -91,3 +89,4 @@ jobs:
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 push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
-71
View File
@@ -1,71 +0,0 @@
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: Build and check package contents
if: steps.version.outputs.publish == 'true'
run: npm run build && npm run check-package
- name: Publish to npm
if: steps.version.outputs.publish == 'true'
run: npm publish
-14
View File
@@ -28,20 +28,6 @@ jobs:
- name: Run unit tests
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
# Tests run from src/, so check the built npm package separately
- name: Build MCP server and check package contents
run: npm --prefix packages/mcp-server run build && npm --prefix packages/mcp-server run check-package
e2e:
name: E2E Tests
runs-on: ubuntu-latest
-3
View File
@@ -76,6 +76,3 @@ ai-models.json
# local backups
*.bak
.gstack/
# admin panel settings (contains secrets)
data/
-3
View File
@@ -61,9 +61,6 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
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
EXPOSE 3000
+1 -31
View File
@@ -21,17 +21,6 @@ A Next.js web application that integrates AI capabilities with draw.io diagrams.
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
<p align="center">
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
</picture>
</a>
</p>
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -54,8 +43,6 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
- [Deploy on Vercel](#deploy-on-vercel)
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
- [Multi-Provider Support](#multi-provider-support)
- [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration)
- [Admin Panel](#admin-panel)
- [How It Works](#how-it-works)
- [Support \& Contact](#support--contact)
- [FAQ](#faq)
@@ -138,13 +125,6 @@ Then ask Claude to create diagrams:
The diagram appears in your browser in real-time!
The MCP server includes most of the web app's drawing features:
- The same drawing rules and shape libraries (AWS, Azure, GCP, Kubernetes and more)
- A screenshot tool, so the AI can check the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg`, or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so you can continue a diagram after a restart
See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
## Getting Started
@@ -229,13 +209,11 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
- SGLang
- Vercel AI Gateway
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
All providers except AWS Bedrock and OpenRouter support custom endpoints.
@@ -244,13 +222,7 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints.
### Server-Side Multi-Model Configuration
Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. For a single-provider quick setup, list comma-separated model IDs in `AI_MODEL`.
### Admin Panel
Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`.
📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes.
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.
**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.
@@ -272,8 +244,6 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
-65
View File
@@ -1,65 +0,0 @@
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
}
-621
View File
@@ -1,621 +0,0 @@
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,
generateId,
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
value=""
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,
envHasDefaultModel,
disabled,
password,
onChange,
}: {
providers: AdminProvider[]
envProviders: EnvProvider[]
envHasDefaultModel: boolean
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 = {
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
id: generateId(),
provider,
models: [],
// Only the very first provider becomes the default, and only when
// the env config has no default that it would replace on save
isDefault:
providers.length === 0 &&
!envProviders.some((p) => p.isDefault) &&
!envHasDefaultModel,
}
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">
{/* Always empty so picking the same type again still fires */}
<Select
value=""
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>
)
}
-645
View File
@@ -1,645 +0,0 @@
"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)]
// For each toggleable group, whether any of its settings has a value (from
// the settings file or the environment)
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
const result: Record<string, boolean> = {}
for (const group of SETTING_GROUPS) {
if (!group.toggleable) continue
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
}
return result
}
export default function AdminPage() {
const dict = useDictionary()
// Localized group title/description, keyed by group id
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[]>([])
// Whether .env itself sets AI_MODEL (a default the panel would override)
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
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)
// A group stays on while it still has values (e.g. from env vars
// that saving can't remove); a user's explicit "on" for a group
// with no values yet is kept across saves
setEnabledGroups((prev) => {
const next = groupsWithValues(map)
for (const id of Object.keys(next)) {
next[id] = next[id] || !!prev[id]
}
return next
})
},
[],
)
const applyProvidersResponse = useCallback(
(data: {
providers: AdminProvider[]
envProviders?: EnvProvider[]
envHasDefaultModel?: boolean
}) => {
setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? [])
setEnvHasDefaultModel(!!data.envHasDefaultModel)
},
[],
)
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])
// value undefined drops the pending change (back to the saved value)
const handleChange = useCallback(
(key: string, value: string | null | undefined) => {
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 (value === undefined || 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 of saved values; drop unsaved input
if (settings[key]?.source === "default")
delete next[key]
else next[key] = null
} else if (next[key] === null) {
delete next[key]
}
}
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}
envHasDefaultModel={envHasDefaultModel}
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]
// Values from env vars can't be removed here, so the
// group can't be turned off from the panel
const envLocked = defs.some(
(d) => settings[d.key]?.source === "env",
)
const fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id)
const title = gt?.title ?? group.title
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
title={
envLocked
? dict.admin.sourceEnvTitle
: undefined
}
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 ||
envLocked
}
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))
setEnabledGroups(
groupsWithValues(settings),
)
}}
>
{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>
)
}
-317
View File
@@ -1,317 +0,0 @@
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 to revert to on empty. Refreshed whenever the parent
// passes server state (a marker or nothing), e.g. after a save.
const [original, setOriginal] = useState(value)
if (typeof value !== "string" && value !== original) setOriginal(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
// undefined drops the pending change (back to the saved value)
onChange: (value: string | null | undefined) => 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">
{/* Clearing a saved secret reverts to it; the X button deletes */}
<SecretInput
id={inputId}
keepOnEmpty={source === "file"}
value={
isDirty
? (pendingValue ?? "")
: (secretState ?? undefined)
}
disabled={disabled}
onChange={(v) =>
onChange(typeof v === "string" ? v : undefined)
}
/>
</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>
)
}
+52 -23
View File
@@ -10,8 +10,8 @@ import {
ResizablePanelGroup,
} from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context"
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() {
const {
@@ -27,16 +27,19 @@ export default function Home() {
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
// Load preferences from localStorage after mount
useEffect(() => {
@@ -47,15 +50,13 @@ export default function Home() {
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
// Keep the query (e.g. ?session=) and hash
const { search, hash } = window.location
router.replace(`/${pathParts.join("/")}${search}${hash}`)
router.replace(`/${pathParts.join("/")}`)
return // Wait for redirect
}
}
const savedUi = localStorage.getItem("drawio-theme")
if (isDrawioTheme(savedUi)) {
if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi)
}
@@ -83,6 +84,11 @@ export default function Home() {
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true)
}, [pathname, router])
@@ -91,6 +97,13 @@ export default function Home() {
onDrawioLoad()
}, [onDrawioLoad])
const handleDrawioAutoSave = useCallback(
(data: { xml?: string }) => {
handleDiagramAutoSave(data)
},
[handleDiagramAutoSave],
)
const handleDarkModeChange = () => {
const newValue = !darkMode
setDarkMode(newValue)
@@ -100,24 +113,35 @@ export default function Home() {
resetDrawioReady()
}
const handleDrawioUiChange = (theme: DrawioTheme) => {
localStorage.setItem("drawio-theme", theme)
setDrawioUi(theme)
const handleDrawioUiChange = () => {
const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady()
}
// Check mobile. The draw.io iframe is not remounted when crossing the
// breakpoint (only the chat panel is), so its ready state stays as is.
// Check mobile - reset draw.io before crossing breakpoint
const isInitialRenderRef = useRef(true)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
const newIsMobile = window.innerWidth < 768
if (
!isInitialRenderRef.current &&
newIsMobile !== isMobileRef.current
) {
setIsDrawioReady(false)
resetDrawioReady()
}
isMobileRef.current = newIsMobile
isInitialRenderRef.current = false
setIsMobile(newIsMobile)
}
checkMobile()
window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile)
}, [])
}, [resetDrawioReady])
const toggleChatPanel = () => {
const panel = chatPanelRef.current
@@ -163,7 +187,7 @@ export default function Home() {
}`}
>
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && (
{isLoaded && canPersistChecked && (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
@@ -171,24 +195,29 @@ export default function Home() {
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef}
autosave
onAutoSave={handleDiagramAutoSave}
onAutoSave={handleDrawioAutoSave}
onExport={handleDiagramExport}
onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{
ui: drawioUi,
spin: false,
libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false,
noSaveBtn: true,
noExitBtn: true,
dark:
darkMode || drawioUi === "dark",
// draw.io names Traditional Chinese "zh-tw"
lang:
currentLang === "zh-Hant"
? "zh-tw"
: currentLang,
dark: darkMode,
lang: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
@@ -235,7 +264,7 @@ export default function Home() {
isVisible={isChatVisible}
onToggleVisibility={toggleChatPanel}
drawioUi={drawioUi}
onDrawioUiChange={handleDrawioUiChange}
onToggleDrawioUi={handleDrawioUiChange}
darkMode={darkMode}
onToggleDarkMode={handleDarkModeChange}
isMobile={isMobile}
-96
View File
@@ -1,96 +0,0 @@
import { checkAdminAuth } from "@/lib/admin/auth"
import {
AdminProvidersSchema,
deriveEnvUpdates,
loadAdminProviders,
maskAdminProviders,
mergeSecrets,
validateAdminProviders,
} from "@/lib/admin/providers"
import {
getEnvFallback,
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,
})) ?? [],
// Whether .env sets a default model. getEnvFallback skips the value
// the panel overlays onto process.env, so a panel default doesn't count.
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
}
}
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())
}
-126
View File
@@ -1,126 +0,0 @@
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(),
})
}
-66
View File
@@ -1,66 +0,0 @@
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,
}),
}),
)
}
+330 -241
View File
@@ -4,62 +4,42 @@ import {
createUIMessageStream,
createUIMessageStreamResponse,
InvalidToolInputError,
LoadAPIKeyError,
stepCountIs,
streamText,
} from "ai"
import fs from "fs/promises"
import { jsonrepair } from "jsonrepair"
import path from "path"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import {
CACHE_POINT,
getAIModel,
SINGLE_SYSTEM_PROVIDERS,
supportsImageInput,
supportsPromptCaching,
usesServerCredentials,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
dropInvalidToolCalls,
fixToolInputJson,
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import {
endTrace,
getTelemetryConfig,
setTraceInput,
setTraceOutput,
wrapWithObserve,
} from "@/lib/langfuse"
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors"
import {
resolveMaxOutputTokens,
withOutputTokenLimitFallback,
} from "@/lib/output-token-limit"
import {
type FlattenedServerModel,
findServerModelById,
} from "@/lib/server-model-config"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { findServerModelById } from "@/lib/server-model-config"
import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import {
getShapeLibrary,
SHAPE_LIBRARY_LIST,
} from "@/packages/mcp-server/src/shape-library.ts"
import { SWIMLANE_EXAMPLE } from "@/packages/mcp-server/src/xml-examples.ts"
// No explicit cap: a reasoning model can spend minutes planning before it emits
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
// which is also where Node's response-body timeout on the upstream stream lands.
export const maxDuration = 120
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
@@ -91,16 +71,24 @@ function createCachedStreamResponse(xml: string): Response {
return createUIMessageStreamResponse({ stream })
}
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>()
// Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
async function handleChatRequest(req: Request): Promise<Response> {
// Check for access code
const accessDenied = checkAccessCode(req)
if (accessDenied) return accessDenied
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
if (accessCodes.length > 0) {
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
return Response.json(
{
error: "Invalid or missing access code. Please configure it in Settings.",
},
{ status: 401 },
)
}
}
const body = await req.json()
const { messages, xml, previousXml, sessionId } = body
@@ -133,6 +121,36 @@ async function handleChatRequest(req: Request): Promise<Response> {
userId: userId,
})
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
const hasOwnApiKey = !!(
req.headers.get("x-ai-provider") &&
(req.headers.get("x-ai-api-key") ||
req.headers.get("x-aws-access-key-id") ||
req.headers.get("x-vertex-api-key"))
)
// Skip quota check if: quota disabled, user has own API key, or is anonymous
if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// === SERVER-SIDE QUOTA CHECK END ===
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) {
@@ -142,7 +160,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || !hasCells(xml)
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
@@ -169,15 +187,6 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrl = `${origin}/api/edgeai`
}
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
// not point the server at a private or internal address
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return Response.json(
{ error: "Private or internal base URLs are not allowed." },
{ status: 400 },
)
}
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie")
@@ -187,9 +196,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
baseUrlEnv?: string
provider?: string
} = {}
let serverModel: FlattenedServerModel | null = null
if (selectedModelId?.startsWith("server:")) {
serverModel = await findServerModelById(selectedModelId)
const serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
@@ -208,8 +216,7 @@ async function handleChatRequest(req: Request): Promise<Response> {
provider: serverModelConfig.provider || provider,
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
// A server model runs the model it was configured with, whatever the header says
modelId: serverModel?.modelId || req.headers.get("x-ai-model"),
modelId: req.headers.get("x-ai-model"),
// AWS Bedrock credentials
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
@@ -219,14 +226,11 @@ async function handleChatRequest(req: Request): Promise<Response> {
...serverModelConfig,
// Vertex AI credentials (Express Mode)
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication, and the access code,
// which the EdgeOne function checks too
...(provider === "edgeone" && {
headers: {
...(cookieHeader && { cookie: cookieHeader }),
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
// Pass cookies for EdgeOne Pages authentication
...(provider === "edgeone" &&
cookieHeader && {
headers: { cookie: cookieHeader },
}),
}
// Read minimal style preference from header
@@ -238,71 +242,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
// Get AI model with optional client overrides
const {
model: baseModel,
model,
providerOptions,
headers,
modelId,
provider: resolvedProvider,
} = getAIModel(clientOverrides)
// On the server's own keys, only run models the server offers: a server
// model picked by id (its model name is fixed above) or one in AI_MODEL.
// With their own key, users can run any model.
const onServerCredentials = usesServerCredentials(
resolvedProvider,
clientOverrides,
)
const envModels =
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
if (onServerCredentials && !serverModel && !envModels.includes(modelId)) {
return Response.json(
{
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
},
{ status: 400 },
)
}
// === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the
// server's keys. Decided by the key actually used: a key header the
// provider never reads must not skip it.
const countsQuota =
isQuotaEnabled() && onServerCredentials && userId !== "anonymous"
if (countsQuota) {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// === SERVER-SIDE QUOTA CHECK END ===
// Retry once if the provider rejects the requested budget, or (newer
// Claude models) the sampling or thinking settings
const model = withOutputTokenLimitFallback(
withDeprecatedParamsFallback(baseModel),
)
// The user setting can raise the budget only on their own key (in the
// desktop app every key is the user's); on the server's keys it can only
// lower it
const maxOutputTokens = resolveMaxOutputTokens(
req.headers.get("x-max-output-tokens"),
onServerCredentials,
)
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
// Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId)
console.log(
@@ -320,10 +266,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[]
// Note: we used to pre-emptively reject images for models we guessed were
// text-only (by name matching). That heuristic misfired on newer models
// (see issue #874), so we now let the request through and surface the real
// provider error if the model genuinely can't accept images.
// Check if user is sending images to a model that doesn't support them
// AI SDK silently drops unsupported parts, so we need to catch this early
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
return Response.json(
{
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
const formattedUserInput = `User input:
@@ -334,37 +286,35 @@ ${userInputText}
// Convert UIMessages to ModelMessages and add system message
const modelMessages = await convertToModelMessages(messages)
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
}
// DEBUG: Log incoming messages structure
console.log("[route.ts] Incoming messages count:", messages.length)
messages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] Message ${idx} role:`,
msg.role,
"parts count:",
msg.parts?.length,
)
if (msg.parts) {
msg.parts.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-invocation" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Part ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputKeys:
part.input && typeof part.input === "object"
? Object.keys(part.input)
: null,
})
}
})
}
})
// Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
@@ -382,43 +332,61 @@ ${userInputText}
)
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
// JSON object, and every provider rejects a tool result whose call is gone.
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model
if (DEBUG_LLM_PAYLOAD) {
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (
part.type === "tool-call" ||
part.type === "tool-result"
) {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
enhancedMessages = enhancedMessages
.map((msg: any) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const filteredContent = msg.content.filter((part: any) => {
if (part.type === "tool-call") {
// Check if input is a valid object (not null, undefined, or empty)
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
console.warn(
`[route.ts] Filtering out tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
return false
}
}
return true
})
return { ...msg, content: filteredContent }
})
}
.filter((msg: any) => msg.content && msg.content.length > 0)
// DEBUG: Log modelMessages structure (what's being sent to AI)
console.log("[route.ts] Model messages count:", enhancedMessages.length)
enhancedMessages.forEach((msg: any, idx: number) => {
console.log(
`[route.ts] ModelMsg ${idx} role:`,
msg.role,
"content count:",
msg.content?.length,
)
if (msg.content) {
msg.content.forEach((part: any, partIdx: number) => {
if (part.type === "tool-call" || part.type === "tool-result") {
console.log(`[route.ts] Content ${partIdx}:`, {
type: part.type,
toolName: part.toolName,
hasInput: !!part.input,
inputType: typeof part.input,
inputValue:
part.input === undefined
? "undefined"
: part.input === null
? "null"
: "object",
})
}
})
}
})
// Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) {
@@ -434,7 +402,7 @@ ${userInputText}
contentParts.push({
type: "image",
image: filePart.url,
mediaType: filePart.mediaType,
mimeType: filePart.mediaType,
})
}
@@ -454,7 +422,9 @@ ${userInputText}
if (enhancedMessages[i].role === "assistant") {
enhancedMessages[i] = {
...enhancedMessages[i],
providerOptions: CACHE_POINT,
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}
break // Only cache the last assistant message
}
@@ -493,7 +463,7 @@ ${previousXml}
${xml || ""}
"""
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed.`
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
const systemMessages = isSingleSystemProvider
? [
@@ -507,13 +477,21 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
{
role: "system" as const,
content: finalSystemMessage,
...(shouldCache && { providerOptions: CACHE_POINT }),
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
},
// Cache breakpoint 2: Previous and Current diagram XML context
{
role: "system" as const,
content: xmlContext,
...(shouldCache && { providerOptions: CACHE_POINT }),
...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
},
]
@@ -521,14 +499,10 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
const result = streamText({
model,
// The system messages carry cache points, so they go in messages.
// A client's own system messages have string content and were
// dropped by the empty-content filter above.
allowSystemInMessages: true,
abortSignal: req.signal,
// Must be sent: unset means the provider's own default, and Bedrock's is
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
maxOutputTokens,
...(process.env.MAX_OUTPUT_TOKENS && {
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
}),
stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => {
@@ -546,11 +520,23 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
error.name === "AI_InvalidToolInputError"
) {
try {
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
// then use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(
fixToolInputJson(toolCall.input),
)
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
let inputToRepair = toolCall.input
if (typeof inputToRepair === "string") {
// Fix `:=` instead of `: ` (LLM sometimes generates this)
inputToRepair = inputToRepair.replace(/:=/g, ": ")
// Fix `= "` instead of `: "`
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
// Fix inconsistent quote escaping in XML attributes within JSON strings
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
// Example: y="-20\" should be y=\"-20\"
inputToRepair = inputToRepair.replace(
/(\w+)="([^"]*?)\\"/g,
'$1=\\"$2\\"',
)
}
// Use jsonrepair to fix truncated JSON
const repairedInput = jsonrepair(inputToRepair)
console.log(
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
)
@@ -560,8 +546,26 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
repairError,
)
// Keep the original error, so the model and the client see why
// the input was rejected and the model can retry the call
// Return a placeholder input to avoid API errors in multi-step
// The tool will fail gracefully on client side
if (toolCall.toolName === "edit_diagram") {
return {
...toolCall,
input: {
operations: [],
_error: "JSON repair failed - no operations to apply",
},
}
}
if (toolCall.toolName === "display_diagram") {
return {
...toolCall,
input: {
xml: "",
_error: "JSON repair failed - empty diagram",
},
}
}
return null
}
}
@@ -570,6 +574,7 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
},
messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs
...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({
@@ -583,20 +588,21 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
// Record token usage for server-side quota tracking (if enabled)
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
// inputTokens already includes cache reads and writes in AI SDK 6
if (countsQuota && totalUsage) {
// Include all 4 token types: input, output, cache read, cache write
if (
isQuotaEnabled() &&
!hasOwnApiKey &&
userId !== "anonymous" &&
totalUsage
) {
const totalTokens =
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0)
(totalUsage.outputTokens || 0) +
(totalUsage.cachedInputTokens || 0) +
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
recordTokenUsage(userId, totalTokens)
}
},
// onFinish is skipped when the stream fails or is aborted, so end the trace here
onError: ({ error }) => {
console.error(error) // what AI SDK does without an onError
endTrace()
},
onAbort: () => endTrace(),
tools: {
// Client-side tool that will be executed on the client
display_diagram: {
@@ -611,7 +617,21 @@ VALIDATION RULES (XML will be rejected if violated):
6. Escape special chars in values: &lt; &gt; &amp; &quot;
Example (generate ONLY this - no wrapper tags):
${SWIMLANE_EXAMPLE}
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
Notes:
- For AWS diagrams, use **AWS 2025 icons**.
@@ -689,7 +709,14 @@ Example: If previous output ended with '<mxCell id="x" style="rounded=1', contin
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries:
${SHAPE_LIBRARY_LIST}
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
- Networking: cisco19, network, kubernetes, vvd, rack
- Business: bpmn, lean_mapping
- General: flowchart, basic, arrows2, infographic, sitemap
- UI/Mockups: android, material_design
- Enterprise: citrix, sap, mscae, atlassian
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
- Icons: webicons
Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({
@@ -700,12 +727,45 @@ Call this tool to get shape names and usage syntax for a specific library.`,
),
}),
execute: async ({ library }) => {
// Only known library names reach the file system
const result = await getShapeLibrary(
library,
path.join(process.cwd(), "docs/shape-libraries"),
// Sanitize input - prevent path traversal attacks
const sanitizedLibrary = library
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
if (sanitizedLibrary !== library.toLowerCase()) {
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
}
const baseDir = path.join(
process.cwd(),
"docs/shape-libraries",
)
return result.ok ? result.text : result.error
const filePath = path.join(
baseDir,
`${sanitizedLibrary}.md`,
)
// Verify path stays within expected directory
const resolvedPath = path.resolve(filePath)
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
return `Invalid library path.`
}
try {
const content = await fs.readFile(filePath, "utf-8")
return content
} catch (error) {
if (
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
}
console.error(
`[get_shape_library] Error loading "${library}":`,
error,
)
return `Error loading library "${library}". Please try again.`
}
},
},
},
@@ -714,9 +774,8 @@ Call this tool to get shape names and usage syntax for a specific library.`,
}),
})
const response = result.toUIMessageStreamResponse({
return result.toUIMessageStreamResponse({
sendReasoning: true,
onError: (error) => streamErrorText(error, onServerCredentials),
messageMetadata: ({ part }) => {
if (part.type === "finish") {
const usage = (part as any).totalUsage
@@ -729,28 +788,63 @@ Call this tool to get shape names and usage syntax for a specific library.`,
return undefined
},
})
modelStreamResponses.add(response)
return response
}
// Errors before the stream starts, as JSON the chat panel reads
// Helper to categorize errors and return appropriate response
function handleError(error: unknown): Response {
console.error("Error in chat route:", error)
const isDev = process.env.NODE_ENV === "development"
const classified = classifyLLMError(error)
const status =
(error as { statusCode?: number })?.statusCode ||
(error as { status?: number })?.status ||
(classified.code === "invalid_api_key" ? 401 : 500)
// Check for specific AI SDK error types
if (APICallError.isInstance(error)) {
return Response.json(
{
error: error.message,
...(isDev && {
details: error.responseBody,
stack: error.stack,
}),
},
{ status: error.statusCode || 500 },
)
}
if (LoadAPIKeyError.isInstance(error)) {
return Response.json(
{
error: "Authentication failed. Please check your API key.",
...(isDev && {
stack: error.stack,
}),
},
{ status: 401 },
)
}
// Fallback for other errors with safety filter
const message =
error instanceof Error ? error.message : "An unexpected error occurred"
const status = (error as any)?.statusCode || (error as any)?.status || 500
// Prevent leaking API keys, tokens, or other sensitive data
const lowerMessage = message.toLowerCase()
const safeMessage =
lowerMessage.includes("key") ||
lowerMessage.includes("token") ||
lowerMessage.includes("sig") ||
lowerMessage.includes("signature") ||
lowerMessage.includes("secret") ||
lowerMessage.includes("password") ||
lowerMessage.includes("credential")
? "Authentication failed. Please check your credentials."
: message
return Response.json(
{
...classified,
error: safeMessage,
...(isDev && {
details: APICallError.isInstance(error)
? error.responseBody
: undefined,
details: message,
stack: error instanceof Error ? error.stack : undefined,
}),
},
@@ -760,16 +854,11 @@ function handleError(error: unknown): Response {
// Wrap handler with error handling
async function safeHandler(req: Request): Promise<Response> {
let response: Response
try {
response = await handleChatRequest(req)
return await handleChatRequest(req)
} catch (error) {
response = handleError(error)
return handleError(error)
}
// Early returns, cache hits and errors never reach streamText's callbacks,
// so their Langfuse trace has to be ended here
if (!modelStreamResponses.has(response)) endTrace()
return response
}
// Wrap with Langfuse observe (if configured)
+1 -1
View File
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({
filename: z.string().min(1).max(255),
format: z.enum(["drawio", "png", "svg", "xmlsvg"]),
format: z.enum(["drawio", "png", "svg"]),
sessionId: z.string().min(1).max(200).optional(),
})
+30 -110
View File
@@ -1,69 +1,13 @@
import { extractFromHtml } from "@extractus/article-extractor"
import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server"
import TurndownService from "turndown"
import { checkAccessCode } from "@/lib/access-code"
import { isPrivateUrl } from "@/lib/ssrf-protection"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
// 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"
}
}
// Read the response body, giving up once it passes MAX_RESPONSE_BYTES so a
// huge download can't exhaust server memory. Returns null when too large.
async function readLimitedBody(
response: Response,
): Promise<ArrayBuffer | null> {
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
return null
}
if (!response.body) return new ArrayBuffer(0)
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel()
return null
}
chunks.push(value)
}
return new Blob(chunks as BlobPart[]).arrayBuffer()
}
export async function POST(req: Request) {
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const { url } = await req.json()
@@ -84,34 +28,22 @@ export async function POST(req: Request) {
)
}
// SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat).
if (await isPrivateUrl(url)) {
// SSRF protection
if (!allowPrivateUrls && isPrivateUrl(url)) {
return NextResponse.json(
{ error: "Cannot access private/internal URLs" },
{ status: 400 },
)
}
// Fetch the page ourselves so we control redirect handling. The
// 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
const headController = new AbortController()
const headTimeout = setTimeout(() => headController.abort(), 3000)
try {
const response = await fetch(url, {
const headResponse = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT },
redirect: "error",
signal: controller.signal,
signal: headController.signal,
})
const contentType = response.headers.get("content-type")
const contentType = headResponse.headers.get("content-type")
if (contentType?.includes("application/pdf")) {
return NextResponse.json(
{
@@ -120,25 +52,27 @@ export async function POST(req: Request) {
{ status: 422 },
)
}
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
if (!response.ok) {
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
}
// Extract article content with timeout to avoid tying up server resources
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
const buffer = await readLimitedBody(response)
if (!buffer) {
return NextResponse.json(
{
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
},
{ status: 413 },
)
}
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
let article
try {
article = await extract(url, undefined, {
headers: { "User-Agent": USER_AGENT },
signal: controller.signal,
})
} catch (err: any) {
if (err?.name === "AbortError") {
return NextResponse.json(
@@ -146,25 +80,11 @@ export async function POST(req: Request) {
{ status: 504 },
)
}
// 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 },
)
throw err
} finally {
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) {
return NextResponse.json(
{ error: "Could not extract content from URL" },
-62
View File
@@ -1,62 +0,0 @@
import { NextResponse } from "next/server"
import { checkAccessCode } from "@/lib/access-code"
import { classifyLLMError } from "@/lib/llm-errors"
import { canListModels, listProviderModels } from "@/lib/provider-models"
import {
allowPrivateUrls,
isPrivateUrl,
redirectGuardedFetch,
} from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
// Public lists need no key
const NO_KEY_NEEDED = new Set<ProviderName>([
"ollama",
"openrouter",
"aihubmix",
])
/**
* The models a provider offers, for the "Fetch models" button in model
* settings. Answers { models: null } for providers that cannot list them,
* so the dialog keeps its suggested models.
*/
export async function POST(req: Request) {
// Sends requests to a URL the client chose, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
const { provider, apiKey, baseUrl } = (await req.json()) as {
provider: ProviderName
apiKey?: string
baseUrl?: string
}
if (!canListModels(provider)) {
return NextResponse.json({ models: null })
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
return NextResponse.json({ error: "Invalid base URL" }, { status: 400 })
}
if (!apiKey && !NO_KEY_NEEDED.has(provider)) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
)
}
try {
const models = await listProviderModels(
provider,
{ apiKey, baseUrl },
(baseUrl && redirectGuardedFetch()) || fetch,
)
return NextResponse.json({ models })
} catch (error) {
console.warn("[provider-models] Listing failed:", error)
const { code, message } = classifyLLMError(error)
return NextResponse.json({ code, error: message })
}
}
+19 -23
View File
@@ -3,8 +3,7 @@
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { Output, streamText } from "ai"
import { checkAccessCode } from "@/lib/access-code"
import { streamObject } from "ai"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
@@ -14,9 +13,6 @@ import {
export const maxDuration = 30
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
@@ -29,18 +25,25 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [],
}
/** A fixed result in the text format useObject reads */
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
function createStreamingResponse(result: ValidationResult): Response {
return new Response(JSON.stringify(result), {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
// Uses the server's model credentials, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
@@ -69,13 +72,6 @@ export async function POST(req: Request): Promise<Response> {
)
}
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
return Response.json(
{ error: "Image data too large" },
{ status: 413 },
)
}
// Get the validation model
let model
try {
@@ -97,9 +93,9 @@ export async function POST(req: Request): Promise<Response> {
) || 10000
// Stream the VLM response for useObject consumption
const result = streamText({
const result = streamObject({
model,
output: Output.object({ schema: ValidationResultSchema }),
schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
@@ -118,10 +114,10 @@ export async function POST(req: Request): Promise<Response> {
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ output }) => {
if (sessionId && output) {
onFinish: ({ object }) => {
if (sessionId && object) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${output.valid}, issues=${output.issues?.length ?? 0}`,
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
)
}
},
+338 -89
View File
@@ -1,16 +1,22 @@
import { streamText, tool } from "ai"
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
import { createAnthropic } from "@ai-sdk/anthropic"
import { createDeepSeek, deepseek } from "@ai-sdk/deepseek"
import { createGateway } from "@ai-sdk/gateway"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
import { createVertex } from "@ai-sdk/google-vertex"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server"
import { z } from "zod"
import { checkAccessCode } from "@/lib/access-code"
import { getAIModel, usesServerCredentials } from "@/lib/ai-providers"
import { classifyLLMError } from "@/lib/llm-errors"
import { createOllama } from "ollama-ai-provider-v2"
import { normalizeMiniMaxBaseURL } from "@/lib/ai-providers"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import type { ProviderName } from "@/lib/types/model-config"
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
export const runtime = "nodejs"
interface ValidateRequest {
provider: ProviderName
provider: string
apiKey: string
baseUrl?: string
modelId: string
@@ -22,22 +28,7 @@ interface ValidateRequest {
vertexApiKey?: string // Express Mode API key
}
const TEST_TIMEOUT_MS = 15_000
// Drawing works through tool calls, so the test asks for one
const PING_TOOL = tool({
description: "Report that the connection works.",
inputSchema: z.object({}),
})
const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
export async function POST(req: Request) {
// Lets the server send requests to arbitrary URLs, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try {
const body: ValidateRequest = await req.json()
const {
@@ -60,7 +51,7 @@ export async function POST(req: Request) {
}
// SECURITY: Block SSRF attacks via custom baseUrl
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) {
return NextResponse.json(
{ valid: false, error: "Invalid base URL" },
{ status: 400 },
@@ -94,87 +85,345 @@ export async function POST(req: Request) {
{ status: 400 },
)
}
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
if (
usesServerCredentials(provider, {
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
vertexApiKey,
})
) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// The same model the chat would use. A client base URL makes it
// refuse redirects to internal hosts.
const { model } = getAIModel({
provider,
modelId,
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
})
let model: any
// Streaming, like the chat (some models only stream). Stop at the
// first tool call; a reasoning model that runs out of tokens first
// proves the connection but not tool support.
const startTime = Date.now()
const result = streamText({
model,
prompt: "Call the ping tool.",
tools: { ping: PING_TOOL },
maxOutputTokens: 1024,
maxRetries: 0,
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
})
let calledTool = false
let finishReason: string | undefined
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
// The timeout ends the stream with an abort part, not an error
if (part.type === "abort") {
const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
)
timeout.name = "TimeoutError"
throw timeout
}
if (part.type === "tool-call") {
calledTool = true
switch (provider) {
case "openai": {
const openai = createOpenAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openai.chat(modelId)
break
}
if (part.type === "finish") finishReason = part.finishReason
case "anthropic": {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || "https://api.anthropic.com/v1",
})
model = anthropic(modelId)
break
}
case "google": {
const google = createGoogleGenerativeAI({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = google(modelId)
break
}
case "vertexai": {
const vertex = createVertex({
apiKey: vertexApiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = vertex(modelId)
break
}
case "azure": {
const azure = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = azure.chat(modelId)
break
}
case "bedrock": {
const bedrock = createAmazonBedrock({
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
region: awsRegion,
})
model = bedrock(modelId)
break
}
case "openrouter": {
const openrouter = createOpenRouter({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = openrouter(modelId)
break
}
case "deepseek": {
if (baseUrl || apiKey) {
const ds = createDeepSeek({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = ds(modelId)
} else {
model = deepseek(modelId)
}
break
}
case "siliconflow": {
const sf = createOpenAI({
apiKey,
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
})
model = sf.chat(modelId)
break
}
case "ollama": {
// SECURITY: Mirror ai-providers.ts guard — only use server
// OLLAMA_API_KEY when the URL is also from server config.
const ollamaApiKey = baseUrl
? apiKey || undefined
: apiKey || process.env.OLLAMA_API_KEY || undefined
const ollamaProvider = createOllama({
baseURL:
baseUrl ||
process.env.OLLAMA_BASE_URL ||
"https://ollama.com/api",
...(ollamaApiKey && {
headers: { Authorization: `Bearer ${ollamaApiKey}` },
}),
})
model = ollamaProvider(modelId)
break
}
case "gateway": {
const gw = createGateway({
apiKey,
...(baseUrl && { baseURL: baseUrl }),
})
model = gw(modelId)
break
}
case "edgeone": {
// EdgeOne uses OpenAI-compatible API via Edge Functions
// Need to pass cookies for EdgeOne Pages authentication
const cookieHeader = req.headers.get("cookie") || ""
const edgeone = createOpenAI({
apiKey: "edgeone", // EdgeOne doesn't require API key
baseURL: baseUrl || "/api/edgeai",
headers: {
cookie: cookieHeader,
},
})
model = edgeone.chat(modelId)
break
}
case "sglang": {
// SGLang is OpenAI-compatible
const sglang = createOpenAI({
apiKey: apiKey || "not-needed",
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
})
model = sglang.chat(modelId)
break
}
case "doubao": {
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
const doubaoBaseUrl =
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
const lowerModelId = modelId.toLowerCase()
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
break
}
case "modelscope": {
const baseURL =
baseUrl || "https://api-inference.modelscope.cn/v1"
const startTime = Date.now()
try {
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
const response = await fetch(
`${baseURL}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: [
{ role: "user", content: "Say 'OK'" },
],
max_tokens: 20,
stream: true,
enable_thinking: false,
}),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`ModelScope API error (${response.status}): ${errorText}`,
)
}
const contentType =
response.headers.get("content-type") || ""
const isValidStreamingResponse =
response.status === 200 &&
(contentType.includes("text/event-stream") ||
contentType.includes("application/json"))
if (!isValidStreamingResponse) {
throw new Error(
`Unexpected response format: ${contentType}`,
)
}
const responseTime = Date.now() - startTime
if (response.body) {
response.body.cancel().catch(() => {
/* Ignore cancellation errors */
})
}
return NextResponse.json({
valid: true,
responseTime,
note: "ModelScope model validated (using streaming API)",
})
} catch (error) {
console.error(
"[validate-model] ModelScope validation failed:",
error,
)
throw error
}
}
case "minimax": {
const rawUrl =
baseUrl ||
PROVIDER_INFO.minimax?.defaultBaseUrl ||
"https://api.minimaxi.com/anthropic"
const { baseURL: minimaxBaseUrl, isAnthropicCompatible } =
normalizeMiniMaxBaseURL(rawUrl)
if (isAnthropicCompatible) {
const minimax = createAnthropic({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
} else {
const minimax = createOpenAI({
apiKey,
baseURL: minimaxBaseUrl,
})
model = minimax.chat(modelId)
}
break
}
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
case "glm":
case "qwen":
case "kimi":
case "qiniu":
case "novita": {
const baseURL =
baseUrl ||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
""
if (!baseURL) {
return NextResponse.json(
{
valid: false,
error: `No base URL configured for provider: ${provider}`,
},
{ status: 400 },
)
}
const openai = createOpenAI({
apiKey,
baseURL,
})
model = openai.chat(modelId)
break
}
default:
return NextResponse.json(
{ valid: false, error: `Unknown provider: ${provider}` },
{ status: 400 },
)
}
// Make a minimal test request
const startTime = Date.now()
await generateText({
model,
prompt: "Say 'OK'",
maxOutputTokens: 20,
})
const responseTime = Date.now() - startTime
return NextResponse.json({
valid: true,
responseTime,
...(!calledTool &&
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
})
} catch (error) {
console.error("[validate-model] Error:", error)
const { code, message } = classifyLLMError(error)
let errorMessage = "Validation failed"
if (error instanceof Error) {
// Extract meaningful error message
if (
error.message.includes("401") ||
error.message.includes("Unauthorized")
) {
errorMessage = "Invalid API key"
} else if (
error.message.includes("404") ||
error.message.includes("not found")
) {
errorMessage = "Model not found"
} else if (
error.message.includes("429") ||
error.message.includes("rate limit")
) {
errorMessage = "Rate limited - try again later"
} else if (error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to server"
} else {
errorMessage = error.message.slice(0, 100)
}
}
return NextResponse.json(
{ valid: false, code, error: message },
{ valid: false, error: errorMessage },
{ status: 200 }, // Return 200 so client can read error message
)
}
+24 -4
View File
@@ -1,9 +1,29 @@
import { checkAccessCode } from "@/lib/access-code"
export async function POST(req: Request) {
if (checkAccessCode(req)) {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
// If no access codes configured, verification always passes
if (accessCodes.length === 0) {
return Response.json({
valid: true,
message: "No access code required",
})
}
const accessCodeHeader = req.headers.get("x-access-code")
if (!accessCodeHeader) {
return Response.json(
{ valid: false, message: "Invalid or missing access code" },
{ valid: false, message: "Access code is required" },
{ status: 401 },
)
}
if (!accessCodes.includes(accessCodeHeader)) {
return Response.json(
{ valid: false, message: "Invalid access code" },
{ status: 401 },
)
}
+1 -7
View File
@@ -6,13 +6,7 @@
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!public",
"!packages/mcp-server/src/preview",
"!lib/model-catalog.json"
]
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
+39 -70
View File
@@ -11,9 +11,7 @@ import {
} from "lucide-react"
import type React from "react"
import {
type Dispatch,
forwardRef,
type SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
@@ -43,20 +41,9 @@ import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Image formats every supported model provider accepts (SVG is read as text)
const SUPPORTED_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
]
function isValidFileType(file: File): boolean {
return (
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
}
function formatFileSize(bytes: number): string {
@@ -177,7 +164,7 @@ interface ChatInputProps {
{ text: string; charCount: number; isExtracting: boolean }
>
urlData?: Map<string, UrlData>
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string
error?: Error | null
@@ -257,11 +244,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error
// Block sending until attached files and URLs have their text, otherwise
// their content would be silently dropped
const isExtractingAttachments =
files.some((file) => pdfData.get(file)?.isExtracting) ||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current
@@ -299,9 +281,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// Enter that confirms an IME candidate must not send the message
if (e.nativeEvent.isComposing || e.keyCode === 229) return
const shouldSend =
sendShortcut === "enter"
? e.key === "Enter" &&
@@ -313,12 +292,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (shouldSend) {
e.preventDefault()
const form = e.currentTarget.closest("form")
if (
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
if (form && input.trim() && !isDisabled) {
form.requestSubmit()
}
}
@@ -406,9 +380,13 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (isDisabled) return
// Let validateFiles show a toast for unsupported types
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles(
Array.from(e.dataTransfer.files),
supportedFiles,
files.length,
dict,
)
@@ -423,34 +401,33 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try {
onUrlChange((prev) =>
new Map(prev).set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
}),
)
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
// Skip if the URL was removed while extracting
onUrlChange((prev) =>
prev.has(url) ? new Map(prev).set(url, data) : prev,
)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
setShowUrlDialog(false)
} catch (error) {
// Remove the URL from the data map on error
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
const newUrlData = urlData
? new Map(urlData)
: new Map<string, UrlData>()
newUrlData.delete(url)
onUrlChange(newUrlData)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
@@ -486,12 +463,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) =>
onUrlChange((prev) => {
const next = new Map(prev)
next.delete(url)
return next
})
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/>
@@ -583,7 +559,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
@@ -612,11 +588,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
) : (
<Button
type="submit"
disabled={
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send}
@@ -642,8 +614,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
dict.save.savedSuccessfully,
)
}
// Local date as YYYY-MM-DD (toISOString would give UTC)
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
@@ -656,11 +629,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<TemplateCreateDialog
open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate}
onSuccess={() => {
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
onSuccess={() => setShowSaveAsTemplate(false)}
initialPrompt={input.trim()}
/>
</form>
+211 -162
View File
@@ -26,7 +26,6 @@ import {
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { ChatLobby } from "@/components/chat/ChatLobby"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ToolCallCard } from "@/components/chat/ToolCallCard"
@@ -38,12 +37,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import {
applyDiagramOperations,
convertToLegalXml,
extractCompleteMxCells,
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import { applyDiagramOperations } from "@/packages/mcp-server/src/diagram-operations.ts"
import { BLANK_MXFILE } from "@/packages/mcp-server/src/pages.ts"
// Helper to extract complete operations from streaming input
function getCompleteOperations(
@@ -130,14 +129,12 @@ const getMessageTextContent = (message: UIMessage): string => {
.join("\n")
}
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
export const APPENDED_FILE_SECTIONS_PATTERN =
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
// Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message)
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
// Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim()
}
interface SessionMetadata {
@@ -149,8 +146,6 @@ interface SessionMetadata {
interface ChatMessageDisplayProps {
messages: UIMessage[]
// Shown on an error that a model setting can fix (bad key, unknown model)
onOpenModelConfig?: () => void
setInput: (input: string) => void
setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>>
@@ -174,7 +169,6 @@ interface ChatMessageDisplayProps {
export function ChatMessageDisplay({
messages,
onOpenModelConfig,
setInput,
setFiles,
processedToolCallsRef,
@@ -194,23 +188,6 @@ export function ChatMessageDisplay({
currentInput = "",
}: ChatMessageDisplayProps) {
const dict = useDictionary()
// The thinking header in the page language
const thinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>{dict.reasoning.thinking}</Shimmer>
}
if (duration === undefined) return <p>{dict.reasoning.thoughtBrief}</p>
return (
<p>
{duration === 1
? dict.reasoning.thoughtForOne
: dict.reasoning.thoughtFor.replace(
"{duration}",
String(duration),
)}
</p>
)
}
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null)
@@ -231,6 +208,20 @@ export function ChatMessageDisplay({
scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
}
}, [messages.length, processedToolCalls])
// Debounce streaming diagram updates - store pending XML and timeout
const pendingXmlRef = useRef<string | null>(null)
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const STREAMING_DEBOUNCE_MS = 150 // Only update diagram every 150ms during streaming
// Refs for edit_diagram streaming
const pendingEditRef = useRef<{
operations: DiagramOperation[]
toolCallId: string
} | null>(null)
const editDebounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{},
)
@@ -352,30 +343,73 @@ export function ChatMessageDisplay({
}
}
// Streaming preview of display_diagram: draw the complete cells written
// so far. The tool handler validates and loads the final diagram.
const handleDisplayChart = useCallback(
(xml: string) => {
const completeCells = extractCompleteMxCells(xml || "")
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
(xml: string, showToast = false) => {
let currentXml = xml || ""
// Skip this update while the cells written so far don't parse
const testDoc = new DOMParser().parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
if (testDoc.querySelector("parsererror")) return
// During streaming (showToast=false), extract only complete mxCell elements
// This allows progressive rendering even with partial/incomplete trailing XML
if (!showToast) {
const completeCells = extractCompleteMxCells(currentXml)
if (!completeCells) {
return
}
currentXml = completeCells
}
try {
// An empty canvas gets a default mxfile to put the cells in
const baseXML = chartXML || BLANK_MXFILE
const replacedXML = replaceNodes(baseXML, convertedXml)
previousXML.current = convertedXml
onDisplayChart(replacedXML, true)
} catch (error) {
console.error("Error processing XML:", error)
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
// Parse and validate XML BEFORE calling replaceNodes
const parser = new DOMParser()
// Wrap in root element for parsing multiple mxCell elements
const testDoc = parser.parseFromString(
`<root>${convertedXml}</root>`,
"text/xml",
)
const parseError = testDoc.querySelector("parsererror")
if (parseError) {
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.malformedXml)
}
return // Skip this update
}
try {
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
const baseXML =
chartXML ||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
const replacedXML = replaceNodes(baseXML, convertedXml)
// During streaming (showToast=false), skip heavy validation for lower latency
// The quick DOM parse check above catches malformed XML
// Full validation runs on final output (showToast=true)
if (!showToast) {
previousXML.current = convertedXml
onDisplayChart(replacedXML, true)
return
}
// Final output: run full validation and auto-fix
const validation = validateAndFixXml(replacedXML)
if (validation.valid) {
previousXML.current = convertedXml
// Use fixed XML if available, otherwise use original
const xmlToLoad = validation.fixed || replacedXML
onDisplayChart(xmlToLoad, true)
} else {
toast.error(dict.errors.validationFailed)
}
} catch (error) {
console.error("Error processing XML:", error)
// Only show toast if this is the final XML (not during streaming)
if (showToast) {
toast.error(dict.errors.failedToProcess)
}
}
}
},
[chartXML, onDisplayChart],
@@ -422,17 +456,8 @@ export function ChatMessageDisplay({
// Previous messages are already processed and won't change
const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : []
// The diagram without streamed previews. Undoing a failed edit's
// preview below changes it before chartXML catches up, and an edit
// streaming right after must start from the undone diagram.
let baseXml = chartXML
messagesToProcess.forEach((message) => {
// Messages restored from a saved session were applied before it was
// saved; the saved diagram is authoritative, so don't replay them
const isRestoredMessage =
loadedMessageIdsRef?.current.has(message.id) ?? false
if (message.parts) {
message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) {
@@ -450,8 +475,6 @@ export function ChatMessageDisplay({
})
}
if (isRestoredMessage) return
if (
part.type === "tool-display_diagram" &&
input?.xml
@@ -465,62 +488,63 @@ export function ChatMessageDisplay({
return // Skip redundant processing
}
// Messages update at most every 150 ms while
// streaming (useChat throttle in chat-panel)
if (state === "input-streaming") {
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
if (
state === "input-streaming" ||
state === "input-available"
) {
// Debounce streaming updates - queue the XML and process after delay
pendingXmlRef.current = xml
if (!debounceTimeoutRef.current) {
// No pending timeout - set one up
debounceTimeoutRef.current = setTimeout(
() => {
const pendingXml =
pendingXmlRef.current
debounceTimeoutRef.current = null
pendingXmlRef.current = null
if (pendingXml) {
handleDisplayChart(
pendingXml,
false,
)
lastProcessedXmlRef.current.set(
toolCallId,
pendingXml,
)
}
},
STREAMING_DEBOUNCE_MS,
)
}
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Input complete: the tool handler loads the
// validated diagram
// Final output - process immediately (clear any pending debounce)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
debounceTimeoutRef.current = null
pendingXmlRef.current = null
}
// Show toast only if final XML is malformed
handleDisplayChart(xml, true)
processedToolCalls.current.add(toolCallId)
// Clean up the ref entry - tool is complete, no longer needed
lastProcessedXmlRef.current.delete(toolCallId)
}
}
// Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (part.type === "tool-edit_diagram") {
// Failed or stopped: if the original XML is still
// stored, the tool handler never ran (invalid
// JSON, or the user pressed stop), so undo the
// streamed preview here. Invalid JSON leaves no
// operations in the input, so check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(
toolCallId,
)
onDisplayChart(originalXml, true)
baseXml = originalXml
}
return
}
if (!input?.operations) return
if (state !== "input-streaming") {
// Input complete: the tool handler applies the
// checked edit (it reads the original XML too)
if (
!processedToolCalls.current.has(toolCallId)
) {
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
}
return
}
if (
part.type === "tool-edit_diagram" &&
input?.operations
) {
const completeOps = getCompleteOperations(
input.operations as DiagramOperation[],
)
if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref)
@@ -529,7 +553,7 @@ export function ChatMessageDisplay({
toolCallId,
)
) {
if (!baseXml) {
if (!chartXML) {
console.warn(
"[edit_diagram streaming] No chart XML available",
)
@@ -537,9 +561,10 @@ export function ChatMessageDisplay({
}
editDiagramOriginalXmlRef.current.set(
toolCallId,
baseXml,
chartXML,
)
}
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
@@ -547,36 +572,94 @@ export function ChatMessageDisplay({
if (!originalXml) return
// Skip if no change from last processed state
const countKey = `${toolCallId}-opCount`
const opCount = String(completeOps.length)
const lastCount = lastProcessedXmlRef.current.get(
toolCallId + "-opCount",
)
if (lastCount === String(completeOps.length)) return
if (
lastProcessedXmlRef.current.get(countKey) ===
opCount
state === "input-streaming" ||
state === "input-available"
) {
return
}
try {
const { result } = applyDiagramOperations(
originalXml,
completeOps,
)
// Load the full document so other pages stay intact
onDisplayChart(result, true)
lastProcessedXmlRef.current.set(
countKey,
opCount,
)
} catch (e) {
console.warn(
"[edit_diagram streaming] Operation failed:",
e instanceof Error ? e.message : e,
// Queue the operations for debounced processing
pendingEditRef.current = {
operations: completeOps,
toolCallId,
}
if (!editDebounceTimeoutRef.current) {
editDebounceTimeoutRef.current = setTimeout(
() => {
const pending =
pendingEditRef.current
editDebounceTimeoutRef.current =
null
pendingEditRef.current = null
if (pending) {
const origXml =
editDiagramOriginalXmlRef.current.get(
pending.toolCallId,
)
if (!origXml) return
try {
const {
result: editedXml,
} = applyDiagramOperations(
origXml,
pending.operations,
)
handleDisplayChart(
editedXml,
false,
)
lastProcessedXmlRef.current.set(
pending.toolCallId +
"-opCount",
String(
pending.operations
.length,
),
)
} catch (e) {
console.warn(
`[edit_diagram streaming] Operation failed:`,
e instanceof Error
? e.message
: e,
)
}
}
},
STREAMING_DEBOUNCE_MS,
)
}
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
// Final state - cleanup streaming refs (tool handler does final application)
if (editDebounceTimeoutRef.current) {
clearTimeout(editDebounceTimeoutRef.current)
editDebounceTimeoutRef.current = null
}
lastProcessedXmlRef.current.delete(
toolCallId + "-opCount",
)
processedToolCalls.current.add(toolCallId)
// Note: Don't delete editDiagramOriginalXmlRef here - tool handler needs it
}
}
}
})
}
})
// NOTE: Don't cleanup debounce timeouts here!
// The cleanup runs on every re-render (when messages changes),
// which would cancel the timeout before it fires.
// Let the timeouts complete naturally - they're harmless if component unmounts.
}, [messages, handleDisplayChart, chartXML])
return (
@@ -762,11 +845,7 @@ export function ChatMessageDisplay({
!isRestoredMessage
}
>
<ReasoningTrigger
getThinkingMessage={
thinkingMessage
}
/>
<ReasoningTrigger />
<ReasoningContent>
{
reasoningPart.text
@@ -872,12 +951,8 @@ export function ChatMessageDisplay({
part.type?.startsWith(
"tool-",
)
// Blank text (some models send
// a lone space) gets no bubble
const isContentPart =
(part.type === "text" &&
part.text.trim() !==
"") ||
part.type === "text" ||
part.type === "file"
if (isToolPart) {
@@ -1227,32 +1302,6 @@ export function ChatMessageDisplay({
return null
},
)}
{message.role ===
"system" &&
(
message.metadata as
| {
openModelConfig?: boolean
}
| undefined
)
?.openModelConfig &&
onOpenModelConfig && (
<button
type="button"
onClick={
onOpenModelConfig
}
className="mt-2 text-xs font-medium underline underline-offset-2 hover:opacity-80"
>
{
dict
.errors
.llm
.openModelSettings
}
</button>
)}
</div>
)
},
+154 -194
View File
@@ -1,7 +1,7 @@
"use client"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport, isToolUIPart, type UIMessage } from "ai"
import { DefaultChatTransport } from "ai"
import {
MessageSquarePlus,
PanelRightClose,
@@ -32,7 +32,6 @@ import { useSessionManager } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage"
@@ -41,13 +40,8 @@ import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { BLANK_MXFILE, hasCells } from "@/packages/mcp-server/src/pages.ts"
import type { ValidationState } from "./chat/ValidationCard"
import {
APPENDED_FILE_SECTIONS_PATTERN,
ChatMessageDisplay,
} from "./chat-message-display"
import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence
@@ -56,11 +50,26 @@ const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
// sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input"
// Type for message parts (tool calls and their states)
interface MessagePart {
type: string
state?: string
toolName?: string
input?: { xml?: string; [key: string]: unknown }
[key: string]: unknown
}
interface ChatMessage {
role: string
parts?: MessagePart[]
[key: string]: unknown
}
interface ChatPanelProps {
isVisible: boolean
onToggleVisibility: () => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
isMobile?: boolean
@@ -78,30 +87,30 @@ const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retr
* Check if auto-resubmit should happen based on tool errors.
* Only checks the LAST tool part (most recent tool call), not all tool parts.
*/
function hasToolErrors(messages: UIMessage[]): boolean {
function hasToolErrors(messages: ChatMessage[]): boolean {
const lastMessage = messages[messages.length - 1]
if (lastMessage?.role !== "assistant") return false
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1)
return lastToolPart?.state === TOOL_ERROR_STATE
}
if (!lastMessage || lastMessage.role !== "assistant") {
return false
}
/**
* Snapshots keep the full multi-page document, but the model only sees and
* edits the first page, so give it the first page's mxGraphModel.
* Older snapshots already hold a single mxGraphModel and are returned as is.
*/
function getFirstPageXml(xml: string): string {
if (!xml.includes("<mxfile")) return xml
const doc = new DOMParser().parseFromString(xml, "text/xml")
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
const toolParts =
(lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>
part.type?.startsWith("tool-"),
) || []
if (toolParts.length === 0) {
return false
}
const lastToolPart = toolParts[toolParts.length - 1]
return lastToolPart?.state === TOOL_ERROR_STATE
}
export default function ChatPanel({
isVisible,
onToggleVisibility,
drawioUi,
onDrawioUiChange,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
isMobile = false,
@@ -168,7 +177,6 @@ export default function ChatPanel({
const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [customSystemMessage, setCustomSystemMessage] = useState("")
const [maxOutputTokens, setMaxOutputTokens] = useState("")
const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -195,14 +203,6 @@ export default function ChatPanel({
}
}, [])
// Load output token budget from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.maxOutputTokens)
if (stored !== null) {
setMaxOutputTokens(stored)
}
}, [])
// Check config on mount
useEffect(() => {
fetch(getApiEndpoint("/api/config"))
@@ -319,15 +319,19 @@ export default function ChatPanel({
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
}, [])
// Handler for output token budget change (empty string = use server default)
const handleMaxOutputTokensChange = useCallback((value: string) => {
const digitsOnly = value.replace(/\D/g, "")
setMaxOutputTokens(digitsOnly)
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
}, [])
// Ref to store the sendMessage function for use in callbacks
const sendMessageRef = useRef<typeof sendMessage | null>(null)
// Failed VLM validations in the current user turn (reset on user action)
const validationRetryCountRef = useRef(0)
// Callback to improve diagram with validation suggestions
const handleImproveWithSuggestions = useCallback((feedback: string) => {
if (sendMessageRef.current) {
// Send the feedback as a new user message to trigger regeneration
sendMessageRef.current({
role: "user",
parts: [{ type: "text", text: feedback }],
})
}
}, [])
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
@@ -336,7 +340,6 @@ export default function ChatPanel({
const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
@@ -364,87 +367,95 @@ export default function ChatPanel({
await handleToolCall({ toolCall }, addToolOutput)
},
onError: (error) => {
// An edit still streaming when the request failed never reaches
// the tool handler: undo its preview. The first stored original
// is the diagram before any of them.
const [originalXml] = editDiagramOriginalXmlRef.current.values()
if (originalXml) onDisplayChart(originalXml, true)
editDiagramOriginalXmlRef.current.clear()
// Server errors are JSON: a quota limit ({type: request, token or
// tpm}), a provider error ({type: "provider", code, message}) or
// {error}. The SDK puts the response body in error.message.
let data: any = null
// Handle server-side quota limit (429 response)
// AI SDK puts the full response body in error.message for non-OK responses
try {
data = JSON.parse(error.message)
const data = JSON.parse(error.message)
if (data.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
return
}
if (data.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
return
}
if (data.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
return
}
} catch {
// Plain text, e.g. a network failure in the browser
// Not JSON, fall through to string matching for backwards compatibility
}
if (data?.type === "request") {
quotaManager.showQuotaLimitToast(data.used, data.limit)
// Fallback to string matching
if (error.message.includes("Daily request limit")) {
quotaManager.showQuotaLimitToast()
return
}
if (data?.type === "token") {
quotaManager.showTokenLimitToast(data.used, data.limit)
if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast()
return
}
if (data?.type === "tpm") {
quotaManager.showTPMLimitToast(data.limit)
if (
error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return
}
const isAccessCodeError = String(
data?.error ?? error.message,
).includes("Invalid or missing access code")
// Silence access code error in console since it's handled by UI
if (!isAccessCodeError) console.error("Chat error:", error)
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error)
}
// A hint the user can act on, then the provider's own words
let text: string = error.message
let openModelConfig = false
if (data?.type === "provider") {
const hints = dict.errors.llm as Record<string, string>
text = hints[data.code]
? `${hints[data.code]}\n\n${data.message}`
: data.message
openModelConfig = [
"invalid_api_key",
"forbidden",
"model_not_found",
].includes(data.code)
} else if (typeof data?.error === "string") {
text = data.error
} else if (error.message === "Failed to fetch") {
text = dict.errors.networkError
// Translate technical errors into user-friendly messages
// The server now handles detailed error messages, so we can display them directly.
// But we still handle connection/network errors that happen before reaching the server.
let friendlyMessage = error.message
// Simple check for network errors if message is generic
if (friendlyMessage === "Failed to fetch") {
friendlyMessage = "Network error. Please check your connection."
}
// Truncated tool input error (model output limit too low)
if (friendlyMessage.includes("toolUse.input is invalid")) {
friendlyMessage =
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url")
) {
friendlyMessage = "This model doesn't support image input."
}
// Add system message for error so it can be cleared
setMessages((currentMessages) => [
...currentMessages,
{
setMessages((currentMessages) => {
const errorMessage = {
id: `error-${Date.now()}`,
role: "system" as const,
content: text,
parts: [{ type: "text" as const, text }],
// The message shows a button that opens model settings
...(openModelConfig && {
metadata: { openModelConfig: true },
}),
},
])
content: friendlyMessage,
parts: [{ type: "text" as const, text: friendlyMessage }],
}
return [...currentMessages, errorMessage]
})
if (isAccessCodeError) {
if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it
setShowSettingsDialog(true)
}
},
// Re-render streamed messages at most every 150 ms. The streaming
// diagram preview draws on each update, so this also limits redraws
experimental_throttle: 150,
onFinish: () => {},
sendAutomaticallyWhen: ({ messages }) => {
const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(messages)
const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) {
// No error, reset retry count and clear state
@@ -490,6 +501,11 @@ export default function ChatPanel({
},
})
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => {
sendMessageRef.current = sendMessage
}, [sendMessage])
// Ref to track latest messages for unload persistence
const messagesRef = useRef(messages)
useEffect(() => {
@@ -498,9 +514,6 @@ export default function ChatPanel({
// Track last synced session ID to detect external changes (e.g., URL back/forward)
const lastSyncedSessionIdRef = useRef<string | null>(null)
// Messages array from our latest save. A session holding this exact array was
// created by our own save, so it must not be treated as an external switch.
const lastSavedMessagesRef = useRef<unknown[] | null>(null)
// Helper: Sync UI state with session data (eliminates duplication)
// Track message IDs that are being loaded from session (to skip animations/scroll)
@@ -567,10 +580,8 @@ export default function ChatPanel({
thumbnailDataUrl = latestSvgRef.current
}
}
const messages = sanitizeMessages(messagesRef.current)
lastSavedMessagesRef.current = messages
return {
messages,
messages: sanitizeMessages(messagesRef.current),
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
diagramXml: currentDiagramXml,
thumbnailDataUrl,
@@ -623,13 +634,8 @@ export default function ChatPanel({
// Skip if session ID hasn't changed (our own saves don't change the ID)
if (newSessionId === lastSyncedSessionIdRef.current) return
// Our own save created this session; the UI already shows its content
const isOwnNewSession =
newSession?.messages === lastSavedMessagesRef.current
// Update last synced ID
lastSyncedSessionIdRef.current = newSessionId
if (isOwnNewSession) return
// Sync UI with new session
if (newSession) {
@@ -770,25 +776,16 @@ export default function ChatPanel({
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"
// Attachments still extracting have no text yet. Template sends call
// requestSubmit() and skip the disabled send button, so check here too.
const isExtracting =
files.some((f) => pdfData.get(f)?.isExtracting) ||
Array.from(urlData.values()).some((d) => d.isExtracting)
if (input.trim() && !isProcessing && !isExtracting) {
// Check if input matches a cached example (only when no messages
// yet and the canvas is empty, same rule as the server)
if (messages.length === 0 && !hasCells(chartXMLRef.current || "")) {
// Pass the file name so a user's own file never matches an example
if (input.trim() && !isProcessing) {
// Check if input matches a cached example (only when no messages yet)
if (messages.length === 0) {
const cached = findCachedResponse(
input.trim(),
files.length > 0,
files.length === 1 ? files[0].name : undefined,
)
if (cached) {
// Add the user message and a finished display_diagram
// answer, and load its diagram here: these messages never
// reach the tool handler
// Add user message and fake assistant response to messages
// The chat-message-display useEffect will handle displaying the diagram
const toolCallId = `cached-${Date.now()}`
// Build user message text including any file content
@@ -820,16 +817,6 @@ export default function ChatPanel({
],
},
] as any)
// Snapshot the canvas before the example so editing this message works
xmlSnapshotsRef.current.set(
0,
chartXMLRef.current || BLANK_MXFILE,
)
const prepared = prepareNewDiagram(cached.xml, {
pageId: "page-1",
pageName: "Page-1",
})
if (prepared.ok) onDisplayChart(prepared.xml, true)
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
@@ -839,6 +826,13 @@ export default function ChatPanel({
}
try {
let chartXml = await onFetchChart()
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
// (Backend only reads first text part, so we must combine them)
const parts: any[] = []
@@ -853,45 +847,32 @@ export default function ChatPanel({
// Add the combined text as the first part
parts.unshift({ type: "text", text: userText })
await sendWithCurrentDiagram(parts)
// Get previous XML from the last snapshot (before this message)
const snapshotKeys = Array.from(
xmlSnapshotsRef.current.keys(),
).sort((a, b) => b - a)
const previousXml =
snapshotKeys.length > 0
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
: ""
// Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml)
sendChatMessage(parts, chartXml, previousXml, sessionId)
// Token count is tracked in onFinish with actual server usage
setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([])
setUrlData(new Map())
} catch (error) {
console.error("Error fetching chart data:", error)
toast.error(dict.errors.failedToExport)
}
}
}
// Export the current diagram, snapshot it for this message, and send
const sendWithCurrentDiagram = async (parts: any[]) => {
const chartXml = formatXML(await onFetchChart())
const previousXml = getPreviousXml(messages.length)
// Snapshot the full multi-page document (kept fresh by autosave) so
// regenerate/edit can restore every page; the model gets page 1 only
xmlSnapshotsRef.current.set(
messages.length,
chartXMLRef.current || chartXml,
)
sendChatMessage(parts, chartXml, previousXml, sessionId)
}
// Send VLM validation feedback as a new user message through the normal send path
const handleImproveWithSuggestions = async (feedback: string) => {
if (status === "streaming" || status === "submitted") return
try {
await sendWithCurrentDiagram([{ type: "text", text: feedback }])
} catch (error) {
console.error("Error fetching chart data:", error)
toast.error(dict.errors.failedToExport)
}
}
// Handle session switching from history dropdown
const handleSelectSession = useCallback(
async (sessionId: string) => {
@@ -995,9 +976,10 @@ export default function ChatPanel({
// Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback(
async (template: { prompt: string }) => {
// Keep attachments: they are sent along with the template prompt
flushSync(() => {
setInput(template.prompt)
setFiles([])
setUrlData(new Map())
})
const formElement = document.getElementById(
@@ -1007,7 +989,7 @@ export default function ChatPanel({
formElement.requestSubmit()
}
},
[setInput],
[setInput, setFiles, setUrlData],
)
const handleInputChange = (
@@ -1022,15 +1004,13 @@ export default function ChatPanel({
}
// Helper functions for message actions (regenerate/edit)
// Extract previous XML snapshot (first page, as sent to the model) before a given message index
// Extract previous XML snapshot before a given message index
const getPreviousXml = (beforeIndex: number): string => {
const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys())
.filter((k) => k < beforeIndex)
.sort((a, b) => b - a)
return snapshotKeys.length > 0
? getFirstPageXml(
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
)
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
: ""
}
@@ -1082,7 +1062,6 @@ export default function ChatPanel({
// Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0
validationRetryCountRef.current = 0
partialXmlRef.current = ""
const config = getSelectedAIConfig()
@@ -1128,9 +1107,6 @@ export default function ChatPanel({
...(minimalStyle && {
"x-minimal-style": "true",
}),
...(maxOutputTokens && {
"x-max-output-tokens": maxOutputTokens,
}),
},
},
)
@@ -1231,12 +1207,7 @@ export default function ChatPanel({
})
// Now send the message after state is guaranteed to be updated
sendChatMessage(
userParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
sendChatMessage(userParts, savedXml, previousXml, sessionId)
}
const handleEditMessage = async (messageIndex: number, newText: string) => {
@@ -1263,13 +1234,10 @@ export default function ChatPanel({
// Clean up snapshots for messages after the user message (they will be removed)
cleanupSnapshotsAfter(messageIndex)
// Create new parts with updated text. The edit box only shows the typed
// text, so keep the appended PDF/file/URL content
// Create new parts with updated text
const newParts = message.parts?.map((part: any) => {
if (part.type === "text") {
const appended =
part.text.match(APPENDED_FILE_SECTIONS_PATTERN)?.[0] ?? ""
return { ...part, text: newText + appended }
return { ...part, text: newText }
}
return part
}) || [{ type: "text", text: newText }]
@@ -1282,12 +1250,7 @@ export default function ChatPanel({
})
// Now send the edited message after state is guaranteed to be updated
sendChatMessage(
newParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
sendChatMessage(newParts, savedXml, previousXml, sessionId)
}
// Collapsed view (desktop only)
@@ -1416,7 +1379,6 @@ export default function ChatPanel({
{/* Messages */}
<main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay
onOpenModelConfig={() => setShowModelConfigDialog(true)}
messages={messages}
setInput={setInput}
setFiles={handleFileChange}
@@ -1480,7 +1442,7 @@ export default function ChatPanel({
open={showSettingsDialog}
onOpenChange={setShowSettingsDialog}
drawioUi={drawioUi}
onDrawioUiChange={onDrawioUiChange}
onToggleDrawioUi={onToggleDrawioUi}
darkMode={darkMode}
onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle}
@@ -1489,8 +1451,6 @@ export default function ChatPanel({
onVlmValidationChange={handleVlmValidationChange}
customSystemMessage={customSystemMessage}
onCustomSystemMessageChange={handleCustomSystemMessageChange}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={handleMaxOutputTokensChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)}
/>
-2
View File
@@ -194,8 +194,6 @@ export function ChatLobby({
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
-3
View File
@@ -55,9 +55,6 @@ export function TemplateCreateDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
e.stopPropagation()
const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) {
+3 -6
View File
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Populate form each time the dialog opens, dropping any cancelled edits
// Populate form when template changes
useEffect(() => {
if (open && template) {
if (template) {
setTitle(template.title || "")
setDescription(template.description || "")
setPrompt(template.prompt || "")
setPinned(template.pinned || false)
setError(null)
}
}, [open, template])
}, [template])
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
@@ -59,9 +59,6 @@ export function TemplateEditDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// React submit events bubble through the portal; keep them away from
// any form this dialog may be rendered in
e.stopPropagation()
if (!template) return
+19 -44
View File
@@ -110,10 +110,6 @@ export function TemplatePanel({
useEffect(() => {
loadTemplates()
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates])
const handleCreateSuccess = () => {
@@ -224,8 +220,7 @@ export function TemplatePanel({
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
// Local date as YYYY-MM-DD (toISOString would give UTC)
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -307,28 +302,6 @@ export function TemplatePanel({
}
}
// Shared by the empty state and the list, so import works in both
const importInput = (
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
)
const importMessageBox = importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)
// Empty state: no templates at all
if (!loading && templates.length === 0) {
return (
@@ -359,18 +332,6 @@ export function TemplatePanel({
<Plus className="w-4 h-4" />
{dict.templates.createFirst}
</button>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
{importMessageBox && (
<div className="mt-3">{importMessageBox}</div>
)}
<TemplateCreateDialog
open={createDialogOpen}
@@ -428,11 +389,27 @@ export function TemplatePanel({
<Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates}
</button>
{importInput}
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
</div>
{/* Import message */}
{importMessageBox}
{importMessage && (
<div
className={`text-xs px-3 py-2 rounded-lg ${
importMessage.type === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{importMessage.text}
</div>
)}
<div className="space-y-2">
{loading
@@ -470,8 +447,6 @@ export function TemplatePanel({
handleTemplateClick(template)
}
onKeyDown={(e) => {
// Ignore keys bubbling up from the action buttons
if (e.target !== e.currentTarget) return
if (
e.key === "Enter" ||
e.key === " "
+34 -28
View File
@@ -66,7 +66,7 @@ export function ToolCallCard({
dict,
}: ToolCallCardProps) {
const callId = part.toolCallId
const { state, input, output, errorText } = part
const { state, input, output } = part
// Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
@@ -92,14 +92,6 @@ export function ToolCallCard({
}
}
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
const handleCopy = () => {
let textToCopy = ""
@@ -169,15 +161,22 @@ export function ToolCallCard({
</>
)}
{state === "output-error" &&
(isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
))}
(() => {
// Check if this is a truncation (incomplete XML) vs real error
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return isTruncated ? (
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
Truncated
</span>
) : (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)
})()}
{input && Object.keys(input).length > 0 && (
<button
type="button"
@@ -225,16 +224,23 @@ export function ToolCallCard({
) : null}
</div>
)}
{/* AI SDK stores tool errors in errorText */}
{state === "output-error" && (errorText || output) && (
<div
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
: (errorText ?? output)}
</div>
)}
{output &&
state === "output-error" &&
(() => {
const isTruncated =
(toolName === "display_diagram" ||
toolName === "append_diagram") &&
!isMxCellXmlComplete(input?.xml)
return (
<div
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
>
{isTruncated
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
: output}
</div>
)
})()}
{/* Show get_shape_library output on success */}
{output &&
toolName === "get_shape_library" &&
+5 -4
View File
@@ -1,6 +1,8 @@
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts"
export type { DiagramOperation }
export interface DiagramOperation {
operation: "update" | "add" | "delete"
cell_id: string
new_xml?: string
}
export interface ToolPartLike {
type: string
@@ -11,5 +13,4 @@ export interface ToolPartLike {
operations?: DiagramOperation[]
} & Record<string, unknown>
output?: string
errorText?: string
}
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { wrapWithMxFile } from "@/lib/utils"
// Dev XML presets for streaming simulator
const DEV_XML_PRESETS: Record<string, string> = {
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
})
// Display the final diagram
const prepared = prepareNewDiagram(xml)
if (prepared.ok) onDisplayChart(prepared.xml)
const fullXml = wrapWithMxFile(xml)
onDisplayChart(fullXml)
setIsSimulating(false)
}
File diff suppressed because it is too large Load Diff
+9 -15
View File
@@ -158,7 +158,7 @@ export function ModelSelector({
}, [])
return (
<div ref={wrapperRef} className="min-w-0 max-w-48">
<div ref={wrapperRef} className="inline-block">
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<ButtonWithTooltip
@@ -167,7 +167,7 @@ export function ModelSelector({
size="sm"
disabled={disabled}
className={cn(
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
!showLabel && "px-1.5 justify-center",
)}
// 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" />
{/* show/hide visible label based on measured width */}
{showLabel ? (
<span className="min-w-0 truncate text-xs">
<span className="text-xs truncate">
{selectedModel
? selectedModel.modelId
: dict.modelConfig.default}
@@ -264,13 +264,9 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
// Unique value so same-named models highlight
// separately; keywords keep search by name
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
value={
model.modelId
}
onSelect={() =>
handleSelect(
model.id,
@@ -355,11 +351,9 @@ export function ModelSelector({
(model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[
model.modelId,
providerLabel,
]}
value={
model.modelId
}
onSelect={() =>
handleSelect(
model.id,
-298
View File
@@ -1,298 +0,0 @@
"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 {
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via
// 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",
})
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// 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, with a link to where the provider issues keys */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label
htmlFor="api-key"
className="text-xs font-medium flex items-center gap-1.5"
>
<Key className="h-3.5 w-3.5 text-muted-foreground" />
{dict.modelConfig.apiKey}
{provider === "ollama" &&
` ${dict.modelConfig.optional}`}
</Label>
{info.apiKeyUrl && (
<a
href={info.apiKeyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline"
>
{dict.modelConfig.getApiKey}
</a>
)}
</div>
{renderSecret({ field: "apiKey", id: "api-key" })}
</div>
{/* Base URL */}
<div className="space-y-2">
<Label
htmlFor="base-url"
className="text-xs font-medium flex items-center gap-1.5"
>
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{baseUrlLabel}
</Label>
<Input
id="base-url"
value={baseUrl ?? ""}
disabled={disabled}
onChange={(e) =>
onChange("baseUrl", e.target.value)
}
// Drop a pasted endpoint path such as /chat/completions
onBlur={(e) => {
const normalized = normalizeBaseUrl(
e.target.value,
)
if (normalized !== e.target.value) {
onChange("baseUrl", normalized)
}
}}
placeholder={
info.defaultBaseUrl ||
dict.modelConfig.customEndpoint
}
className="h-9 rounded-xl font-mono text-xs"
/>
{requestUrl && (
<p className="text-xs text-muted-foreground font-mono break-all">
{formatMessage(dict.modelConfig.requestUrl, {
url: requestUrl,
})}
</p>
)}
{provider === "minimax" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint}
</p>
)}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div>
</>
)}
{footer}
</div>
)
}
-36
View File
@@ -1,36 +0,0 @@
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}
/>
)
}
+23 -65
View File
@@ -25,7 +25,6 @@ import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path"
import type { DrawioTheme } from "@/lib/drawio-themes"
import { i18n, type Locale } from "@/lib/i18n/config"
import { STORAGE_KEYS } from "@/lib/storage"
@@ -64,8 +63,8 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawioUi: DrawioTheme
onDrawioUiChange: (theme: DrawioTheme) => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
darkMode: boolean
onToggleDarkMode: () => void
minimalStyle?: boolean
@@ -75,8 +74,6 @@ interface SettingsDialogProps {
onOpenModelConfig?: () => void
customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void
maxOutputTokens?: string
onMaxOutputTokensChange?: (value: string) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -93,7 +90,7 @@ function SettingsContent({
open,
onOpenChange,
drawioUi,
onDrawioUiChange,
onToggleDrawioUi,
darkMode,
onToggleDarkMode,
minimalStyle = false,
@@ -103,8 +100,6 @@ function SettingsContent({
onOpenModelConfig,
customSystemMessage = "",
onCustomSystemMessageChange = () => {},
maxOutputTokens = "",
onMaxOutputTokensChange = () => {},
}: SettingsDialogProps) {
const dict = useDictionary()
const router = useRouter()
@@ -139,11 +134,8 @@ function SettingsContent({
const [isApplyingProxy, setIsApplyingProxy] = useState(false)
useEffect(() => {
// Re-fetch config whenever the dialog opens to ensure we always show
// the access code input if the server requires it. This fixes the case
// where a stale localStorage cache (from before ACCESS_CODE_LIST was
// configured) would hide the access code input.
if (!open) return
// Only fetch if not cached in localStorage
if (getStoredAccessCodeRequired() !== null) return
fetch(getApiEndpoint("/api/config"))
.then((res) => {
@@ -159,9 +151,10 @@ function SettingsContent({
setAccessCodeRequired(required)
})
.catch(() => {
// Keep existing cached value on error
// Don't cache on error - allow retry on next mount
setAccessCodeRequired(false)
})
}, [open])
}, [])
// Detect current language from pathname
useEffect(() => {
@@ -437,40 +430,23 @@ function SettingsContent({
{/* Draw.io Style */}
<SettingItem
label={dict.settings.drawioStyle}
description={dict.settings.drawioStyleDescription}
description={`${dict.settings.drawioStyleDescription} ${
drawioUi === "min"
? dict.settings.minimal
: dict.settings.sketch
}`}
>
<Select
value={drawioUi}
onValueChange={(v) =>
onDrawioUiChange(v as DrawioTheme)
}
<Button
id="drawio-ui"
variant="outline"
onClick={onToggleDrawioUi}
className="h-9 w-[120px] rounded-xl border-border-subtle hover:bg-interactive-hover font-normal"
>
<SelectTrigger
id="drawio-ui-select"
aria-label={dict.settings.drawioStyle}
className="w-[120px] h-9 rounded-xl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kennedy">
{dict.settings.themeDefault}
</SelectItem>
<SelectItem value="atlas">Atlas</SelectItem>
<SelectItem value="dark">
{dict.settings.themeDark}
</SelectItem>
<SelectItem value="min">
{dict.settings.themeMinimal}
</SelectItem>
<SelectItem value="sketch">
{dict.settings.themeSketch}
</SelectItem>
<SelectItem value="simple">
{dict.settings.themeSimple}
</SelectItem>
</SelectContent>
</Select>
{dict.settings.switchTo}{" "}
{drawioUi === "min"
? dict.settings.sketch
: dict.settings.minimal}
</Button>
</SettingItem>
{/* Diagram Style */}
@@ -595,24 +571,6 @@ function SettingsContent({
/>
</div>
{/* Max Output Tokens */}
<SettingItem
label={dict.settings.maxOutputTokens}
description={dict.settings.maxOutputTokensDescription}
>
<Input
id="max-output-tokens"
type="text"
inputMode="numeric"
value={maxOutputTokens}
onChange={(e) =>
onMaxOutputTokensChange(e.target.value)
}
placeholder="64000"
className="h-9 w-28 text-sm"
/>
</SettingItem>
{/* Send Shortcut */}
<SettingItem
label={dict.settings.sendShortcut}
+95 -102
View File
@@ -1,17 +1,16 @@
"use client"
import type React from "react"
import { createContext, useContext, useRef, useState } from "react"
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef } from "react-drawio"
import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path"
import {
BLANK_MXFILE,
normalizeToMxfile,
} from "@/packages/mcp-server/src/pages.ts"
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
extractDiagramXML,
isRealDiagram,
validateAndFixXml,
} from "../lib/utils"
interface DiagramContextType {
chartXML: string
@@ -23,7 +22,7 @@ interface DiagramContextType {
handleExportWithoutHistory: () => void
resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: EventExport) => void
handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void
saveDiagramToFile: (
@@ -43,12 +42,6 @@ interface DiagramContextType {
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Exports for thumbnails, validation PNGs and file saves carry a tag in the
// request's `message` field. draw.io echoes the request back in the export
// event, so each result reaches its own caller; untagged exports (chat-panel's
// onFetchChart) resolve resolverRef.
type ExportTag = "thumbnail" | "validation"
export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("")
@@ -60,10 +53,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | null>(null)
// Pending thumbnail and validation PNG exports, keyed by their export tag
const taggedResolversRef = useRef<
Partial<Record<ExportTag, (value: string) => void>>
>({})
// Resolver for PNG export (used for VLM validation)
const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount
@@ -85,16 +76,14 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false)
}
// Update chartXML and its ref together, so callbacks that read the ref
// (export handler, autosave) see the new value right away
const updateChartXML = (xml: string) => {
chartXMLRef.current = xml
setChartXML(xml)
}
// Keep chartXMLRef in sync with state for restoration after remount
useEffect(() => {
chartXMLRef.current = chartXML
}, [chartXML])
// Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string, fullDiagramXML?: string) => void) | null
resolver: ((data: string) => void) | null
format: ExportFormat | null
}>({ resolver: null, format: null })
@@ -117,52 +106,64 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// Export with a tag in `message` (draw.io echoes it back in the export
// event) and wait for that result. Resolves to null on timeout, which is
// expected occasionally.
const requestTaggedExport = (
tag: ExportTag,
format: "xmlsvg" | "png",
timeoutMs: number,
) =>
new Promise<string | null>((resolve) => {
const finish = (value: string | null) => {
clearTimeout(timer)
if (taggedResolversRef.current[tag] === finish) {
delete taggedResolversRef.current[tag]
}
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
taggedResolversRef.current[tag] = finish
drawioRef.current?.exportDiagram({ format, message: tag })
})
// Get current diagram as SVG for thumbnail (used by session storage)
const getThumbnailSvg = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
if (!isRealDiagram(chartXML)) return null
try {
const svgData = await Promise.race([
new Promise<string>((resolve) => {
resolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "xmlsvg" })
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Export timeout")), 3000),
),
])
// xmlsvg exports return an SVG data URL
const svgData = await requestTaggedExport("thumbnail", "xmlsvg", 3000)
if (svgData?.startsWith("data:image/svg")) {
// Update latestSvg so it's available for future saves
setLatestSvg(svgData)
return svgData
if (svgData?.includes("<svg")) {
setLatestSvg(svgData)
return svgData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
return null
}
// Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null
// Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null
if (!isRealDiagram(chartXML)) return null
const pngData = await requestTaggedExport("validation", "png", 5000)
// PNG data should be a base64 data URL
return pngData?.startsWith("data:image/png") ? pngData : null
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
}
const loadDiagram = (
@@ -171,11 +172,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
): string | null => {
let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal
// use). Not strict: the XML may hold the user's own diagram, and the
// tool handlers check model XML strictly before it gets here.
// Validate XML structure before loading (unless skipped for internal use)
if (!skipValidation) {
const validation = validateAndFixXml(chart, { strict: false })
const validation = validateAndFixXml(chart)
if (!validation.valid) {
console.warn(
"[loadDiagram] Validation error:",
@@ -194,7 +193,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
updateChartXML(xmlToLoad)
setChartXML(xmlToLoad)
if (drawioRef.current) {
drawioRef.current.load({
@@ -205,40 +204,41 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
return null
}
const handleDiagramExport = (data: EventExport) => {
// Tagged exports (thumbnail, validation PNG, file save) go only to
// their own caller, so they never take the result meant for resolverRef
const tag = data.message?.message
if (tag === "thumbnail" || tag === "validation") {
taggedResolversRef.current[tag]?.(data.data)
return
}
if (tag === "save") {
saveResolverRef.current.resolver?.(data.data, data.xml)
saveResolverRef.current = { resolver: null, format: null }
const handleDiagramExport = (data: any) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
return
}
// Don't write chartXML here: exports don't change the diagram, and
// data.xml from xmlsvg exports has compressed <diagram> payloads that
// would break edit_diagram/display_diagram. Autosave keeps chartXML
// up to date with the full uncompressed multi-page document (#879).
// Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format
saveResolverRef.current.resolver(data.data)
saveResolverRef.current = { resolver: null, format: null }
// For non-xmlsvg formats, skip XML extraction as it will fail
// Only drawio (which uses xmlsvg internally) has the content attribute
// xmlsvg is saved directly as SVG file, no need for extraction
if (format === "png" || format === "svg" || format === "xmlsvg") {
return
}
}
const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML)
setLatestSvg(data.data)
// Only add to history if this was a user-initiated export
// Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20
if (expectHistoryExportRef.current) {
// Store the full multi-page document (extractedXML is only the
// first page), so restoring a version keeps every page
const historyXml = chartXMLRef.current || extractedXML
setDiagramHistory((prev) => {
const newHistory = [
...prev,
{
svg: data.data,
xml: historyXml,
xml: extractedXML,
},
]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
@@ -253,20 +253,18 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
}
// react-drawio registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram but
// DrawIO hasn't loaded yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
// Don't overwrite a pending restore - if we have a real diagram in state
// but DrawIO isn't ready yet, it means we're waiting to restore
if (!isDrawioReady && isRealDiagram(chartXML)) {
return
}
updateChartXML(data.xml)
setChartXML(data.xml)
}
const clearDiagram = () => {
const emptyDiagram = BLANK_MXFILE
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true)
setLatestSvg("")
@@ -290,21 +288,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
// Set up the resolver before triggering export
saveResolverRef.current = {
resolver: (exportData: string, fullDiagramXML?: string) => {
resolver: (exportData: string) => {
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved.
const xml = fullDiagramXML?.trim()
? fullDiagramXML
: extractDiagramXML(exportData)
fileContent =
normalizeToMxfile(xml, {
pageId: "page-1",
pageName: "Page-1",
}) ?? xml
// Extract XML from SVG for .drawio format
const xml = extractDiagramXML(exportData)
let xmlContent = xml
if (!xml.includes("<mxfile")) {
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
}
fileContent = xmlContent
mimeType = "application/xml"
extension = ".drawio"
} else if (format === "png") {
@@ -364,10 +360,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}
// Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({
format: drawioFormat,
message: "save",
})
drawioRef.current.exportDiagram({ format: drawioFormat })
}
// Log save event to Langfuse (just flags the trace, doesn't send content)
-3
View File
@@ -11,9 +11,6 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"]
env_file: .env
volumes:
# Persists admin panel settings (data/settings.json)
- ./data:/app/data
# environment:
# # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
+1 -15
View File
@@ -124,13 +124,6 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
图表会实时显示在浏览器中!
MCP服务器包含网页版的大部分画图功能:
- 与网页版相同的画图规则和形状库(AWS、Azure、GCP、Kubernetes等)
- 截图工具,AI可以查看画好的图并自行修正
- 版本历史、多页图表,以及下载为`.drawio`、`.png`、`.svg`或`.drawio.svg`格式
- 自动保存到`~/.next-ai-drawio/`,重启后可以接着画
详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
## 快速开始
@@ -211,7 +204,6 @@ npm run dev
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -224,18 +216,12 @@ npm run dev
### 服务端多模型配置
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。如果只需要单 provider 下的多个模型,也可以直接在 `AI_MODEL` 中用逗号分隔模型 ID。
管理员可以配置多个服务端模型,让所有用户无需提供个人 API Key 即可使用。通过 `AI_MODELS_CONFIG` 环境变量(JSON 字符串)或 `ai-models.json` 文件配置。
**模型要求**:此任务需要强大的模型能力,因为它涉及生成具有严格格式约束的长文本(draw.io XML)。推荐使用 Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro 和 DeepSeek V3.2/R1。
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
### 管理面板
设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。
📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。
## 工作原理
-24
View File
@@ -1,24 +0,0 @@
# 管理面板
无需手动编辑 `.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_*` 变量在构建时固化,无法在面板中修改。
+3 -51
View File
@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
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
```bash
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
或者使用 Bearer 认证令牌(例如通过会下发 OAuth 风格 token 的网关时)。`ANTHROPIC_AUTH_TOKEN` 会作为 `Authorization: Bearer <token>` 头发送,而 `ANTHROPIC_API_KEY` 会作为 `x-api-key` 头发送。两者互斥,只能设置其中之一:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
可选的自定义端点:
```bash
@@ -149,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=global.anthropic.claude-sonnet-5-5
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
较新的 Claude 模型只能通过推理配置文件(inference profile)的 ID 调用,这种 ID 以地区前缀开头,例如 `global.` 或 `us.`。直接用 `anthropic.claude-sonnet-5-5` 这样的 ID 会报错 “on-demand throughput isn't supported”。
注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
### OpenRouter
@@ -239,7 +215,7 @@ MiniMax 支持两种 API 格式:
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
AI_MODEL=MiniMax-M2.7
```
可选配置:
@@ -310,19 +286,6 @@ AI_MODEL=your_model_id
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`。
@@ -330,7 +293,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
```bash
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
AI_PROVIDER=google # 或:openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
```
## 服务端多模型配置
@@ -351,17 +314,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
在项目根目录创建 `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
-24
View File
@@ -1,24 +0,0 @@
# 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.
+3 -51
View File
@@ -61,21 +61,6 @@ Optional custom endpoint (for OpenAI-compatible services):
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
```bash
@@ -83,13 +68,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
Or use a Bearer auth token instead of an API key (e.g. when going through a gateway that issues OAuth-style tokens). `ANTHROPIC_AUTH_TOKEN` is sent as `Authorization: Bearer <token>`, while `ANTHROPIC_API_KEY` is sent as `x-api-key`. The two are mutually exclusive — set only one:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
Optional custom endpoint:
```bash
@@ -164,11 +142,9 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=global.anthropic.claude-sonnet-5-5
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
Recent Claude models answer only through an inference profile id, which starts with a region prefix such as `global.` or `us.`. The plain id (`anthropic.claude-sonnet-5-5`) fails with "on-demand throughput isn't supported".
Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter
@@ -254,7 +230,7 @@ MiniMax supports two API formats:
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
AI_MODEL=MiniMax-M2.7
```
Optional configuration:
@@ -325,19 +301,6 @@ Optional 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
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -345,7 +308,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`:
```bash
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
AI_PROVIDER=google # or: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
```
## Server-Side Multi-Model Configuration
@@ -366,17 +329,6 @@ 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).
**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
```json
+1 -15
View File
@@ -122,13 +122,6 @@ Claudeにダイアグラムの作成を依頼:
ダイアグラムがリアルタイムでブラウザに表示されます!
MCPサーバーには、Webアプリの主な作図機能が含まれています:
- Webアプリと同じ作図ルールとシェイプライブラリ(AWS、Azure、GCP、Kubernetesなど)
- スクリーンショットツール:AIが描画結果を確認して修正できます
- バージョン履歴、複数ページのダイアグラム、`.drawio`・`.png`・`.svg`・`.drawio.svg`形式でのダウンロード
- `~/.next-ai-drawio/`への自動保存:再起動後も続きから編集できます
詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
## はじめに
@@ -210,7 +203,6 @@ Next.jsアプリをデプロイする最も簡単な方法は、Next.jsの作成
- Azure OpenAI
- Ollama
- OpenRouter
- AIHubMix
- DeepSeek
- SiliconFlow
- ModelScope
@@ -223,18 +215,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
### サーバーサイドマルチモデル設定
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。同一プロバイダー内の複数モデルだけが必要な場合は、`AI_MODEL` にカンマ区切りでモデルIDを列挙する簡易設定も使えます。
管理者は、ユーザーが個人のAPIキーを提供することなく利用できる複数のサーバーサイドモデルを設定できます。`AI_MODELS_CONFIG` 環境変数(JSON文字列)または `ai-models.json` ファイルで設定します。
**モデル要件**:このタスクは厳密なフォーマット制約(draw.io XML)を持つ長文テキスト生成を伴うため、強力なモデル機能が必要です。Claude Sonnet 4.5、GPT-5.1、Gemini 3 Pro、DeepSeek V3.2/R1を推奨します。
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
### 管理パネル
`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。
📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。
## 仕組み
-24
View File
@@ -1,24 +0,0 @@
# 管理パネル
`.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_*` 変数はビルド時に固定され、パネルでは変更できません。
+3 -51
View File
@@ -46,21 +46,6 @@ AI_MODEL=gpt-4o
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
```bash
@@ -68,13 +53,6 @@ ANTHROPIC_API_KEY=your_api_key
AI_MODEL=claude-sonnet-4-5-20250514
```
または、Bearer 認証トークンを使用することもできます(OAuth スタイルのトークンを発行するゲートウェイ経由で利用する場合など)。`ANTHROPIC_AUTH_TOKEN` は `Authorization: Bearer <token>` ヘッダーで送信され、`ANTHROPIC_API_KEY` は `x-api-key` ヘッダーで送信されます。両者は排他的なので、いずれか一方のみを設定してください:
```bash
ANTHROPIC_AUTH_TOKEN=your_auth_token
AI_MODEL=claude-sonnet-4-5-20250514
```
任意のカスタムエンドポイント:
```bash
@@ -149,11 +127,9 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AI_MODEL=global.anthropic.claude-sonnet-5-5
AI_MODEL=anthropic.claude-sonnet-4-5-20250514-v1:0
```
最近の Claude モデルは、推論プロファイル(inference profile)の ID でのみ呼び出せます。この ID は `global.` や `us.` などのリージョン接頭辞で始まります。`anthropic.claude-sonnet-5-5` のような接頭辞なしの ID では「on-demand throughput isn't supported」というエラーになります。
注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
### OpenRouter
@@ -239,7 +215,7 @@ MiniMax は 2 つの API 形式をサポートしています:
```bash
MINIMAX_API_KEY=your_api_key
AI_MODEL=MiniMax-M3
AI_MODEL=MiniMax-M2.7
```
オプション設定:
@@ -310,19 +286,6 @@ AI_MODEL=your_model_id
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` を設定する必要はありません。
@@ -330,7 +293,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
AI_PROVIDER=google # または: openai, anthropic, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
```
## サーバーサイドマルチモデル設定
@@ -351,17 +314,6 @@ AI_MODELS_CONFIG='{"providers":[{"name":"OpenAI","provider":"openai","models":["
プロジェクトルートに `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
+5 -2
View File
@@ -11,6 +11,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| gcp2 | 297 | `mxgraph.gcp2` | Google Cloud Platform - Compute Engine, BigQuery, GKE, etc. | [gcp2.md](./gcp2.md) |
| alibaba_cloud | 273 | `mxgraph.alibaba_cloud` | Alibaba Cloud - ECS, OSS, RDS, SLB, VPC, etc. | [alibaba_cloud.md](./alibaba_cloud.md) |
| openstack | 18 | `mxgraph.openstack` | OpenStack cloud platform icons | [openstack.md](./openstack.md) |
| digitalocean | 74 | `mxgraph.digitalocean` | DigitalOcean - Droplets, Spaces, Kubernetes, etc. | [digitalocean.md](./digitalocean.md) |
| salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure
@@ -19,6 +20,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
|---------|--------|--------|-------------|------|
| cisco19 | 232 | `mxgraph.cisco19` | Cisco network equipment - routers, switches, firewalls | [cisco19.md](./cisco19.md) |
| network | 58 | `mxgraph.networks` | General network diagram symbols | [network.md](./network.md) |
| arista | 45 | `mxgraph.arista` | Arista network switches and equipment | [arista.md](./arista.md) |
| kubernetes | 40 | `mxgraph.kubernetes` | Kubernetes - pods, services, deployments, nodes | [kubernetes.md](./kubernetes.md) |
| vvd | 93 | `mxgraph.vvd` | VMware Validated Design icons | [vvd.md](./vvd.md) |
| rack | 11 | `mxgraph.rack` | Server rack and data center equipment | [rack.md](./rack.md) |
@@ -28,6 +30,7 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) |
| eip | 36 | `mxgraph.eip` | Enterprise Integration Patterns - messaging, routing | [eip.md](./eip.md) |
| lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams
@@ -45,7 +48,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) |
| material_design | 300 | `image=https://fonts.gstatic.com/...` | Google Material Icons (SVG images) | [material_design.md](./material_design.md) |
## Enterprise Software
@@ -71,5 +73,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) |
| un-ocha-icons | 242 | `mxgraph.un-ocha-icons` | UN OCHA humanitarian icons | [un-ocha-icons.md](./un-ocha-icons.md) |
**Total: 30 libraries, 4,184 shapes**
**Total: 33 libraries, 4,281 shapes**
+23 -43
View File
@@ -67,62 +67,41 @@ const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
/**
* Create standardized JSON response
* Create standardized response with CORS headers
*/
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders,
},
})
}
// Only the app's own server (/api/chat, /api/validate-model) calls this
// function, so no CORS headers are sent: other sites' pages can't call it
// from a browser and spend the deployment's Edge AI quota.
// Same rule as lib/access-code.ts, but reading the edge function's env.
// No codes configured (or env unavailable) means no check.
function hasValidAccessCode(request: Request, env: any): boolean {
const accessCodes: string[] =
env?.ACCESS_CODE_LIST?.split(",")
.map((code: string) => code.trim())
.filter(Boolean) || []
if (accessCodes.length === 0) return true
const accessCode = request.headers.get("x-access-code")
return !!accessCode && accessCodes.includes(accessCode)
/**
* Handle OPTIONS request for CORS preflight
*/
function handleOptionsRequest(): Response {
return new Response(null, {
headers: {
...CORS_HEADERS,
"Access-Control-Max-Age": "86400",
},
})
}
export async function onRequest({ request, env }: any) {
// Requiring JSON also makes any cross-site browser request need a CORS
// preflight, which fails without CORS headers
if (
request.method !== "POST" ||
!request.headers.get("content-type")?.includes("application/json")
) {
return createResponse(
{
error: {
message: "Expected a POST request with a JSON body",
type: "invalid_request_error",
},
},
400,
)
}
if (!hasValidAccessCode(request, env)) {
return createResponse(
{
error: {
message: "Invalid or missing access code",
type: "invalid_request_error",
},
},
401,
)
export async function onRequest({ request, env: _env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
}
request.headers.delete("accept-encoding")
@@ -174,7 +153,7 @@ export async function onRequest({ request, env }: any) {
type: "invalid_request_error",
},
},
400,
429,
)
}
@@ -237,6 +216,7 @@ export async function onRequest({ request, env }: any) {
"Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
...CORS_HEADERS,
},
})
} catch (error: any) {
+4
View File
@@ -60,6 +60,10 @@ declare global {
maximize: () => void
/** Close the window */
close: () => void
/** Open file dialog and return file path */
openFile: () => Promise<string | null>
/** Save data to file via save dialog */
saveFile: (data: string) => Promise<boolean>
/** Get proxy configuration */
getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */
+26 -57
View File
@@ -32,55 +32,6 @@ export function rebuildAppMenu(): void {
buildAppMenu()
}
/**
* Apply a preset and restart the server so it takes effect.
* If the restart fails, go back to the previous preset and restart again,
* so the running server always matches the saved current preset.
* Throws an error describing the outcome on failure.
*/
export async function switchPreset(
id: string,
): Promise<Record<string, string>> {
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(id)
if (!env) {
throw new Error("Preset not found")
}
rebuildAppMenu()
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
if (!app.isPackaged) {
return env
}
try {
await restartNextServer()
return env
} catch (error) {
console.error("Failed to restart server:", error)
const reason = error instanceof Error ? error.message : String(error)
// Revert to previous preset on failure
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
try {
await restartNextServer()
} catch (retryError) {
console.error("Failed to restart server again:", retryError)
throw new Error(
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
)
}
throw new Error(
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
)
}
}
/**
* Get the menu template with translations
*/
@@ -241,14 +192,32 @@ function buildConfigMenu(
type: "radio",
checked: preset.id === currentPresetId,
click: async () => {
try {
await switchPreset(preset.id)
} catch (error) {
// Show error dialog to notify user
dialog.showErrorBox(
"Configuration Error",
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
)
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(preset.id)
if (env) {
try {
await restartNextServer()
rebuildAppMenu() // Rebuild menu to update checkmarks
} catch (error) {
console.error("Failed to restart server:", error)
// Revert to previous preset on failure
if (previousPresetId) {
applyPresetToEnv(previousPresetId)
} else {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
// Show error dialog to notify user
dialog.showErrorBox(
"Configuration Error",
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
},
}))
+69 -123
View File
@@ -1,11 +1,5 @@
import { randomUUID } from "node:crypto"
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import path from "node:path"
import { app, safeStorage } from "electron"
@@ -36,9 +30,7 @@ let hasWarnedAboutPlaintext = false
* Warns if encryption is not available (API key stored in plaintext)
*/
function encryptValue(value: string): string {
// Already encrypted (a value that could not be decrypted): keep it as is
// instead of wrapping it in a second layer of encryption
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
if (!value) {
return value
}
@@ -69,7 +61,6 @@ function encryptValue(value: string): string {
/**
* Decrypt a sensitive value using safeStorage
* Returns the original value if it's not encrypted or decryption fails
* (so saving writes the stored ciphertext back unchanged)
*/
function decryptValue(value: string): string {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
@@ -188,15 +179,6 @@ export function loadPresets(): ConfigPresetsFile {
return data
} catch (error) {
console.error("Failed to load config presets:", error)
// Move the unreadable file aside so the next save can't overwrite
// the user's presets with an empty list
const backupPath = `${configPath}.corrupt-${Date.now()}`
try {
renameSync(configPath, backupPath)
console.error(`Unreadable config presets moved to ${backupPath}`)
} catch (renameError) {
console.error("Failed to back up config presets:", renameError)
}
return {
version: 1,
currentPresetId: null,
@@ -229,11 +211,7 @@ export function savePresets(data: ConfigPresetsFile): void {
}
try {
// Write a temp file and rename it, so a crash mid-write can't leave
// a truncated config file
const tempPath = `${configPath}.tmp`
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
renameSync(tempPath, configPath)
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save config presets:", error)
throw error
@@ -329,10 +307,9 @@ export function deletePreset(id: string): boolean {
data.presets.splice(index, 1)
// Clear current preset (and its env vars) if it was deleted
// Clear current preset if it was deleted
if (data.currentPresetId === id) {
data.currentPresetId = null
setPresetEnv(null)
}
savePresets(data)
@@ -345,15 +322,13 @@ export function deletePreset(id: string): boolean {
export function setCurrentPreset(id: string | null): boolean {
const data = loadPresets()
let preset: ConfigPreset | null = null
if (id !== null) {
preset = data.presets.find((p) => p.id === id) || null
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return false
}
}
setPresetEnv(preset)
data.currentPresetId = id
savePresets(data)
return true
@@ -390,23 +365,78 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
}
/**
* Map a preset's config to environment variables
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// Map generic AI_API_KEY to provider-specific key
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerApiKey = PROVIDER_ENV_MAP[provider].apiKey
if (providerApiKey) {
process.env[providerApiKey] = value
appliedEnv[providerApiKey] = value
}
}
// Map generic AI_BASE_URL to provider-specific key
else if (
key === "AI_BASE_URL" &&
provider &&
PROVIDER_ENV_MAP[provider]
) {
const providerBaseUrl = PROVIDER_ENV_MAP[provider].baseUrl
if (providerBaseUrl) {
process.env[providerBaseUrl] = value
appliedEnv[providerBaseUrl] = value
}
}
// Apply other env vars directly
else {
process.env[key] = value
appliedEnv[key] = value
}
}
}
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Get environment variables from current preset
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
*/
function presetToEnv(preset: ConfigPreset): Record<string, string> {
export function getCurrentPresetEnv(): Record<string, string> {
const preset = getCurrentPreset()
if (!preset) {
return {}
}
const env: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") {
// A key that could not be decrypted is useless to the server
if (value.startsWith(ENCRYPTED_PREFIX)) {
console.warn(
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
)
}
// Map generic AI_API_KEY to provider-specific key
else if (
if (
key === "AI_API_KEY" &&
provider &&
PROVIDER_ENV_MAP[provider]
@@ -436,90 +466,6 @@ function presetToEnv(preset: ConfigPreset): Record<string, string> {
return env
}
/**
* Values that env vars had before a preset first set them
* (from the system or .env files), and the keys the active preset set
*/
const originalEnv: Record<string, string | undefined> = {}
let presetEnvKeys: string[] = []
/**
* Replace the env vars of the previous preset with those of the given preset
* (null leaves no preset applied). Restoring first means switching presets
* never leaves the previous preset's base URL, model or key behind.
*/
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
for (const key of presetEnvKeys) {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
}
const env = preset ? presetToEnv(preset) : {}
for (const [key, value] of Object.entries(env)) {
if (!(key in originalEnv)) {
originalEnv[key] = process.env[key]
}
process.env[key] = value
}
presetEnvKeys = Object.keys(env)
writeDevPresetEnv(env)
return env
}
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
/**
* Development only: write the active preset's env vars (decrypted and mapped)
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
* this file changes. The dev server can't decrypt the config file itself.
*/
function writeDevPresetEnv(env: Record<string, string>): void {
if (app.isPackaged) {
return
}
try {
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
writeFileSync(filePath, JSON.stringify(env, null, 2), {
encoding: "utf-8",
mode: 0o600,
})
} catch (error) {
console.error("Failed to write dev preset env:", error)
}
}
/**
* Apply preset environment variables to the current process
* Returns the environment variables that were applied
*/
export function applyPresetToEnv(id: string): Record<string, string> | null {
const data = loadPresets()
const preset = data.presets.find((p) => p.id === id)
if (!preset) {
return null
}
const appliedEnv = setPresetEnv(preset)
// Set as current preset
data.currentPresetId = id
savePresets(data)
return appliedEnv
}
/**
* Apply the saved current preset's environment variables (used at startup)
*/
export function applyCurrentPresetToEnv(): void {
setPresetEnv(getCurrentPreset())
}
/**
* Get user's preferred locale from config
* Returns undefined if not set
+6 -10
View File
@@ -48,16 +48,12 @@ function loadEnvFromFile(filePath: string): void {
const key = trimmed.slice(0, equalIndex).trim()
let value = trimmed.slice(equalIndex + 1).trim()
const quote = value[0]
const closingQuote =
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
if (closingQuote > 0) {
// Quoted value: keep what's inside the quotes and drop
// anything after them (e.g. a comment)
value = value.slice(1, closingQuote)
} else {
// Unquoted value: drop an inline comment ("value # comment")
value = value.replace(/\s+#.*$/, "")
// Remove surrounding quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
// Don't override existing environment variables
+20 -48
View File
@@ -1,17 +1,12 @@
import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu"
import { applyCurrentPresetToEnv } from "./config-manager"
import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window"
import {
createWindow,
getAppUrl,
getMainWindow,
isAppUrl,
} from "./window-manager"
import { createWindow, getMainWindow } from "./window-manager"
// Single instance lock
const gotTheLock = app.requestSingleInstanceLock()
@@ -33,14 +28,16 @@ if (!gotTheLock) {
// Apply proxy settings from saved config
applyProxyToEnv()
const isDev = !app.isPackaged
// Apply saved preset environment variables (overrides .env)
const presetEnv = getCurrentPresetEnv()
for (const [key, value] of Object.entries(presetEnv)) {
process.env[key] = value
}
const isDev = process.env.NODE_ENV === "development"
let serverUrl: string | null = null
app.whenReady().then(async () => {
// Apply saved preset environment variables (overrides .env).
// Must run after ready: on Windows and Linux safeStorage can't
// decrypt the API key before that.
applyCurrentPresetToEnv()
// Register IPC handlers
registerIpcHandlers()
registerSettingsWindowHandlers()
@@ -49,7 +46,6 @@ if (!gotTheLock) {
buildAppMenu()
try {
let serverUrl: string
if (isDev) {
// Development: use the dev server URL
serverUrl =
@@ -73,9 +69,8 @@ if (!gotTheLock) {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
const appUrl = getAppUrl()
if (appUrl) {
createWindow(appUrl)
if (serverUrl) {
createWindow(serverUrl)
}
}
})
@@ -92,47 +87,24 @@ if (!gotTheLock) {
stopNextServer()
})
// Pages allowed inside app windows: the app server and draw.io
const isInAppUrl = (url: string): boolean => {
if (isAppUrl(url)) return true
try {
const { hostname } = new URL(url)
return ["diagrams.net", "draw.io"].some(
(domain) =>
hostname === domain || hostname.endsWith(`.${domain}`),
)
} catch {
return false
}
}
const isWebUrl = (url: string): boolean =>
url.startsWith("http://") || url.startsWith("https://")
// Open external links in default browser
app.on("web-contents-created", (_, contents) => {
contents.setWindowOpenHandler(({ url }) => {
if (isInAppUrl(url)) {
// Allow diagrams.net iframe
if (
url.includes("diagrams.net") ||
url.includes("draw.io") ||
url.startsWith("http://localhost") ||
url.startsWith("http://127.0.0.1")
) {
return { action: "allow" }
}
// Open other links in external browser
if (isWebUrl(url)) {
if (url.startsWith("http://") || url.startsWith("https://")) {
shell.openExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
// Clicking a plain link would otherwise replace the app page with
// an external site that keeps the preload API
contents.on("will-navigate", (event) => {
if (isInAppUrl(event.url)) {
return
}
event.preventDefault()
if (isWebUrl(event.url)) {
shell.openExternal(event.url)
}
})
})
}
+100 -80
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron"
import { rebuildAppMenu, switchPreset } from "./app-menu"
import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { rebuildAppMenu } from "./app-menu"
import {
applyPresetToEnv,
type ConfigPreset,
createPreset,
deletePreset,
@@ -19,7 +20,6 @@ import {
type ProxyConfig,
saveProxyConfig,
} from "./proxy-manager"
import { isAppUrl } from "./window-manager"
/**
* Allowed configuration keys for presets
@@ -48,32 +48,13 @@ function sanitizePresetConfig(
return sanitized
}
/**
* Register an IPC handler that only answers the app's own pages
* (the main window on the app server, or the local settings page).
* A main window that somehow ends up on an external site still gets the
* preload API, so its calls must be rejected here.
*/
function handle<Args extends unknown[]>(
channel: string,
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
): void {
ipcMain.handle(channel, (event, ...args) => {
const url = event.senderFrame?.url
if (!isAppUrl(url) && !url?.startsWith("file://")) {
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
}
return listener(event, ...(args as Args))
})
}
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
// ==================== App Info ====================
handle("get-version", () => {
ipcMain.handle("get-version", () => {
return app.getVersion()
})
@@ -98,30 +79,84 @@ export function registerIpcHandlers(): void {
win?.close()
})
// ==================== File Dialogs ====================
ipcMain.handle("dialog-open-file", async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return null
const result = await dialog.showOpenDialog(win, {
properties: ["openFile"],
filters: [
{ name: "Draw.io Files", extensions: ["drawio", "xml"] },
{ name: "All Files", extensions: ["*"] },
],
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
// Read the file content
const fs = await import("node:fs/promises")
try {
const content = await fs.readFile(result.filePaths[0], "utf-8")
return content
} catch (error) {
console.error("Failed to read file:", error)
return null
}
})
ipcMain.handle("dialog-save-file", async (event, data: string) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return false
const result = await dialog.showSaveDialog(win, {
filters: [
{ name: "Draw.io Files", extensions: ["drawio"] },
{ name: "XML Files", extensions: ["xml"] },
],
})
if (result.canceled || !result.filePath) {
return false
}
const fs = await import("node:fs/promises")
try {
await fs.writeFile(result.filePath, data, "utf-8")
return true
} catch (error) {
console.error("Failed to save file:", error)
return false
}
})
// ==================== Config Presets ====================
handle("config-presets:get-all", () => {
ipcMain.handle("config-presets:get-all", () => {
return getAllPresets()
})
handle("config-presets:get-current", () => {
ipcMain.handle("config-presets:get-current", () => {
return getCurrentPreset()
})
handle("config-presets:get-current-id", () => {
ipcMain.handle("config-presets:get-current-id", () => {
return getCurrentPresetId()
})
handle(
ipcMain.handle(
"config-presets:save",
async (
(
_event,
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
id?: string
},
) => {
// Validate preset name
if (typeof preset?.name !== "string" || !preset.name.trim()) {
if (typeof preset.name !== "string" || !preset.name.trim()) {
throw new Error("Invalid preset name")
}
@@ -130,48 +165,42 @@ export function registerIpcHandlers(): void {
if (preset.id) {
// Update existing preset
const updated = updatePreset(preset.id, {
return updatePreset(preset.id, {
name: preset.name.trim(),
config: sanitizedConfig,
})
// Re-apply the active preset so the edit takes effect
if (updated && updated.id === getCurrentPresetId()) {
await switchPreset(updated.id)
} else {
rebuildAppMenu()
}
return updated
}
// Create new preset
const created = createPreset({
return createPreset({
name: preset.name.trim(),
config: sanitizedConfig,
})
rebuildAppMenu()
return created
},
)
handle("config-presets:delete", async (_event, id: string) => {
const wasCurrent = id === getCurrentPresetId()
// Deleting the active preset also clears its env vars
const deleted = deletePreset(id)
rebuildAppMenu()
// Restart so the server stops using the deleted preset
if (deleted && wasCurrent && app.isPackaged) {
await restartNextServer()
}
return deleted
ipcMain.handle("config-presets:delete", (_event, id: string) => {
return deletePreset(id)
})
handle("config-presets:apply", async (_event, id: string) => {
ipcMain.handle("config-presets:apply", async (_event, id: string) => {
const env = applyPresetToEnv(id)
if (!env) {
return { success: false, error: "Preset not found" }
}
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development mode, the config file change will trigger
// the file watcher in electron-dev.mjs to restart Next.js
// We just need to save the preset (already done in applyPresetToEnv)
return { success: true, env, devMode: true }
}
// Production mode: restart the Next.js server to apply new environment variables
try {
const env = await switchPreset(id)
// In development mode, electron-dev.mjs restarts Next.js
return app.isPackaged
? { success: true, env }
: { success: true, env, devMode: true }
await restartNextServer()
return { success: true, env }
} catch (error) {
return {
success: false,
@@ -183,39 +212,30 @@ export function registerIpcHandlers(): void {
}
})
handle("config-presets:set-current", (_event, id: string | null) => {
return setCurrentPreset(id)
})
ipcMain.handle(
"config-presets:set-current",
(_event, id: string | null) => {
return setCurrentPreset(id)
},
)
// ==================== Proxy Settings ====================
handle("get-proxy", () => {
ipcMain.handle("get-proxy", () => {
return getProxyConfig()
})
handle("set-proxy", async (_event, config: ProxyConfig) => {
const isOptionalString = (value: unknown) =>
value === undefined || typeof value === "string"
if (
typeof config !== "object" ||
config === null ||
!isOptionalString(config.httpProxy) ||
!isOptionalString(config.httpsProxy)
) {
return { success: false, error: "Invalid proxy settings" }
}
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
try {
// Save config to file
saveProxyConfig({
httpProxy: config.httpProxy,
httpsProxy: config.httpsProxy,
})
saveProxyConfig(config)
// Apply to current process environment
applyProxyToEnv()
if (!app.isPackaged) {
const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development, env vars are already applied
// Next.js dev server may need manual restart
return { success: true, devMode: true }
@@ -237,11 +257,11 @@ export function registerIpcHandlers(): void {
// ==================== User Locale ====================
handle("get-user-locale", () => {
ipcMain.handle("get-user-locale", () => {
return getUserLocale()
})
handle("set-user-locale", (_event, locale: string) => {
ipcMain.handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" }
+42 -71
View File
@@ -6,22 +6,10 @@ import {
getAllocatedPort,
getServerUrl,
isPortAvailable,
saveServerPort,
} from "./port-manager"
import { setAppUrl } from "./window-manager"
let serverProcess: UtilityProcess | null = null
// Start and restart run one at a time, so overlapping calls (e.g. two quick
// preset switches) can't leave two servers running
let serverQueue: Promise<unknown> = Promise.resolve()
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
const result = serverQueue.then(task)
serverQueue = result.catch(() => {})
return result
}
/**
* Get the path to the standalone server resources
* In packaged app: resources/standalone
@@ -57,11 +45,7 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
* Start the Next.js standalone server using Electron's utilityProcess
* This API is designed for running Node.js code in the background
*/
export function startNextServer(): Promise<string> {
return runExclusive(startServer)
}
async function startServer(): Promise<string> {
export async function startNextServer(): Promise<string> {
const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js")
@@ -87,13 +71,6 @@ async function startServer(): Promise<string> {
HOSTNAME: "127.0.0.1",
// Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1",
// The preset keys are the user's own, not a server's
NEXT_AI_DRAWIO_DESKTOP: "1",
}
// Keep requests to local model servers (e.g. Ollama) off the proxy
if (!process.env.NO_PROXY && !process.env.no_proxy) {
env.NO_PROXY = "localhost,127.0.0.1,[::1]"
}
// Set cache directory to a writable location (user's app data folder)
@@ -119,33 +96,28 @@ async function startServer(): Promise<string> {
// Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron
const proc = utilityProcess.fork(serverPath, [], {
serverProcess = utilityProcess.fork(serverPath, [], {
cwd: resourcePath,
env,
stdio: "pipe",
})
serverProcess = proc
proc.stdout?.on("data", (data) => {
serverProcess.stdout?.on("data", (data) => {
console.log(`[Next.js] ${data.toString().trim()}`)
})
proc.stderr?.on("data", (data) => {
serverProcess.stderr?.on("data", (data) => {
console.error(`[Next.js Error] ${data.toString().trim()}`)
})
proc.on("exit", (code) => {
serverProcess.on("exit", (code) => {
console.log(`Next.js server exited with code ${code}`)
// An old server can exit after a new one started; keep the new one
if (serverProcess === proc) {
serverProcess = null
}
serverProcess = null
})
const url = getServerUrl()
await waitForServer(url)
console.log(`Next.js server started at ${url}`)
saveServerPort(port)
return url
}
@@ -154,36 +126,39 @@ async function startServer(): Promise<string> {
* Stop the Next.js server process and wait for it to exit
*/
export async function stopNextServer(): Promise<void> {
const proc = serverProcess
if (!proc) {
return
}
console.log("Stopping Next.js server...")
serverProcess = null
if (serverProcess) {
console.log("Stopping Next.js server...")
// Resolves true when the process exits, false after the timeout
const waitForExit = (ms: number) =>
new Promise<boolean>((resolve) => {
proc.once("exit", () => resolve(true))
setTimeout(() => resolve(false), ms)
// Create a promise that resolves when the process exits
const exitPromise = new Promise<void>((resolve) => {
const proc = serverProcess
if (!proc) {
resolve()
return
}
const onExit = () => {
resolve()
}
proc.once("exit", onExit)
// Timeout after 5 seconds
setTimeout(() => {
proc.removeListener("exit", onExit)
resolve()
}, 5000)
})
proc.kill()
serverProcess.kill()
serverProcess = null
// Next.js waits for open requests (e.g. a streaming reply) before it
// exits, so force kill it if it is still running after 5 seconds
if (!(await waitForExit(5000)) && proc.pid) {
console.warn("Next.js server did not exit in time, force killing it")
try {
process.kill(proc.pid, "SIGKILL")
} catch (error) {
console.error("Failed to force kill Next.js server:", error)
}
await waitForExit(2000)
// Wait for process to exit
await exitPromise
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
}
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
}
/**
@@ -209,19 +184,15 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
/**
* Restart the Next.js server with new environment variables
*/
export function restartNextServer(): Promise<string> {
return runExclusive(async () => {
console.log("Restarting Next.js server...")
export async function restartNextServer(): Promise<string> {
console.log("Restarting Next.js server...")
// Stop the current server and wait for it to exit
await stopNextServer()
// Stop the current server and wait for it to exit
await stopNextServer()
// Wait for the port to be released
await waitForServerStop()
// Wait for the port to be released
await waitForServerStop()
// Start the server again, and follow it if it moved to another port
const url = await startServer()
setAppUrl(url)
return url
})
// Start the server again
return startNextServer()
}
+1 -52
View File
@@ -1,6 +1,4 @@
import { readFileSync, writeFileSync } from "node:fs"
import net from "node:net"
import path from "node:path"
import { app } from "electron"
/**
@@ -25,40 +23,6 @@ const PORT_CONFIG = {
*/
let allocatedPort: number | null = null
/**
* File that remembers the production port from the last launch, so the app
* keeps the same origin (and its localStorage) instead of switching between
* the legacy and new port depending on which one is free at startup
*/
function getSavedPortPath(): string {
return path.join(app.getPath("userData"), "server-port.json")
}
function loadSavedPort(): number | null {
try {
const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8"))
return Number.isInteger(port) ? port : null
} catch {
return null
}
}
/**
* Remember the port of the first production launch. A later launch that
* found it taken keeps it remembered: the user's data lives under that
* origin, and the next launch goes back to it once it is free.
*/
export function saveServerPort(port: number): void {
if (!app.isPackaged || loadSavedPort() !== null) {
return
}
try {
writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8")
} catch (error) {
console.error("Failed to save server port:", error)
}
}
/**
* Check if a specific port is available
*/
@@ -80,8 +44,7 @@ export function isPortAvailable(port: number): Promise<boolean> {
/**
* Find an available port
* - In development: uses fixed port (6002)
* - In production: uses the port from the last launch, then the legacy
* port (61337), then 13370, to preserve localStorage
* - In production: uses fixed port (13370) to preserve localStorage
* - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0)
*
@@ -106,20 +69,6 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null
}
// In production, use the port from the last launch first
if (!isDev) {
const savedPort = loadSavedPort()
if (savedPort !== null) {
if (await isPortAvailable(savedPort)) {
allocatedPort = savedPort
return savedPort
}
console.warn(
`Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`,
)
}
}
// In production, try legacy port first to preserve existing users' localStorage
if (!isDev) {
const legacyPort = PORT_CONFIG.legacyProduction
+5 -18
View File
@@ -13,22 +13,18 @@ function getConfigPath(): string {
/**
* Load proxy configuration from JSON file
* Returns null if the user never saved proxy settings (or the file is invalid)
*/
export function loadProxyConfig(): ProxyConfig | null {
export function loadProxyConfig(): ProxyConfig {
try {
const configPath = getConfigPath()
if (fs.existsSync(configPath)) {
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
if (data && typeof data === "object" && !Array.isArray(data)) {
return data as ProxyConfig
}
console.error("Ignoring invalid proxy config:", data)
const data = fs.readFileSync(configPath, "utf-8")
return JSON.parse(data) as ProxyConfig
}
} catch (error) {
console.error("Failed to load proxy config:", error)
}
return null
return {}
}
/**
@@ -37,11 +33,7 @@ export function loadProxyConfig(): ProxyConfig | null {
export function saveProxyConfig(config: ProxyConfig): void {
try {
const configPath = getConfigPath()
// Write a temp file and rename it, so a crash mid-write can't leave
// a truncated file
const tempPath = `${configPath}.tmp`
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
fs.renameSync(tempPath, configPath)
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
} catch (error) {
console.error("Failed to save proxy config:", error)
throw error
@@ -55,11 +47,6 @@ export function saveProxyConfig(config: ProxyConfig): void {
export function applyProxyToEnv(): void {
const config = loadProxyConfig()
// No saved settings: keep proxy vars inherited from the system or .env
if (!config) {
return
}
if (config.httpProxy) {
process.env.HTTP_PROXY = config.httpProxy
process.env.http_proxy = config.httpProxy
+1 -45
View File
@@ -3,9 +3,6 @@ import { app, BrowserWindow, screen } from "electron"
let mainWindow: BrowserWindow | null = null
// URL of the app server the main window loads
let appUrl: string | null = null
/**
* Get the icon path based on platform
* Note: electron-builder converts icon.png during packaging,
@@ -31,7 +28,6 @@ function getIconPath(): string | undefined {
* Create the main application window
*/
export function createWindow(serverUrl: string): BrowserWindow {
appUrl = serverUrl
const { width, height } = screen.getPrimaryDisplay().workAreaSize
mainWindow = new BrowserWindow({
@@ -60,17 +56,10 @@ export function createWindow(serverUrl: string): BrowserWindow {
})
// Open DevTools in development
if (!app.isPackaged) {
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools()
}
// Override the draw.io iframe's beforeunload handler so the window can
// close after the user edits text in a shape (fixes #815). Diagrams are
// already persisted via autosave, so the prompt is unnecessary.
mainWindow.webContents.on("will-prevent-unload", (event) => {
event.preventDefault()
})
mainWindow.on("closed", () => {
mainWindow = null
})
@@ -97,36 +86,3 @@ export function createWindow(serverUrl: string): BrowserWindow {
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}
/**
* Get the app server URL the main window loads
*/
export function getAppUrl(): string | null {
return appUrl
}
/**
* Point the main window at a new app server URL
* (the restarted server can come up on a different port)
*/
export function setAppUrl(url: string): void {
if (url === appUrl) {
return
}
appUrl = url
mainWindow?.loadURL(url)
}
/**
* Check if a URL belongs to the app server (same origin)
*/
export function isAppUrl(url: string | undefined): boolean {
if (!url || !appUrl) {
return false
}
try {
return new URL(url).origin === new URL(appUrl).origin
} catch {
return false
}
}
+4
View File
@@ -18,6 +18,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"),
// File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
// Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
+6 -7
View File
@@ -213,9 +213,6 @@ async function savePreset() {
}
})
// closeModal() clears editingPresetId, so remember it for the toast
const isEdit = Boolean(editingPresetId)
try {
saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>'
@@ -223,7 +220,10 @@ async function savePreset() {
await window.settingsAPI.savePreset(preset)
await loadPresets()
closeModal()
showToast(isEdit ? "Preset updated" : "Preset created", "success")
showToast(
editingPresetId ? "Preset updated" : "Preset created",
"success",
)
} catch (error) {
console.error("Failed to save preset:", error)
showToast("Failed to save preset", "error")
@@ -265,6 +265,8 @@ async function applyPreset(id) {
const result = await window.settingsAPI.applyPreset(id)
if (result.success) {
currentPresetId = id
renderPresets()
showToast("Preset applied, server restarting...", "success")
} else {
showToast(result.error || "Failed to apply preset", "error")
@@ -272,9 +274,6 @@ async function applyPreset(id) {
} catch (error) {
console.error("Failed to apply preset:", error)
showToast("Failed to apply preset", "error")
} finally {
// Reload to show the active preset and reset the Apply button
await loadPresets()
}
}
+4 -41
View File
@@ -1,22 +1,12 @@
# 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, deepseek, siliconflow, gateway, novita
# Default: bedrock
AI_PROVIDER=bedrock
# 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
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
# so a thinking model can spend it all before the tool call. Users can lower it in Settings,
# and raise it only when they use their own API key, so this also caps cost on server keys.
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
# MAX_OUTPUT_TOKENS=64000
# AWS Bedrock Configuration
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your-access-key-id
@@ -35,8 +25,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENAI_REASONING_SUMMARY=detailed # Optional: Override reasoning summary (none/brief/detailed)
# Anthropic (Direct) Configuration
# ANTHROPIC_API_KEY=sk-ant-... # Sent as `x-api-key` header
# ANTHROPIC_AUTH_TOKEN= # Alternative to ANTHROPIC_API_KEY; sent as `Authorization: Bearer` header (mutually exclusive)
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_BASE_URL=https://your-custom-anthropic/v1
# ANTHROPIC_THINKING_TYPE=enabled # Optional: Anthropic extended thinking (enabled)
# ANTHROPIC_THINKING_BUDGET_TOKENS=12000 # Optional: Budget for extended thinking in tokens
@@ -44,6 +33,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Google Generative AI Configuration
# GOOGLE_GENERATIVE_AI_API_KEY=...
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint
# GOOGLE_CANDIDATE_COUNT=1 # Optional: Number of candidates to generate
# GOOGLE_TOP_K=40 # Optional: Top K sampling parameter
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
@@ -78,10 +68,6 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# OPENROUTER_API_KEY=sk-or-v1-...
# 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_API_KEY=sk-...
# DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 # Optional: Custom endpoint
@@ -123,25 +109,12 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models).
# Claude 4.7 and later reject it; the request is then retried without it.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models)
# TEMPERATURE=0
# Debug Logging (Optional)
# Log the structure of the messages each chat request sends to the model
# DEBUG_LLM_PAYLOAD=true
# Access Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code
# 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)
# 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
@@ -199,13 +172,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key
# 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.
+79 -66
View File
@@ -1,4 +1,5 @@
import type { MutableRefObject } from "react"
import { useRef } from "react"
import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
@@ -6,15 +7,10 @@ import type {
} from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete } from "@/lib/utils"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development"
// display_diagram replaces the document with this one page
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
interface ToolCall {
toolCallId: string
toolName: string
@@ -52,8 +48,6 @@ type ValidateDiagramFn = (
interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
// Failed VLM validations in the current user turn (reset on each user message)
validationRetryCountRef: MutableRefObject<number>
chartXMLRef: MutableRefObject<string>
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string>
@@ -78,7 +72,6 @@ interface UseDiagramToolHandlersParams {
export function useDiagramToolHandlers({
partialXmlRef,
editDiagramOriginalXmlRef,
validationRetryCountRef,
chartXMLRef,
onDisplayChart,
onFetchChart,
@@ -89,6 +82,9 @@ export function useDiagramToolHandlers({
sessionId,
onValidationStateChange,
}: UseDiagramToolHandlersParams) {
// Track validation retry count per tool call
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
// Helper to update validation state
const updateValidationState = (
toolCallId: string,
@@ -178,12 +174,11 @@ NEXT STEP: Call append_diagram with the continuation XML.
const finalXml = xml
partialXmlRef.current = "" // Reset any partial from previous truncation
// Wrap, validate and auto-fix the model's XML like the MCP server's
// create_new_diagram, then load it
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
: prepared.error
// Wrap raw XML with full mxfile structure for draw.io
const fullXml = wrapWithMxFile(finalXml)
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(fullXml)
if (validationError) {
console.warn("[display_diagram] Validation error:", validationError)
@@ -237,15 +232,17 @@ ${finalXml}
)
}
// Each retry is a new tool call, so count attempts per user turn
const attempt = validationRetryCountRef.current + 1
const retryCount =
validationRetryCountRef.current.get(
toolCall.toolCallId,
) || 0
// Notify UI that we're validating (include the image)
updateValidationState(
toolCall.toolCallId,
"validating",
{
attempt,
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData,
},
@@ -257,14 +254,17 @@ ${finalXml}
)
if (!result.valid) {
if (attempt < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current = attempt
if (retryCount < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current.set(
toolCall.toolCallId,
retryCount + 1,
)
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
@@ -274,7 +274,7 @@ ${finalXml}
toolCall.toolCallId,
"failed",
{
attempt,
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
result,
imageData: capturedPngData,
@@ -285,17 +285,19 @@ ${finalXml}
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
})
return
} else {
// Last attempt - accept the diagram with warning
// Max retries reached - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
validationRetryCountRef.current = 0
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
// Notify UI that we're accepting with issues (include the image)
updateValidationState(
@@ -312,8 +314,10 @@ ${finalXml}
return
}
} else {
// Validation passed - reset retry count
validationRetryCountRef.current = 0
// Validation passed - clean up retry count
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
if (DEBUG) {
console.log(
"[display_diagram] Validation passed!",
@@ -378,17 +382,12 @@ ${finalXml}
}
let currentXml = ""
// Use the original XML captured during streaming (shared with chat-message-display)
// This ensures we apply operations to the same base XML that streaming used
const originalXml = editDiagramOriginalXmlRef.current.get(
toolCall.toolCallId,
)
// On failure, undo the streaming preview so the canvas matches the XML
// reported back to the model
const restoreOriginal = () => {
if (originalXml) onDisplayChart(originalXml, true)
}
try {
// Use the original XML captured during streaming (shared with chat-message-display)
// This ensures we apply operations to the same base XML that streaming used
const originalXml = editDiagramOriginalXmlRef.current.get(
toolCall.toolCallId,
)
if (originalXml) {
currentXml = originalXml
} else {
@@ -402,19 +401,26 @@ ${finalXml}
}
}
// All or nothing, checked like the MCP server's edit_diagram.
// The model sees the first page, so edits target it.
const outcome = editDiagram(currentXml, operations, {})
if (!outcome.ok) {
const reason = outcome.pageError
? outcome.errors[0]
: `No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}`
restoreOriginal()
const { applyDiagramOperations } = await import("@/lib/utils")
const { result: editedXml, errors } = applyDiagramOperations(
currentXml,
operations,
)
// Check for operation errors
if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `${reason}
errorText: `Some operations failed:\n${errorMessages}
Current diagram XML:
\`\`\`xml
@@ -428,12 +434,35 @@ Please check the cell IDs and retry.`,
return
}
onDisplayChart(outcome.xml, true)
// loadDiagram validates and returns error if invalid
const validationError = onDisplayChart(editedXml)
if (validationError) {
console.warn(
"[edit_diagram] Validation error:",
validationError,
)
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `Edit produced invalid XML: ${validationError}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please fix the operations to avoid structural issues.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
return
}
onExport()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${outcome.applied} operation(s) to the diagram.`,
output: `Successfully applied ${operations.length} operation(s) to the diagram.`,
})
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
@@ -443,7 +472,6 @@ Please check the cell IDs and retry.`,
const errorMessage =
error instanceof Error ? error.message : String(error)
restoreOriginal()
addToolOutput({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
@@ -468,19 +496,6 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
) => {
const { xml } = toolCall.input as { xml: string }
// Nothing to continue: loading the fragment alone would replace the whole diagram
if (!partialXmlRef.current) {
addToolOutput({
tool: "append_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
})
return
}
// Detect if LLM incorrectly started fresh instead of continuing
// LLM should only output bare mxCells now, so wrapper tags indicate error
const trimmed = xml.trim()
@@ -519,10 +534,8 @@ Start your continuation with the NEXT character after where it stopped.`,
const finalXml = partialXmlRef.current
partialXmlRef.current = "" // Reset
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
: prepared.error
const fullXml = wrapWithMxFile(finalXml)
const validationError = onDisplayChart(fullXml)
if (validationError) {
addToolOutput({
+29 -57
View File
@@ -101,15 +101,6 @@ function saveConfig(config: MultiModelConfig): void {
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
}
/**
* Server model to fall back to: the one marked default, else the first one
*/
function defaultServerModelId(
serverModels: FlattenedServerModel[],
): string | undefined {
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
}
export interface UseModelConfigReturn {
// State
config: MultiModelConfig
@@ -153,16 +144,6 @@ export function useModelConfig(): UseModelConfigReturn {
setIsLoaded(true)
}, [])
// Pick up config changes saved by other tabs, so this tab neither shows a
// stale model nor overwrites their changes on its next save
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
}
window.addEventListener("storage", handleStorage)
return () => window.removeEventListener("storage", handleStorage)
}, [])
// Load server models on mount (if any)
useEffect(() => {
if (typeof window === "undefined") return
@@ -184,18 +165,17 @@ export function useModelConfig(): UseModelConfigReturn {
setServerModels(raw)
setServerLoaded(true)
// Auto-select the default server model if no model is selected,
// or if the saved server model is gone (renamed or removed)
// Auto-select default server model if no model is currently selected
setConfig((prev) => {
const id = prev.selectedModelId
const isStale =
id?.startsWith("server:") &&
!raw.some((m) => m.id === id)
if (id && !isStale) return prev
const fallback = defaultServerModelId(raw)
return fallback === id
? prev
: { ...prev, selectedModelId: fallback }
if (!prev.selectedModelId && raw.length > 0) {
const defaultModel = raw.find((m) => m.isDefault)
if (defaultModel) {
return { ...prev, selectedModelId: defaultModel.id }
}
// If no default marked, use first server model
return { ...prev, selectedModelId: raw[0].id }
}
return prev
})
})
.catch((error) => {
@@ -280,31 +260,24 @@ export function useModelConfig(): UseModelConfigReturn {
[],
)
const deleteProvider = useCallback(
(providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
const deleteProvider = useCallback((providerId: string) => {
setConfig((prev) => {
const provider = prev.providers.find((p) => p.id === providerId)
const modelIds = provider?.models.map((m) => m.id) || []
// Fall back to the default server model if the selected model
// belongs to the deleted provider
const newSelectedId =
prev.selectedModelId &&
modelIds.includes(prev.selectedModelId)
? defaultServerModelId(serverModels)
: prev.selectedModelId
// Clear selected model if it belongs to deleted provider
const newSelectedId =
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
? undefined
: prev.selectedModelId
return {
...prev,
providers: prev.providers.filter(
(p) => p.id !== providerId,
),
selectedModelId: newSelectedId,
}
})
},
[serverModels],
)
return {
...prev,
providers: prev.providers.filter((p) => p.id !== providerId),
selectedModelId: newSelectedId,
}
})
}, [])
const addModel = useCallback(
(providerId: string, modelId: string): ModelConfig => {
@@ -361,15 +334,14 @@ export function useModelConfig(): UseModelConfigReturn {
}
: p,
),
// Fall back to the default server model if the selected model
// was deleted
// Clear selected model if it was deleted
selectedModelId:
prev.selectedModelId === modelConfigId
? defaultServerModelId(serverModels)
? undefined
: prev.selectedModelId,
}))
},
[serverModels],
[],
)
const resetConfig = useCallback(() => {
+4 -32
View File
@@ -1,8 +1,6 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useDictionary } from "@/hooks/use-dictionary"
import {
type ChatSession,
createEmptySession,
@@ -46,15 +44,6 @@ export interface UseSessionManagerReturn {
clearCurrentSession: () => void
}
// Reading the session list loads every stored session in full, and window
// focus also fires each time the user clicks back from the draw.io iframe
const FOCUS_REFRESH_INTERVAL_MS = 30_000
function notifySaveFailed(message: string) {
// Same id, so repeated failures update one toast instead of stacking
toast.error(message, { id: "session-save-failed", duration: 8000 })
}
interface UseSessionManagerOptions {
/** Session ID from URL param - if provided, load this session; if null, start blank */
initialSessionId?: string | null
@@ -64,7 +53,6 @@ export function useSessionManager(
options: UseSessionManagerOptions = {},
): UseSessionManagerReturn {
const { initialSessionId } = options
const dict = useDictionary()
const [sessions, setSessions] = useState<SessionMetadata[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null,
@@ -175,15 +163,9 @@ export function useSessionManager(
handleSessionIdChange()
}, [initialSessionId, isAvailable])
// Refresh sessions on window focus (multi-tab sync), at most once per interval
const lastFocusRefreshRef = useRef(0)
// Refresh sessions on window focus (multi-tab sync)
useEffect(() => {
const handleFocus = () => {
const now = Date.now()
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
return
}
lastFocusRefreshRef.current = now
refreshSessions()
}
window.addEventListener("focus", handleFocus)
@@ -256,8 +238,6 @@ export function useSessionManager(
) {
return
}
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return
if (!currentSession) {
// Create a new session if none exists
@@ -270,12 +250,7 @@ export function useSessionManager(
diagramHistory: data.diagramHistory,
title: extractTitle(data.messages),
}
// Without a stored session, keep no session id (it would end
// up in the URL and point to nothing after a reload)
if (!(await saveSession(newSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
await saveSession(newSession)
await enforceSessionLimit()
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
@@ -302,10 +277,7 @@ export function useSessionManager(
: currentSession.title,
}
if (!(await saveSession(updatedSession))) {
notifySaveFailed(dict.errors.sessionSaveFailed)
return
}
await saveSession(updatedSession)
setCurrentSession(updatedSession)
// Update sessions list metadata
@@ -326,7 +298,7 @@ export function useSessionManager(
),
)
},
[currentSession, currentSessionId, refreshSessions, dict],
[currentSession, currentSessionId, refreshSessions],
)
// Clear current session state (for starting fresh without loading another session)
-3
View File
@@ -6,7 +6,6 @@
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react"
import { getSelectedAIConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path"
import {
type ValidationResult,
@@ -40,8 +39,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
// Resolved per request so a changed access code is picked up
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
onFinish: ({
object,
error: finishError,
+1 -11
View File
@@ -1,17 +1,7 @@
import { LangfuseSpanProcessor } from "@langfuse/otel"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
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)
}
}
export function register() {
// Skip telemetry if Langfuse env vars are not configured
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
console.warn(
-22
View File
@@ -1,22 +0,0 @@
/**
* Check the x-access-code header against ACCESS_CODE_LIST.
* Returns a 401 response to send back when the check fails, or null when the
* request may continue (including when no access codes are configured).
*/
export function checkAccessCode(req: Request): Response | null {
const accessCodes =
process.env.ACCESS_CODE_LIST?.split(",")
.map((code) => code.trim())
.filter(Boolean) || []
if (accessCodes.length === 0) return null
const accessCodeHeader = req.headers.get("x-access-code")
if (accessCodeHeader && accessCodes.includes(accessCodeHeader)) return null
return Response.json(
{
error: "Invalid or missing access code. Please configure it in Settings.",
},
{ status: 401 },
)
}
-37
View File
@@ -1,37 +0,0 @@
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)}` : "••••",
}
}
-315
View File
@@ -1,315 +0,0 @@
import { z } from "zod"
import {
ProviderNameSchema,
type ServerModelsConfig,
slugify,
} 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.`
}
}
// Server model ids are built from the slugified name, so names must
// stay distinct after slugifying ("OpenAI" and "openai" would collide)
const names = list.map((p) => displayName(p))
const slugs = names.map(slugify)
if (new Set(slugs).size !== slugs.length) {
return "Provider display names must be unique (ignoring case and punctuation)."
}
const envSlugs = new Set(envProviders.map((p) => slugify(p.name)))
const clash = names.find((_, i) => envSlugs.has(slugs[i]))
if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
}
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") {
// ADMIN_ names keep the standard AWS_* vars untouched, so other
// AWS clients (e.g. the DynamoDB quota table) keep their own
// credentials instead of picking up the panel's Bedrock keys
if (p.awsAccessKeyId)
updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId
if (p.awsSecretAccessKey)
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
} 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("ADMIN_AWS_ACCESS_KEY_ID")
keys.add("ADMIN_AWS_SECRET_ACCESS_KEY")
keys.add("ADMIN_AWS_REGION")
// Written by older versions; listed so the next save clears them
keys.add("AWS_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION")
} 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]
}
-229
View File
@@ -1,229 +0,0 @@
// 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),
]),
)
-149
View File
@@ -1,149 +0,0 @@
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>
}
interface SettingsState {
// Original env values snapshotted before the first overlay, so removing
// a key from the settings file restores the env default. null = was unset.
originalEnv: Record<string, string | null>
// Keys currently overlaid, so we can restore ones removed from the file.
overlaidKeys: Set<string>
cachedSettings: Record<string, string> | null
}
// Kept on globalThis because the build can load this module more than once
// (instrumentation.ts and the API routes get separate copies); per-module
// state would make a route forget what instrumentation overlaid at startup.
const globalState = globalThis as typeof globalThis & {
__adminSettingsState?: SettingsState
}
globalState.__adminSettingsState ??= {
originalEnv: {},
overlaidKeys: new Set(),
cachedSettings: null,
}
const state = globalState.__adminSettingsState
export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE
if (custom && custom.trim().length > 0) return custom
return path.join(process.cwd(), "data", "settings.json")
}
export function loadSettings(): Record<string, string> {
if (state.cachedSettings) return state.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
}
state.cachedSettings = values
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err)
}
state.cachedSettings = {}
}
return state.cachedSettings
}
export function applyToEnv(): void {
const values = loadSettings()
const { originalEnv } = state
// Restore env for keys that were overlaid before but are now gone
for (const key of state.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
}
state.overlaidKeys = new Set(Object.keys(values))
}
// The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null {
if (state.overlaidKeys.has(key)) return state.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)
state.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 {
state.cachedSettings = null
writableCache = null
for (const key of state.overlaidKeys) {
const original = state.originalEnv[key]
if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original
}
state.overlaidKeys = new Set()
state.originalEnv = {}
}
+704 -431
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,8 +1,6 @@
export interface CachedResponse {
promptText: string
hasImage: boolean
// Name of the bundled example file the prompt is sent with
fileName?: string
xml: string
}
@@ -256,7 +254,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText: "Replicate this in aws style",
hasImage: true,
fileName: "architecture.png",
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
</mxCell>
@@ -321,7 +318,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText: "Replicate this flowchart.",
hasImage: true,
fileName: "example.png",
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
</mxCell>
@@ -383,7 +379,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{
promptText: "Summarize this paper as a diagram",
hasImage: true,
fileName: "chain-of-thought.txt",
xml: `<mxCell id="title_bg" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
value="" vertex="1">
@@ -884,19 +879,14 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
},
]
// Examples that come with a file only match when that exact example file is
// attached, so a user's own file with the same prompt still goes to the model.
// Callers that can't tell file names (the server) only get text-only examples.
export function findCachedResponse(
promptText: string,
hasImage: boolean,
fileName?: string,
): CachedResponse | undefined {
return CACHED_EXAMPLE_RESPONSES.find(
(c) =>
c.promptText === promptText &&
c.hasImage === hasImage &&
(!c.fileName || c.fileName === fileName) &&
c.xml !== "",
)
}
+45 -91
View File
@@ -6,37 +6,25 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export const MAX_FILES = 5
// Helper function to validate file parts in messages
// Checks every message, since history is sent to the model too
export function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
for (const message of messages) {
const fileParts =
message?.parts?.filter((p: any) => p.type === "file") || []
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// The client sends files inline. Any other URL would be downloaded
// by the server (AI SDK does that for models without URL support).
if (
typeof filePart.url !== "string" ||
!filePart.url.startsWith("data:")
) {
return {
valid: false,
error: "Files must be uploaded inline as data URLs.",
}
}
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
@@ -53,83 +41,49 @@ export function validateFileParts(messages: any[]): {
return { valid: true }
}
// A tool-call input providers accept: a non-empty JSON object
function isValidToolInput(input: unknown): boolean {
return !!input && typeof input === "object" && Object.keys(input).length > 0
// Helper function to check if diagram is minimal/empty
export function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
// Also fixes invalid/undefined inputs from interrupted streaming
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content.map((part: any) => {
if (
part.type === "tool-call" &&
isValidToolInput(part.input) &&
(part.toolName === "display_diagram" ||
part.toolName === "edit_diagram")
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
}
return part
})
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}
// Remove tool-calls with invalid inputs (from failed repair or interrupted streaming),
// together with their tool-results: providers reject a result whose call is missing.
// Messages left empty are removed too (Bedrock rejects empty content arrays).
export function dropInvalidToolCalls(messages: any[]): any[] {
const droppedIds = new Set<string>()
return messages
.map((msg) => {
if (!Array.isArray(msg.content)) return msg
const content = msg.content.filter((part: any) => {
if (
msg.role === "assistant" &&
part.type === "tool-call" &&
!isValidToolInput(part.input)
) {
console.warn(
`[chat-helpers] Dropping tool-call with invalid input:`,
{ toolName: part.toolName, input: part.input },
)
droppedIds.add(part.toolCallId)
return false
}
// Results always come after their call, so the id is known by now
return !(
part.type === "tool-result" &&
droppedIds.has(part.toolCallId)
)
})
return { ...msg, content }
})
.filter((msg) => !Array.isArray(msg.content) || msg.content.length > 0)
}
// Fix common LLM JSON mistakes in tool-call input before jsonrepair runs
export function fixToolInputJson(input: string): string {
return (
input
// Inconsistent quote escaping in XML attributes inside JSON strings:
// y="-20\" (opening quote unescaped, closing escaped) becomes y=\"-20\".
// Must run before the key fix below, which would rewrite the `="`.
.replace(/(\w+)="([^"]*?)\\"/g, '$1=\\"$2\\"')
// `:=` instead of `: `
.replace(/:=/g, ": ")
// `"key"= "` instead of `"key": "`, only for JSON keys
.replace(/"(\w+)"\s*=\s*"/g, '"$1": "')
)
}
-94
View File
@@ -1,94 +0,0 @@
import { wrapLanguageModel } from "ai"
import { rejectionText } from "@/lib/output-token-limit"
type WrappedModel = ReturnType<typeof wrapLanguageModel>
/**
* Claude 4.7 and later answer a non-default temperature, top_p or top_k,
* and the extended thinking budget (thinking type "enabled"), with a 400.
* TEMPERATURE and the *_THINKING_BUDGET_TOKENS settings send exactly these.
*/
const DEPRECATED_PARAM =
/`?(?:temperature|top_p|top_k)`? is deprecated for this model|"?thinking\.type\.enabled"? is not supported/i
interface CallParams {
temperature?: number
topP?: number
topK?: number
providerOptions?: Record<string, Record<string, unknown> | undefined>
}
// What these models take instead of a budget. Without display "summarized"
// they think but send no thinking text to show.
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
/** Turn a thinking config of type "enabled" stored under key into adaptive */
function adaptiveThinking(
options: Record<string, unknown> | undefined,
key: string,
): Record<string, unknown> | undefined {
const config = options?.[key] as { type?: string } | undefined
if (config?.type !== "enabled") return options
return { ...options, [key]: ADAPTIVE_THINKING }
}
/**
* The params without the settings newer Claude models reject, or null when
* the error is about something else or there is nothing to change. The
* model then runs with its default sampling, and a thinking budget becomes
* adaptive thinking.
*/
export function withoutDeprecatedParams<T extends CallParams>(
error: unknown,
params: T,
): T | null {
const text = rejectionText(error)
if (!text || !DEPRECATED_PARAM.test(text)) return null
const { temperature, topP, topK, ...rest } = params
const options = params.providerOptions
const anthropic = adaptiveThinking(options?.anthropic, "thinking")
const bedrock = adaptiveThinking(options?.bedrock, "reasoningConfig")
const changed =
temperature !== undefined ||
topP !== undefined ||
topK !== undefined ||
anthropic !== options?.anthropic ||
bedrock !== options?.bedrock
if (!changed) return null
return {
...rest,
...(options && {
providerOptions: {
...options,
...(anthropic && { anthropic }),
...(bedrock && { bedrock }),
},
}),
} as T
}
/** Retry the stream once without the settings newer Claude models reject. */
export function withDeprecatedParamsFallback(
model: WrappedModel,
): WrappedModel {
return wrapLanguageModel({
model,
middleware: {
specificationVersion: "v3",
async wrapStream({ doStream, params, model: inner }) {
try {
return await doStream()
} catch (error) {
const retry = withoutDeprecatedParams(error, params)
if (!retry) throw error
console.warn(
"[model params] Rejected sampling or thinking settings, retrying with default sampling and adaptive thinking",
)
return await inner.doStream(retry)
}
},
},
})
}
-17
View File
@@ -1,17 +0,0 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
+10 -181
View File
@@ -34,8 +34,7 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
"qiniu": "Qiniu"
},
"chat": {
"placeholder": "Describe your diagram or upload a file...",
@@ -103,12 +102,10 @@
"theme": "Theme",
"themeDescription": "Dark/Light mode for interface and DrawIO canvas.",
"drawioStyle": "DrawIO Style",
"drawioStyleDescription": "Canvas style",
"themeDefault": "Default",
"themeDark": "Dark",
"themeMinimal": "Minimal",
"themeSketch": "Sketch",
"themeSimple": "Simple",
"drawioStyleDescription": "Canvas style:",
"switchTo": "Switch to",
"minimal": "Minimal",
"sketch": "Sketch",
"diagramStyle": "Diagram Style",
"diagramStyleDescription": "Toggle between minimal and styled diagram output.",
"sendShortcut": "Send Shortcut",
@@ -132,8 +129,6 @@
"customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
"maxOutputTokens": "Max Output Tokens",
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
"panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats",
@@ -178,6 +173,9 @@
"networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
"validationFailed": "Diagram validation failed. Please try regenerating.",
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
"failedToProcess": "Failed to process diagram. Please try regenerating.",
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage",
@@ -185,23 +183,7 @@
"failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated",
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"llm": {
"invalid_api_key": "The provider rejected the API key. Check it in model settings.",
"forbidden": "The provider refused the request. The key may not have access to this model or region.",
"model_not_found": "The provider does not know this model. Check the model ID in model settings.",
"insufficient_quota": "The provider account has no credit or quota left.",
"rate_limited": "The provider is limiting requests. Wait a moment and try again.",
"context_too_long": "The conversation is too long for this model. Start a new chat or pick a model with a larger context.",
"images_unsupported": "This model doesn't support image input.",
"tools_unsupported": "This model doesn't support tool calls, which drawing needs. Pick another model.",
"output_truncated": "The output was cut off before the diagram was complete. Try a simpler request or raise the output limit in settings.",
"provider_unavailable": "The provider is having problems. Try again later.",
"cannot_connect": "Could not reach the provider. Check the base URL and your network.",
"timeout": "The provider did not answer in time.",
"openModelSettings": "Open model settings"
}
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
},
"quota": {
"dailyLimit": "Daily Quota Reached",
@@ -248,7 +230,6 @@
"reasoning": {
"thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds",
"thoughtForOne": "Thought for 1 second",
"thoughtBrief": "Thought for a few seconds"
},
"dev": {
@@ -385,15 +366,9 @@
"enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL",
"optional": "(optional)",
"getApiKey": "Get API key",
"fetchModels": "Fetch models from the provider",
"noTools": "no tool calls",
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
"requestUrl": "Requests go to {url}",
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"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",
"customModelId": "Custom model ID...",
"allAdded": "All added",
@@ -425,152 +400,6 @@
"showUnvalidatedModels": "Show unvalidated models",
"allModelsShown": "All models are shown (including unvalidated)",
"unvalidatedModelWarning": "This model has not been validated",
"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."
}
}
"serverDefaultModel": "Server default model"
}
}
+10 -181
View File
@@ -34,8 +34,7 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (Xiaomi)"
"qiniu": "Qiniu"
},
"chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -103,12 +102,10 @@
"theme": "テーマ",
"themeDescription": "インターフェースと DrawIO キャンバスのダーク/ライトモード。",
"drawioStyle": "DrawIO スタイル",
"drawioStyleDescription": "キャンバススタイル",
"themeDefault": "デフォルト",
"themeDark": "ダーク",
"themeMinimal": "ミニマル",
"themeSketch": "スケッチ",
"themeSimple": "シンプル",
"drawioStyleDescription": "キャンバススタイル:",
"switchTo": "切り替え",
"minimal": "ミニマル",
"sketch": "スケッチ",
"diagramStyle": "ダイアグラムスタイル",
"diagramStyleDescription": "ミニマルとスタイル付きの出力を切り替えます。",
"sendShortcut": "送信ショートカット",
@@ -132,8 +129,6 @@
"customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"maxOutputTokens": "最大出力トークン数",
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
"panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット",
@@ -178,6 +173,9 @@
"networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました",
@@ -185,23 +183,7 @@
"failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"llm": {
"invalid_api_key": "プロバイダーが API キーを拒否しました。モデル設定で確認してください。",
"forbidden": "プロバイダーがリクエストを拒否しました。このキーにはこのモデルまたはリージョンの利用権限がない可能性があります。",
"model_not_found": "プロバイダーがこのモデルを認識できません。モデル設定でモデル ID を確認してください。",
"insufficient_quota": "プロバイダーのアカウントの残高または利用枠がなくなりました。",
"rate_limited": "プロバイダーがリクエスト数を制限しています。少し待ってから再試行してください。",
"context_too_long": "会話がこのモデルで扱える長さを超えています。新しいチャットを始めるか、より長いコンテキストに対応したモデルを選んでください。",
"images_unsupported": "このモデルは画像入力に対応していません。",
"tools_unsupported": "このモデルはツール呼び出しに対応していません。作図にはツール呼び出しが必要です。別のモデルを選んでください。",
"output_truncated": "ダイアグラムが完成する前に出力が途中で切れました。リクエストを簡単にするか、設定で出力上限を上げてください。",
"provider_unavailable": "プロバイダーで問題が発生しています。しばらくしてから再試行してください。",
"cannot_connect": "プロバイダーに接続できません。Base URL とネットワークを確認してください。",
"timeout": "プロバイダーから時間内に応答がありませんでした。",
"openModelSettings": "モデル設定を開く"
}
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
},
"quota": {
"dailyLimit": "1日の割当量に達しました",
@@ -248,7 +230,6 @@
"reasoning": {
"thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました",
"thoughtForOne": "1 秒考えました",
"thoughtBrief": "数秒考えました"
},
"dev": {
@@ -339,15 +320,9 @@
"enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL",
"optional": "(オプション)",
"getApiKey": "API キーを取得",
"fetchModels": "プロバイダーからモデル一覧を取得",
"noTools": "ツール呼び出し非対応",
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
"customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
"models": "モデル",
"customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み",
@@ -379,9 +354,7 @@
"showUnvalidatedModels": "未検証のモデルを表示",
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
"unvalidatedModelWarning": "このモデルは検証されていません",
"serverDefaultModel": "サーバーデフォルトモデル",
"showValue": "値を表示",
"hideValue": "値を非表示"
"serverDefaultModel": "サーバーデフォルトモデル"
},
"templates": {
"title": "マイテンプレート",
@@ -428,149 +401,5 @@
"importNoFile": "JSON ファイルを選択してください",
"importFailed": "インポートに失敗しました:{error}",
"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 日のリセット境界に使用するタイムゾーン。"
}
}
}
}
+10 -181
View File
@@ -34,8 +34,7 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
"qiniu": "Qiniu"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
@@ -103,12 +102,10 @@
"theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式",
"themeDefault": "預設",
"themeDark": "深色",
"themeMinimal": "簡約",
"themeSketch": "草圖",
"themeSimple": "簡單",
"drawioStyleDescription": "畫布樣式:",
"switchTo": "切換到",
"minimal": "簡約",
"sketch": "草圖",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",
@@ -132,8 +129,6 @@
"customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"maxOutputTokens": "最大輸出 token 數",
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
"panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天",
@@ -178,6 +173,9 @@
"networkError": "網路錯誤。請檢查您的連線。",
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
@@ -185,23 +183,7 @@
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"llm": {
"invalid_api_key": "服務商拒絕了這個 API Key,請在模型設定中檢查。",
"forbidden": "服務商拒絕了這次請求。這個 Key 可能沒有使用該模型或該地區的權限。",
"model_not_found": "服務商找不到這個模型,請在模型設定中檢查模型 ID。",
"insufficient_quota": "服務商帳戶的餘額或額度已經用完。",
"rate_limited": "服務商正在限制請求頻率,請稍候再試。",
"context_too_long": "對話內容超過了這個模型能處理的長度。請開啟新的對話,或換一個上下文更長的模型。",
"images_unsupported": "這個模型不支援圖片輸入。",
"tools_unsupported": "這個模型不支援工具呼叫,而繪圖需要工具呼叫。請換一個模型。",
"output_truncated": "輸出在圖表完成之前就被截斷了。請簡化請求,或在設定中調高輸出上限。",
"provider_unavailable": "服務商發生問題,請稍後再試。",
"cannot_connect": "無法連線到服務商,請檢查 Base URL 和網路。",
"timeout": "服務商沒有及時回應。",
"openModelSettings": "開啟模型設定"
}
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
},
"quota": {
"dailyLimit": "已達每日配額",
@@ -248,7 +230,6 @@
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
@@ -385,15 +366,9 @@
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"getApiKey": "取得 API Key",
"fetchModels": "從服務商取得模型清單",
"noTools": "不支援工具呼叫",
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
"requestUrl": "請求將傳送至 {url}",
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
"customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型",
"customModelId": "自訂模型 ID...",
"allAdded": "已全部新增",
@@ -425,152 +400,6 @@
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"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": "每日重置邊界所用的時區。"
}
}
"serverDefaultModel": "伺服器預設模型"
}
}
+10 -181
View File
@@ -34,8 +34,7 @@
"glm": "GLM",
"qwen": "Qwen",
"kimi": "Kimi",
"qiniu": "Qiniu",
"mimo": "MiMo (小米)"
"qiniu": "Qiniu"
},
"chat": {
"placeholder": "描述您的图表或上传文件...",
@@ -103,12 +102,10 @@
"theme": "主题",
"themeDescription": "界面和 DrawIO 画布的深色/浅色模式。",
"drawioStyle": "DrawIO 样式",
"drawioStyleDescription": "画布样式",
"themeDefault": "默认",
"themeDark": "深色",
"themeMinimal": "简约",
"themeSketch": "草图",
"themeSimple": "简单",
"drawioStyleDescription": "画布样式:",
"switchTo": "切换到",
"minimal": "简约",
"sketch": "草图",
"diagramStyle": "图表样式",
"diagramStyleDescription": "切换简约与精致图表输出模式。",
"sendShortcut": "发送快捷键",
@@ -132,8 +129,6 @@
"customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"maxOutputTokens": "最大输出 token 数",
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
"panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天",
@@ -178,6 +173,9 @@
"networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
"validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。",
"sessionCorrupted": "会话数据已损坏。重新开始。",
"failedToSave": "无法保存消息到 localStorage",
"failedToRestore": "无法从 localStorage 恢复",
@@ -185,23 +183,7 @@
"failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"llm": {
"invalid_api_key": "服务商拒绝了这个 API Key,请在模型设置里检查。",
"forbidden": "服务商拒绝了这次请求。这个 Key 可能没有使用该模型或该地区的权限。",
"model_not_found": "服务商找不到这个模型,请在模型设置里检查模型 ID。",
"insufficient_quota": "服务商账户的余额或额度已经用完。",
"rate_limited": "服务商正在限制请求频率,请稍等片刻再试。",
"context_too_long": "对话内容超过了这个模型能处理的长度。请新开一个对话,或换一个上下文更长的模型。",
"images_unsupported": "这个模型不支持图片输入。",
"tools_unsupported": "这个模型不支持工具调用,而画图需要工具调用。请换一个模型。",
"output_truncated": "输出在图画完之前就被截断了。请简化请求,或在设置里调高输出上限。",
"provider_unavailable": "服务商出了问题,请稍后再试。",
"cannot_connect": "连接不上服务商,请检查 Base URL 和网络。",
"timeout": "服务商没有及时响应。",
"openModelSettings": "打开模型设置"
}
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
},
"quota": {
"dailyLimit": "已达每日配额",
@@ -248,7 +230,6 @@
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了几秒钟"
},
"dev": {
@@ -385,15 +366,9 @@
"enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL",
"optional": "(可选)",
"getApiKey": "获取 API Key",
"fetchModels": "从服务商获取模型列表",
"noTools": "不支持工具调用",
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
"requestUrl": "请求将发往 {url}",
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
"customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型",
"customModelId": "自定义模型 ID...",
"allAdded": "已全部添加",
@@ -425,152 +400,6 @@
"showUnvalidatedModels": "显示未验证的模型",
"allModelsShown": "显示所有模型(包括未验证的)",
"unvalidatedModelWarning": "此模型尚未验证",
"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": "每日重置边界所用的时区。"
}
}
"serverDefaultModel": "服务器默认模型"
}
}
+1 -8
View File
@@ -51,15 +51,8 @@ export function setTraceOutput(output: string) {
if (!isLangfuseEnabled()) return
updateActiveTrace({ output })
endTrace()
}
// End the observe() wrapper span (AI SDK creates its own child spans with usage).
// It uses endOnExit: false, so every request path has to end it, or the trace
// is never exported: stream finish, stream error/abort, and early returns.
export function endTrace() {
if (!isLangfuseEnabled()) return
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
const activeSpan = api.trace.getActiveSpan()
if (activeSpan) {
activeSpan.end()
-178
View File
@@ -1,178 +0,0 @@
import {
APICallError,
InvalidToolInputError,
LoadAPIKeyError,
NoSuchToolError,
RetryError,
ToolCallRepairError,
} from "ai"
/**
* What went wrong with a model call, for a hint the user can act on. The
* provider's own message always goes along, because a guess can be wrong.
*/
export type LLMErrorCode =
| "invalid_api_key"
| "forbidden"
| "model_not_found"
| "insufficient_quota"
| "rate_limited"
| "context_too_long"
| "images_unsupported"
| "tools_unsupported"
| "output_truncated"
| "provider_unavailable"
| "cannot_connect"
| "timeout"
| "unknown"
export interface LLMError {
type: "provider"
code: LLMErrorCode
message: string
}
// Texts that name the cause more precisely than the status code: a quota
// error can come as 403 or 429, a context or image error as a plain 400
const SPECIFIC_TEXTS: Array<[RegExp, LLMErrorCode]> = [
[
// Not "too many tokens": that is Bedrock's throttling message
/context length|context window|maximum context|prompt is too long|input is too long|too many input tokens/i,
"context_too_long",
],
[
/image content block|image_url|does not support image|image input is not supported/i,
"images_unsupported",
],
[
/does not support tools|tool use is not supported|tools? (?:are|is) not supported|function calling is not supported/i,
"tools_unsupported",
],
// Bedrock, when the output limit cut the tool call's JSON short
[/toolUse\.input is invalid/i, "output_truncated"],
// Bedrock, for a model id without the inference profile prefix
[/on-demand throughput isn.t supported/i, "model_not_found"],
[
/insufficient[_ ]quota|insufficient balance|exceeded your current quota|credit balance is too low|余额不足/i,
"insufficient_quota",
],
]
const STATUS_CODES: Record<number, LLMErrorCode> = {
401: "invalid_api_key",
402: "insufficient_quota",
// Not "invalid key": a valid key can lack access to a model or region
403: "forbidden",
404: "model_not_found",
408: "timeout",
// A retired model
410: "model_not_found",
413: "context_too_long",
429: "rate_limited",
}
const GENERAL_TEXTS: Array<[RegExp, LLMErrorCode]> = [
[
/model[_ ]not[_ ]found|model .*does not exist|unknown model|no such model/i,
"model_not_found",
],
[
/invalid[_ ]api[_ ]key|incorrect api key|unauthorized/i,
"invalid_api_key",
],
[/rate limit|too many requests/i, "rate_limited"],
[
/Cannot connect to API|ECONNREFUSED|ENOTFOUND|ECONNRESET|ETIMEDOUT|fetch failed/i,
"cannot_connect",
],
]
/** Secrets a provider may echo back: API keys, Bearer tokens, key=value */
function redact(text: string): string {
return text
.replace(/\b(sk|pk|rk|ak)-[A-Za-z0-9_-]{8,}/g, "$1-[redacted]")
.replace(/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
.replace(
/\b(api[_-]?key|access[_-]?key|secret|token|password|signature)(["']?\s*[:=]\s*["']?)[^\s"',&}]+/gi,
"$1$2[redacted]",
)
}
function problemDetail(body: string): string | undefined {
try {
const detail = JSON.parse(body)?.detail
return typeof detail === "string" ? detail : undefined
} catch {
return undefined
}
}
/**
* The error text for the chat stream: what went wrong with the provider as
* JSON for the hint, or the text the model must read to fix a tool call.
* On the server's keys the provider's own text stays in the server log:
* it can name the server's account, role or internal hosts.
*/
export function streamErrorText(error: unknown, hideDetails = false): string {
// The SDK passes an invalid tool call's error as a plain string
if (typeof error === "string") return error
if (isToolCallError(error)) return (error as Error).message
const classified = classifyLLMError(error)
if (hideDetails) {
console.error("[chat] Provider error:", error)
classified.message = "The provider returned an error."
}
return JSON.stringify(classified)
}
/**
* Model and tool errors the SDK sends back to the model as the tool result,
* so it can fix its call. Their text has to stay as it is.
*/
export function isToolCallError(error: unknown): boolean {
return (
InvalidToolInputError.isInstance(error) ||
NoSuchToolError.isInstance(error) ||
ToolCallRepairError.isInstance(error)
)
}
export function classifyLLMError(error: unknown): LLMError {
// After the SDK's retries, the last attempt says what happened
const e = RetryError.isInstance(error) ? error.lastError : error
// Errors sent inside the stream can be plain objects like OpenRouter's
// { code: 503, message }
const plain = e as {
message?: unknown
code?: unknown
statusCode?: number
}
const raw =
e instanceof Error
? e.message
: typeof plain?.message === "string"
? plain.message
: String(e)
const body = APICallError.isInstance(e) ? (e.responseBody ?? "") : ""
// A problem+json body names the reason the SDK left out (NVIDIA: "Gone")
const detail = problemDetail(body)
const message = redact(detail ? `${raw}: ${detail}` : raw).slice(0, 500)
const text = `${raw} ${body}`
const status = APICallError.isInstance(e)
? e.statusCode
: (plain?.statusCode ??
(typeof plain?.code === "number" ? plain.code : undefined))
const find = (rules: Array<[RegExp, LLMErrorCode]>) =>
rules.find(([pattern]) => pattern.test(text))?.[1]
const code =
(e instanceof Error && e.name === "TimeoutError" && "timeout") ||
(LoadAPIKeyError.isInstance(e) && "invalid_api_key") ||
find(SPECIFIC_TEXTS) ||
(status && STATUS_CODES[status]) ||
(status && status >= 500 && "provider_unavailable") ||
find(GENERAL_TEXTS) ||
"unknown"
return { type: "provider", code, message }
}
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
import catalog from "@/lib/model-catalog.json"
import type { ProviderName } from "@/lib/types/model-config"
/**
* What models.dev knows about a model (scripts/update-model-catalog.mjs).
* Only used for hints: requests are sent the same way either way, since
* the data can be wrong or out of date.
*/
export interface ModelInfo {
tools: boolean
images: boolean
reasoning: boolean
context?: number
output?: number
}
const CATALOG = catalog as Record<string, Record<string, ModelInfo>>
/**
* The entry for a model: an exact match ignoring case, else the longest id
* the model id starts with, followed by "-", ":" or ".". So
* claude-sonnet-4-5-20250929 finds claude-sonnet-4-5, but gpt-4 does not
* find gpt-4o.
*/
export function getModelInfo(
provider: ProviderName,
modelId: string,
): ModelInfo | undefined {
const models = CATALOG[provider]
if (!models) return undefined
const wanted = modelId.trim().toLowerCase()
let best: string | undefined
for (const id of Object.keys(models)) {
const lower = id.toLowerCase()
if (lower === wanted) return models[id]
if (
wanted.startsWith(lower) &&
"-:.".includes(wanted[lower.length]) &&
lower.length > (best?.length ?? 0)
) {
best = id
}
}
return best ? models[best] : undefined
}
-224
View File
@@ -1,224 +0,0 @@
import { wrapLanguageModel } from "ai"
type WrappedModel = ReturnType<typeof wrapLanguageModel>
/**
* Default output budget for a chat turn.
*
* This has to cover thinking + prose + the tool call, because reasoning models
* spend it in that order. Measured on deepseek-v4-flash: refining an existing
* diagram burned 16000 tokens on thinking alone and the request ended with
* finishReason "length" before display_diagram was ever called (issue #924).
* 64000 leaves room for the plan and the XML in one turn.
*/
export const DEFAULT_MAX_OUTPUT_TOKENS = 64000
/** Ceiling for the user-supplied override, to catch typos like an extra zero. */
export const MAX_OUTPUT_TOKENS_LIMIT = 200000
/**
* Below this a diagram cannot come out whole, so a retry would just produce
* truncated XML instead of the provider's error. Better to surface the error.
*/
const MIN_USABLE_OUTPUT_TOKENS = 1024
/**
* Retry budget when a rejection names the budget parameter but no number we can
* read. It is the default from before 64000, which these providers ran with.
*/
const FALLBACK_OUTPUT_TOKENS = 16000
/** Status codes that can carry a complaint about the requested budget. */
const BUDGET_REJECTION_STATUSES = new Set([400, 422])
function usableLimit(value: number): number | null {
return value >= MIN_USABLE_OUTPUT_TOKENS ? value : null
}
/** Message and body of an error that may be about the budget, or null. */
export function rejectionText(error: unknown): string | null {
const err = error as {
message?: unknown
responseBody?: unknown
statusCode?: unknown
}
// An auth or rate-limit failure is not about the budget, so leave it alone.
if (
typeof err?.statusCode === "number" &&
!BUDGET_REJECTION_STATUSES.has(err.statusCode)
) {
return null
}
const text = [
typeof err?.message === "string" ? err.message : "",
typeof err?.responseBody === "string" ? err.responseBody : "",
].join(" ")
return text.trim() ? text : null
}
/**
* A budget this large exceeds what some models accept. Providers reject it with a
* 400 that names the real limit, so we parse the number out and retry once
* instead of failing the turn.
*
* Formats seen in the wild:
* - Bedrock: "The maximum tokens you requested exceeds the model limit of 4096."
* - OpenRouter: "This endpoint's maximum context length is 64000 tokens. However,
* you requested about 64025 tokens (25 of text input, 64000 in the output)."
* Note this one is an input+output ceiling, so the input has to be subtracted.
* vLLM and SGLang send the same kind of ceiling, with the input written as
* "6000 in the messages", "has 6000 input tokens" or "6000 tokens from the input".
* - Anthropic: "max_tokens: 200000 > 64000, which is the maximum allowed..."
* - OpenAI: "This model supports at most 16384 completion tokens"
* - Volcengine Ark: "The parameter `max_tokens` specified in the request are not
* valid: integer above maximum value, expected a value <= 32768, but got 64000"
* - DashScope: "Range of max_tokens should be [1, 8192]"
*
* Every pattern names tokens explicitly. A generic one (an earlier draft matched
* "lower than N") would reinterpret unrelated failures, and retrying on a bogus
* number turns a readable error into an empty diagram.
*/
function readCeiling(text: string): number | null {
// Combined input+output ceiling: subtract the input the provider counted,
// plus a small margin because its estimate is approximate.
const context = text.match(/maximum context length (?:is|of) (\d+)/i)
if (context) {
const input =
text.match(/(\d+) of text input/i) ||
text.match(/(\d+) in the messages/i) ||
text.match(/(\d+) tokens from the input/i) ||
text.match(/(\d+) input tokens/i)
return Number(context[1]) - (input ? Number(input[1]) : 0) - 1024
}
const output =
text.match(/model limit of (\d+)/i) ||
text.match(/> (\d+), which is the maximum/i) ||
text.match(/at most (\d+) completion tokens/i) ||
text.match(/max_\w*tokens.*?expected a value (?:<=|\\u003c=) (\d+)/i) ||
text.match(/Range of max_tokens should be \[1,\s*(\d+)\]/i)
return output ? Number(output[1]) : null
}
/** The usable output ceiling named in a rejection, or null. */
export function parseOutputTokenLimit(error: unknown): number | null {
const text = rejectionText(error)
const ceiling = text ? readCeiling(text) : null
return ceiling === null ? null : usableLimit(ceiling)
}
/**
* Thinking budget the provider adds on top of maxOutputTokens. Bedrock and
* Anthropic send maxOutputTokens + budgetTokens as max_tokens, so a ceiling in
* their rejection covers both.
*/
function thinkingBudget(providerOptions: unknown): number {
const options = providerOptions as
| {
bedrock?: {
reasoningConfig?: { type?: string; budgetTokens?: unknown }
}
anthropic?: {
thinking?: { type?: string; budgetTokens?: unknown }
}
}
| undefined
const config =
options?.bedrock?.reasoningConfig ?? options?.anthropic?.thinking
return config?.type === "enabled" && typeof config.budgetTokens === "number"
? config.budgetTokens
: 0
}
/**
* The budget to retry with after a rejection, or null to surface the error.
*/
export function retryOutputTokens(
error: unknown,
params: { maxOutputTokens?: number; providerOptions?: unknown },
): number | null {
const requested = params.maxOutputTokens
const text = rejectionText(error)
if (!requested || !text) return null
const ceiling = readCeiling(text)
if (ceiling !== null) {
// The ceiling applies to what was actually sent, thinking included,
// so the retry has to leave room for the thinking too.
const thinking = thinkingBudget(params.providerOptions)
if (ceiling >= requested + thinking) return null
return usableLimit(ceiling - thinking)
}
// Names the budget parameter, but in a format we cannot read a number from
if (/max_\w*tokens/i.test(text) && requested > FALLBACK_OUTPUT_TOKENS) {
return FALLBACK_OUTPUT_TOKENS
}
return null
}
/**
* Retry the stream once with a smaller budget when the provider rejects the
* requested one. Without this, raising the default breaks every model whose
* ceiling is below it (measured: bedrock claude-3-haiku 4096, nova-lite 10000,
* openrouter deepseek-r1 64000 shared with the input).
*/
export function withOutputTokenLimitFallback(
model: WrappedModel,
): WrappedModel {
return wrapLanguageModel({
model,
middleware: {
specificationVersion: "v3",
async wrapStream({ doStream, params, model: inner }) {
try {
return await doStream()
} catch (error) {
const retry = retryOutputTokens(error, params)
if (!retry) throw error
console.warn(
`[maxOutputTokens] ${params.maxOutputTokens} rejected, retrying with ${retry}`,
)
return await inner.doStream({
...params,
maxOutputTokens: retry,
})
}
},
},
})
}
function validBudget(value: string | null | undefined): number | null {
const parsed = Number(value)
return Number.isInteger(parsed) &&
parsed > 0 &&
parsed <= MAX_OUTPUT_TOKENS_LIMIT
? parsed
: null
}
/**
* Resolve the output budget: user setting (sent as a header so it works in the
* desktop app too), then server env, then the default. Both sources go through
* the same validation, so a typo in either falls back instead of reaching the
* provider.
*
* On the server's credentials the user setting can only lower the server value,
* so MAX_OUTPUT_TOKENS keeps capping what the server pays for.
*/
export function resolveMaxOutputTokens(
headerValue: string | null,
usesServerCredentials: boolean,
): number {
const header = validBudget(headerValue)
const server =
validBudget(process.env.MAX_OUTPUT_TOKENS) ?? DEFAULT_MAX_OUTPUT_TOKENS
if (header === null) return server
return usesServerCredentials ? Math.min(header, server) : header
}
+3 -6
View File
@@ -1,4 +1,4 @@
import { extractText } from "unpdf"
import { extractText, getDocumentProxy } from "unpdf"
// Maximum characters allowed for extracted text (configurable via env)
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
@@ -14,7 +14,6 @@ const TEXT_EXTENSIONS = [
".json",
".csv",
".xml",
".svg",
".html",
".css",
".js",
@@ -44,10 +43,8 @@ const TEXT_EXTENSIONS = [
*/
export async function extractPdfText(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
// Pass raw bytes so unpdf destroys the PDF document when it is done
const { text } = await extractText(new Uint8Array(buffer), {
mergePages: true,
})
const pdf = await getDocumentProxy(new Uint8Array(buffer))
const { text } = await extractText(pdf, { mergePages: true })
return text as string
}
-200
View File
@@ -1,200 +0,0 @@
import { createGateway } from "ai"
import { getModelInfo } from "@/lib/model-catalog"
import {
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
/** A model a provider offers. tools is false when it cannot call tools. */
export interface ListedModel {
id: string
tools?: boolean
}
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
export function canListModels(provider: ProviderName): boolean {
return (
Object.hasOwn(PROVIDER_INFO, provider) &&
!!PROVIDER_INFO[provider].modelList
)
}
// Models in OpenAI-style lists that are not for chat
const NON_CHAT =
/(?:^|[-/_])(?:embed(?:ding)?s?|whisper|tts|transcribe|dall-e|moderation|rerank|realtime|sora)(?:$|[-/_])|gpt-image/i
const NON_CHAT_AIHUBMIX_TYPES = new Set([
"embedding",
"image_generation",
"rerank",
"transcription",
"tts",
"video",
])
/** Chat model ids from AIHubMix's public model list */
export function extractAihubmixModelIds(payload: unknown): string[] {
const data = (payload as { data?: unknown })?.data
if (!Array.isArray(data)) return []
const ids = new Set<string>()
for (const item of data) {
const record = item as { model_id?: unknown; types?: unknown }
if (typeof record?.model_id !== "string" || !record.model_id.trim()) {
continue
}
const types = new Set(
typeof record.types === "string"
? record.types.split(",").map((t) => t.trim())
: [],
)
if (!types.has("llm")) continue
if ([...NON_CHAT_AIHUBMIX_TYPES].some((t) => types.has(t))) continue
ids.add(record.model_id.trim())
}
return [...ids]
}
/** GET a JSON list; a failed request carries its status for the error hint */
async function getJson(
url: string,
headers: Record<string, string>,
fetchFn: typeof fetch,
): Promise<any> {
const response = await fetchFn(url, {
headers,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
throw Object.assign(
new Error(`The model list request failed (${response.status})`),
{ statusCode: response.status },
)
}
return response.json()
}
/**
* Where to list from without the user's base URL: where chat goes then. For
* Ollama that is the server's Ollama, else the SDK's local default; a local
* default in PROVIDER_INFO (SGLang's) only fills the settings form.
*/
function listFallbackUrl(provider: ProviderName): string {
if (provider === "ollama") {
return process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434/api"
}
const url = PROVIDER_INFO[provider].defaultBaseUrl
return url?.startsWith("https://") ? url : ""
}
/**
* The provider's chat models, with tool support from the provider's own
* data or else models.dev. Only the client's key is used, so the server's
* keys never go to a URL the client chose.
*/
export async function listProviderModels(
provider: ProviderName,
{ apiKey, baseUrl }: { apiKey?: string; baseUrl?: string },
fetchFn: typeof fetch = fetch,
): Promise<ListedModel[]> {
const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider))
const bearer: Record<string, string> = apiKey
? { Authorization: `Bearer ${apiKey}` }
: {}
let models: ListedModel[]
// AIHubMix has a public list, unless the user points to another
// endpoint, which is OpenAI-compatible
const style =
provider === "aihubmix" &&
baseUrl &&
!/^https:\/\/aihubmix\.com(\/v1)?$/.test(base)
? "openai"
: PROVIDER_INFO[provider].modelList
switch (style) {
case "anthropic": {
const data = await getJson(
`${base}/models?limit=1000`,
{
"x-api-key": apiKey ?? "",
"anthropic-version": "2023-06-01",
},
fetchFn,
)
models = (data.data ?? []).map((m: { id: string }) => ({
id: m.id,
}))
break
}
case "google": {
// The key goes in a header: in the URL it would end up in logs
const data = await getJson(
`${base}/models?pageSize=1000`,
{ "x-goog-api-key": apiKey ?? "" },
fetchFn,
)
models = (data.models ?? [])
.filter((m: { supportedGenerationMethods?: string[] }) =>
m.supportedGenerationMethods?.includes("generateContent"),
)
.map((m: { name: string }) => ({
id: m.name.replace(/^models\//, ""),
}))
break
}
case "ollama": {
const api = base.endsWith("/api") ? base : `${base}/api`
const data = await getJson(`${api}/tags`, bearer, fetchFn)
models = (data.models ?? []).map((m: { name: string }) => ({
id: m.name,
}))
break
}
case "openrouter": {
const data = await getJson(`${base}/models`, bearer, fetchFn)
models = (data.data ?? []).map(
(m: { id: string; supported_parameters?: string[] }) => ({
id: m.id,
...(m.supported_parameters && {
tools: m.supported_parameters.includes("tools"),
}),
}),
)
break
}
case "gateway": {
const { models: entries } = await createGateway({
...(apiKey && { apiKey }),
...(baseUrl && { baseURL: base }),
fetch: fetchFn,
}).getAvailableModels()
models = entries
.filter((m) => !m.modelType || m.modelType === "language")
.map((m) => ({ id: m.id }))
break
}
case "aihubmix": {
const data = await getJson(AIHUBMIX_MODELS_ENDPOINT, {}, fetchFn)
models = extractAihubmixModelIds(data).map((id) => ({ id }))
break
}
default: {
if (!base) {
throw new Error(
`${PROVIDER_INFO[provider].label} needs a base URL to list its models.`,
)
}
const data = await getJson(`${base}/models`, bearer, fetchFn)
models = (data.data ?? [])
.map((m: { id: string }) => ({ id: m.id }))
.filter((m: ListedModel) => !NON_CHAT.test(m.id))
}
}
return models.map((m) => ({
...m,
tools: m.tools ?? getModelInfo(provider, m.id)?.tools,
}))
}
+6 -103
View File
@@ -47,14 +47,11 @@ export interface FlattenedServerModel {
/**
* Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production", "主力" → "4e3b-529b"
* Non-ASCII characters become their hex code point so CJK names stay
* distinct; the id is sent in HTTP headers, which must be ASCII.
* e.g., "OpenAI Production" → "openai-production"
*/
export function slugify(name: string): string {
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^\p{ASCII}]/gu, (c) => `-${c.codePointAt(0)?.toString(16)}-`)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
@@ -65,54 +62,7 @@ function getConfigPath(): string {
return path.join(process.cwd(), "ai-models.json")
}
/**
* 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> {
export async function loadRawServerModelsConfig(): Promise<ServerModelsConfig | null> {
// Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments
const envConfig = process.env.AI_MODELS_CONFIG
if (envConfig && envConfig.trim().length > 0) {
@@ -135,51 +85,15 @@ export async function loadEnvServerModelsConfig(): Promise<ServerModelsConfig |
const json = JSON.parse(jsonStr)
return ServerModelsConfigSchema.parse(json)
} catch (err: any) {
if (err?.code !== "ENOENT") {
console.error(
"[server-model-config] Failed to load ai-models.json:",
err,
)
if (err?.code === "ENOENT") {
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:",
"[server-model-config] Failed to load ai-models.json:",
err,
)
return null
}
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<
@@ -192,7 +106,6 @@ export async function loadFlattenedServerModels(): Promise<
const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = []
const seenIds = new Set<string>()
for (const p of cfg.providers) {
const providerLabel =
@@ -203,16 +116,6 @@ export async function loadFlattenedServerModels(): Promise<
for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}`
// Names that differ only in case or punctuation share a slug.
// A repeated id would always resolve to the first provider's
// credentials, so drop it instead.
if (seenIds.has(id)) {
console.warn(
`[server-model-config] Skipping duplicate model id "${id}". Provider names must differ in letters or digits.`,
)
continue
}
seenIds.add(id)
// Default model priority:
// 1. From ai-models.json: first model of provider with default: true
+121 -65
View File
@@ -1,6 +1,5 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid"
import { toast } from "sonner"
import type { Template } from "./template-storage"
// Constants
@@ -59,10 +58,36 @@ interface ChatSessionDB extends DBSchema {
// Database singleton
let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<ChatSessionDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) {
// A failed or lost connection is not cached: the next call reopens it
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) {
if (oldVersion < 1) {
@@ -90,29 +115,23 @@ async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
}
}
},
blocked() {
// An older tab keeps the DB open, so the upgrade has to wait
toast.warning(
"Please close other tabs of this app to finish updating chat storage.",
{ id: "idb-upgrade-blocked", duration: 10000 },
)
},
blocking(_currentVersion, _blockedVersion, event) {
// Another tab needs to upgrade the DB: close our connection so
// it is not stuck, and reopen on the next call
const db = event.target as IDBDatabase
db.close()
dbPromise = null
},
terminated() {
// The browser closed the connection (e.g. Safari after a long
// time in the background)
dbPromise = null
resetDBPromise()
},
}).catch((error) => {
dbPromise = null
throw error
})
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
}
return dbPromise
}
@@ -127,31 +146,46 @@ export function isIndexedDBAvailable(): boolean {
}
}
// Check if IndexedDB is actually usable (not just present).
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
// that other code depends on.
export async function isIndexedDBUsable(): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await getDB()
return true
} catch {
return false
}
}
// CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return []
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
return await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readonly")
const index = tx.store.index("by-updated")
const metadata: SessionMetadata[] = []
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram: !!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
// Use cursor to read only metadata fields (avoids loading full messages/XML)
let cursor = await index.openCursor(null, "prev") // newest first
while (cursor) {
const s = cursor.value
metadata.push({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messages.length,
hasDiagram:
!!s.diagramXml && s.diagramXml.trim().length > 0,
thumbnailDataUrl: s.thumbnailDataUrl,
})
cursor = await cursor.continue()
}
return metadata
})
} catch (error) {
console.error("Failed to get session metadata:", error)
return []
@@ -161,36 +195,56 @@ export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
export async function getSession(id: string): Promise<ChatSession | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null
})
} catch (error) {
console.error("Failed to get session:", error)
return null
}
}
// Returns false on failure (e.g. storage quota exceeded). Other sessions are
// never deleted automatically; the caller tells the user instead.
export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
const db = await getDB()
await db.put(STORE_NAME, session)
await withDB(async (db) => {
await db.put(STORE_NAME, session)
})
return true
} catch (error) {
console.error("Failed to save session:", error)
// Reopen the connection next time in case it was lost (Safari reports
// "Connection to Indexed Database server lost" without closing it)
dbPromise = null
return false
// Handle quota exceeded
if (
error instanceof DOMException &&
error.name === "QuotaExceededError"
) {
console.warn("Storage quota exceeded, deleting oldest session...")
await deleteOldestSession()
// Retry once
try {
await withDB(async (db) => {
await db.put(STORE_NAME, session)
})
return true
} catch (retryError) {
console.error(
"Failed to save session after cleanup:",
retryError,
)
return false
}
} else {
console.error("Failed to save session:", error)
return false
}
}
}
export async function deleteSession(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
await db.delete(STORE_NAME, id)
await withDB(async (db) => {
await db.delete(STORE_NAME, id)
})
} catch (error) {
console.error("Failed to delete session:", error)
}
@@ -199,8 +253,9 @@ export async function deleteSession(id: string): Promise<void> {
export async function getSessionCount(): Promise<number> {
if (!isIndexedDBAvailable()) return 0
try {
const db = await getDB()
return await db.count(STORE_NAME)
return await withDB(async (db) => {
return await db.count(STORE_NAME)
})
} catch (error) {
console.error("Failed to get session count:", error)
return 0
@@ -210,14 +265,15 @@ export async function getSessionCount(): Promise<number> {
export async function deleteOldestSession(): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
await withDB(async (db) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const index = tx.store.index("by-updated")
const cursor = await index.openCursor()
if (cursor) {
await cursor.delete()
}
await tx.done
})
} catch (error) {
console.error("Failed to delete oldest session:", error)
}
+42 -112
View File
@@ -2,132 +2,62 @@
* SSRF (Server-Side Request Forgery) protection utilities
*/
import { lookup } from "node:dns/promises"
/**
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
* Works for both user-supplied literal IPs and DNS-resolved addresses.
* Check if URL points to private/internal network
* Blocks: localhost, private IPs, link-local, AWS metadata service
*/
function isPrivateIp(ip: string): boolean {
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
export function isPrivateUrl(urlString: string): boolean {
try {
const url = new URL(urlString)
const hostname = url.hostname.toLowerCase()
// IPv6
if (addr.includes(":")) {
if (addr === "::1" || addr === "::") return true
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
// Block localhost
if (
addr.startsWith("fc") ||
addr.startsWith("fd") ||
addr.startsWith("::ffff:")
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1"
) {
return true
}
// link-local (fe80::/10)
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
if (linkLocal) {
const high = parseInt(linkLocal[1], 16)
if (high >= 0x80 && high <= 0xbf) return true
// Block AWS/cloud metadata endpoints
if (
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal"
) {
return true
}
// Check for private IPv4 ranges
const ipv4Match = hostname.match(
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
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 === 127) return true // 127.0.0.0/8 (loopback)
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".localhost")
) {
return true
}
return false
}
// IPv4
const ipv4Match = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number)
if (a === 10) return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
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 === 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)
}
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 (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "::"
) {
return true
}
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 {
return true // Invalid URL or DNS failure - block it
return true // Invalid URL - block it
}
}
/**
* Whether private URLs are allowed (defaults to true)
* Set ALLOW_PRIVATE_URLS=false to block private URLs
* Read per call so admin-panel changes apply without restart
*/
export function allowPrivateUrls(): boolean {
return process.env.ALLOW_PRIVATE_URLS !== "false"
}
/**
* A fetch for requests to a base URL the client chose. With private URLs
* blocked, a public URL could still redirect the request to an internal
* host, so redirects are refused. Undefined when private URLs are allowed.
*/
export function redirectGuardedFetch(): typeof fetch | undefined {
if (allowPrivateUrls()) return undefined
return async (input, init) => {
const response = await fetch(input, { ...init, redirect: "manual" })
if (response.status >= 300 && response.status < 400) {
throw new Error("Redirects are not allowed for custom base URLs")
}
return response
}
}
export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false"
-3
View File
@@ -31,9 +31,6 @@ export const STORAGE_KEYS = {
// Custom system message
customSystemMessage: "next-ai-draw-io-custom-system-message",
// Output token budget per turn (empty = server default)
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
// Panel visibility
showRecentChats: "next-ai-draw-io-show-recent-chats",
showMyTemplates: "next-ai-draw-io-show-my-templates",
+33 -10
View File
@@ -1,14 +1,11 @@
/**
* System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5)
*
* Token counting utilities are in a separate file (token-counter.ts) to avoid
* WebAssembly issues with Next.js server-side rendering.
*/
import {
SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE,
} from "@/packages/mcp-server/src/xml-examples.ts"
// Default system prompt (~1900 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation.
@@ -44,7 +41,7 @@ parameters: {
tool name: edit_diagram
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
parameters: {
operations: Array<{operation: "update" | "add" | "delete", cell_id: string, new_xml?: string}>
edits: Array<{search: string, replace: string}>
}
---Tool3---
tool name: append_diagram
@@ -244,7 +241,21 @@ const EXTENDED_ADDITIONS = `
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml
${SWIMLANE_EXAMPLE}
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
### append_diagram Details
@@ -307,7 +318,12 @@ If cell_id not found, check "Current diagram XML" for correct IDs. Use display_d
### Two edges between same nodes (CORRECT - no overlap):
\`\`\`xml
${TWO_EDGES_EXAMPLE}
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
\`\`\`
### Edge with single waypoint (simple detour):
@@ -326,7 +342,14 @@ ${TWO_EDGES_EXAMPLE}
**WRONG:** Direct diagonal line crosses over Develop
**CORRECT:** Route around the OUTSIDE (go right first, then up)
\`\`\`xml
${WAYPOINT_EXAMPLE}
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="750" y="80"/>
<mxPoint x="750" y="150"/>
</Array>
</mxGeometry>
</mxCell>
\`\`\`
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
+106 -54
View File
@@ -57,6 +57,33 @@ export function generateDefaultTitle(prompt: string): string {
// Database singleton
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
const resetDBPromise = () => {
dbPromise = null
}
const isClosingError = (error: unknown): boolean => {
return (
error instanceof DOMException &&
error.name === "InvalidStateError" &&
/closing/i.test(error.message)
)
}
const withDB = async <T>(
action: (db: IDBPDatabase<TemplateDB>) => Promise<T>,
): Promise<T> => {
try {
const db = await getDB()
return await action(db)
} catch (error) {
if (isClosingError(error)) {
resetDBPromise()
const db = await getDB()
return await action(db)
}
throw error
}
}
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
if (!dbPromise) {
@@ -74,7 +101,23 @@ async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
}
}
},
terminated() {
resetDBPromise()
},
})
dbPromise
.then((db) => {
db.onversionchange = () => {
db.close()
resetDBPromise()
}
db.onclose = () => {
resetDBPromise()
}
})
.catch(() => {
resetDBPromise()
})
}
return dbPromise
}
@@ -94,9 +137,10 @@ export function isIndexedDBAvailable(): boolean {
export async function getAllTemplates(): Promise<Template[]> {
if (!isIndexedDBAvailable()) return []
try {
const db = await getDB()
const templates = await db.getAll(STORE_NAME)
return sortTemplates(templates)
return await withDB(async (db) => {
const templates = await db.getAll(STORE_NAME)
return sortTemplates(templates)
})
} catch (error) {
console.error("Failed to get templates:", error)
return []
@@ -106,8 +150,9 @@ export async function getAllTemplates(): Promise<Template[]> {
export async function getTemplate(id: string): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
return (await db.get(STORE_NAME, id)) || null
return await withDB(async (db) => {
return (await db.get(STORE_NAME, id)) || null
})
} catch (error) {
console.error("Failed to get template:", error)
return null
@@ -137,8 +182,9 @@ export async function createTemplate(
}
try {
const db = await getDB()
await db.put(STORE_NAME, template)
await withDB(async (db) => {
await db.put(STORE_NAME, template)
})
return template
} catch (error) {
console.error("Failed to create template:", error)
@@ -152,19 +198,20 @@ export async function updateTemplate(
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
return await withDB(async (db) => {
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const updated: Template = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: Date.now(),
}
await db.put(STORE_NAME, updated)
return updated
const updated: Template = {
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: Date.now(),
}
await db.put(STORE_NAME, updated)
return updated
})
} catch (error) {
console.error("Failed to update template:", error)
return null
@@ -174,8 +221,9 @@ export async function updateTemplate(
export async function deleteTemplate(id: string): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
const db = await getDB()
await db.delete(STORE_NAME, id)
await withDB(async (db) => {
await db.delete(STORE_NAME, id)
})
return true
} catch (error) {
console.error("Failed to delete template:", error)
@@ -189,24 +237,25 @@ export async function duplicateTemplate(
): Promise<Template | null> {
if (!isIndexedDBAvailable()) return null
try {
const db = await getDB()
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
return await withDB(async (db) => {
const existing = await db.get(STORE_NAME, id)
if (!existing) return null
const now = Date.now()
const duplicate: Template = {
...existing,
id: nanoid(),
title: `${existing.title} ${copySuffix}`,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
}
await db.put(STORE_NAME, duplicate)
return duplicate
const now = Date.now()
const duplicate: Template = {
...existing,
id: nanoid(),
title: `${existing.title} ${copySuffix}`,
createdAt: now,
updatedAt: now,
clickCount: 0,
runCount: 0,
lastUsedAt: 0,
pinned: false,
}
await db.put(STORE_NAME, duplicate)
return duplicate
})
} catch (error) {
console.error("Failed to duplicate template:", error)
return null
@@ -218,12 +267,13 @@ export async function duplicateTemplate(
export async function incrementClickCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
template.clickCount += 1
template.updatedAt = Date.now()
await db.put(STORE_NAME, template)
await withDB(async (db) => {
const template = await db.get(STORE_NAME, id)
if (!template) return
template.clickCount += 1
template.updatedAt = Date.now()
await db.put(STORE_NAME, template)
})
} catch (error) {
console.error("Failed to increment click count:", error)
}
@@ -232,14 +282,15 @@ export async function incrementClickCount(id: string): Promise<void> {
export async function incrementRunCount(id: string): Promise<void> {
if (!isIndexedDBAvailable()) return
try {
const db = await getDB()
const template = await db.get(STORE_NAME, id)
if (!template) return
const now = Date.now()
template.runCount += 1
template.lastUsedAt = now
template.updatedAt = now
await db.put(STORE_NAME, template)
await withDB(async (db) => {
const template = await db.get(STORE_NAME, id)
if (!template) return
const now = Date.now()
template.runCount += 1
template.lastUsedAt = now
template.updatedAt = now
await db.put(STORE_NAME, template)
})
} catch (error) {
console.error("Failed to increment run count:", error)
}
@@ -372,8 +423,9 @@ export async function importTemplates(
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
}
try {
const db = await getDB()
await db.put(STORE_NAME, newTemplate)
await withDB(async (db) => {
await db.put(STORE_NAME, newTemplate)
})
existingKeys.add(key)
imported++
} catch (error) {
+106 -310
View File
@@ -9,7 +9,6 @@ export type ProviderName =
| "bedrock"
| "ollama"
| "openrouter"
| "aihubmix"
| "deepseek"
| "siliconflow"
| "sglang"
@@ -23,8 +22,6 @@ export type ProviderName =
| "kimi"
| "minimax"
| "novita"
| "mimo"
| "atlascloud"
// Individual model configuration
export interface ModelConfig {
@@ -32,8 +29,6 @@ export interface ModelConfig {
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
validated?: boolean // Has this model been validated
validationError?: string // Error message if validation failed
validationWarning?: string // Passed, but e.g. did not call a tool
responseTime?: number // Milliseconds the last test took
}
// Provider configuration
@@ -90,15 +85,6 @@ export interface FlattenedModel {
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
export const PROVIDER_LOGO_MAP: Record<string, string> = {
openai: "openai",
@@ -107,7 +93,6 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
azure: "azure",
bedrock: "amazon-bedrock",
openrouter: "openrouter",
aihubmix: "aihubmix",
deepseek: "deepseek",
siliconflow: "siliconflow",
sglang: "openai", // SGLang is OpenAI-compatible
@@ -118,49 +103,24 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
modelscope: "modelscope",
minimax: "minimax",
novita: "novita",
mimo: "xiaomi",
atlascloud: "openai",
}
/** How a provider lists its models (see lib/provider-models.ts) */
export type ModelListStyle =
| "openai"
| "anthropic"
| "google"
| "ollama"
| "openrouter"
| "aihubmix"
| "gateway"
// Provider metadata. apiKeyUrl is the page where users create a key.
// modelList is missing where a key alone cannot list the models (Bedrock,
// Vertex, Azure) or the list is not reliable (Doubao, MiniMax).
// Provider metadata
export const PROVIDER_INFO: Record<
ProviderName,
{
label: string
defaultBaseUrl?: string
apiKeyUrl?: string
modelList?: ModelListStyle
}
{ label: string; defaultBaseUrl?: string }
> = {
openai: {
label: "OpenAI",
defaultBaseUrl: "https://api.openai.com/v1",
apiKeyUrl: "https://platform.openai.com/api-keys",
modelList: "openai",
},
anthropic: {
label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1",
apiKeyUrl: "https://platform.claude.com/settings/keys",
modelList: "anthropic",
},
google: {
label: "Google",
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyUrl: "https://aistudio.google.com/apikey",
modelList: "google",
},
vertexai: { label: "Google Vertex AI" },
azure: {
@@ -171,366 +131,237 @@ export const PROVIDER_INFO: Record<
ollama: {
label: "Ollama",
defaultBaseUrl: "https://ollama.com/api",
apiKeyUrl: "https://ollama.com/settings/keys",
modelList: "ollama",
},
openrouter: {
label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
apiKeyUrl: "https://openrouter.ai/keys",
modelList: "openrouter",
},
aihubmix: {
label: "AIHubMix",
defaultBaseUrl: "https://aihubmix.com/v1",
apiKeyUrl: "https://aihubmix.com/token",
modelList: "aihubmix",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
apiKeyUrl: "https://platform.deepseek.com/api_keys",
modelList: "openai",
},
siliconflow: {
label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1",
apiKeyUrl: "https://cloud.siliconflow.cn/account/ak",
modelList: "openai",
},
sglang: {
label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1",
modelList: "openai",
},
gateway: {
label: "AI Gateway",
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
apiKeyUrl: "https://vercel.com/ai-gateway",
modelList: "gateway",
},
edgeone: { label: "EdgeOne Pages" },
doubao: {
label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
apiKeyUrl:
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
},
modelscope: {
label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
modelList: "openai",
},
glm: {
label: "GLM (Zhipu)",
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
modelList: "openai",
},
qwen: {
label: "Qwen (Alibaba)",
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
modelList: "openai",
},
qiniu: {
label: "Qiniu",
defaultBaseUrl: "https://api.qnaigc.com/v1",
apiKeyUrl: "https://www.qiniu.com/ai/models",
modelList: "openai",
},
kimi: {
label: "Kimi (Moonshot)",
defaultBaseUrl: "https://api.moonshot.cn/v1",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
modelList: "openai",
},
minimax: {
label: "MiniMax",
defaultBaseUrl: "https://api.minimaxi.com/anthropic",
apiKeyUrl:
"https://platform.minimaxi.com/user-center/basic-information/interface-key",
},
novita: {
label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai",
apiKeyUrl: "https://novita.ai/dashboard/key",
modelList: "openai",
},
mimo: {
label: "MiMo (Xiaomi)",
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
apiKeyUrl: "https://platform.xiaomimimo.com/#/console/api-keys",
modelList: "openai",
},
atlascloud: {
label: "Atlas Cloud",
defaultBaseUrl: "https://api.atlascloud.ai/v1",
apiKeyUrl: "https://www.atlascloud.ai/console/api-keys",
modelList: "openai",
},
}
// Suggested models per provider for quick add
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
openai: [
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6-astra",
"gpt-5.5-pro",
"gpt-5.5",
"gpt-5.4-pro",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5-codex-mini",
"gpt-5.2-pro",
"gpt-5.2-chat-latest",
"gpt-5.2",
"gpt-5.1-codex-mini",
"gpt-5.1-codex",
"gpt-5.1-chat-latest",
"gpt-5.1",
"gpt-5-pro",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-codex",
"gpt-5-chat-latest",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
],
anthropic: [
// Claude 5 series (latest)
"claude-opus-5-5",
"claude-sonnet-5-5",
"claude-fable-5-1",
"claude-opus-5",
"claude-sonnet-5",
// Claude 4.8 / 4.7 / 4.6 series (dateless pinned IDs)
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-opus-4-7",
"claude-opus-4-6",
// Claude 4.5 series
"claude-sonnet-4-5-20250929",
"claude-opus-4-5-20251101",
// Claude 4.5 series (latest)
"claude-opus-4-5-20250514",
"claude-sonnet-4-5-20250514",
// Claude 4 series
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
// Claude 3.7 series
"claude-3-7-sonnet-20250219",
// Claude 3.5 series
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
// Claude 3 series
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
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-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-preview-05-20",
// 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: [
// 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-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
// Gemini 2.0 series
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
// Gemini 1.5 series
"gemini-1.5-pro",
"gemini-1.5-flash",
],
azure: [
"gpt-5.5",
"gpt-5.4",
"gpt-5.1",
"gpt-5",
"gpt-5-mini",
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
"o3",
"o4-mini",
],
// Newer models only answer through an inference profile id (the region
// prefix). Each id here was called once on 2026-10-04.
azure: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-35-turbo"],
bedrock: [
// Anthropic Claude ("global." works from any region)
"global.anthropic.claude-opus-5-5",
"global.anthropic.claude-sonnet-5-5",
"global.anthropic.claude-fable-5-1",
"global.anthropic.claude-opus-5",
"global.anthropic.claude-sonnet-5",
"global.anthropic.claude-opus-4-8",
"global.anthropic.claude-opus-4-7",
"global.anthropic.claude-sonnet-4-6",
"global.anthropic.claude-opus-4-6-v1",
"global.anthropic.claude-opus-4-5-20251101-v1:0",
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
"global.anthropic.claude-sonnet-4-20250514-v1:0",
// Anthropic Claude
"anthropic.claude-opus-4-5-20250514-v1:0",
"anthropic.claude-sonnet-4-5-20250514-v1:0",
"anthropic.claude-opus-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-opus-20240229-v1:0",
"anthropic.claude-3-sonnet-20240229-v1:0",
"anthropic.claude-3-haiku-20240307-v1:0",
// Amazon Nova
"us.amazon.nova-2-lite-v1:0",
"amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0",
// Meta Llama
"us.meta.llama4-maverick-17b-instruct-v1:0",
"us.meta.llama4-scout-17b-instruct-v1:0",
"us.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-large-3-675b-instruct",
"us.mistral.pixtral-large-2502-v1:0",
"mistral.mistral-large-2411-v1:0",
"mistral.mistral-small-2503-v1:0",
],
openrouter: [
// Anthropic
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"anthropic/claude-fable-5.1",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"anthropic/claude-sonnet-4",
"anthropic/claude-opus-4",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.5-haiku",
// OpenAI
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"openai/gpt-5.5",
"openai/gpt-5.4",
"openai/gpt-5.4-mini",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/o1",
"openai/o3-mini",
// Google
"google/gemini-3.1-pro-preview",
"google/gemini-3.5-flash",
"google/gemini-2.5-flash-lite",
// xAI
"x-ai/grok-4.3",
"google/gemini-2.5-pro",
"google/gemini-2.5-flash",
"google/gemini-2.0-flash-exp:free",
// Meta Llama
"meta-llama/llama-4-maverick",
"meta-llama/llama-4-scout",
"meta-llama/llama-3.3-70b-instruct",
"meta-llama/llama-3.1-405b-instruct",
"meta-llama/llama-3.1-70b-instruct",
// DeepSeek
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v3.2",
"deepseek/deepseek-chat",
"deepseek/deepseek-r1",
// Qwen
"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",
"qwen/qwen-2.5-72b-instruct",
],
deepseek: ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
siliconflow: [
// DeepSeek
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V4-Flash",
"deepseek-ai/DeepSeek-V3.2",
// MiniMax
"MiniMaxAI/MiniMax-M3",
// Moonshot
"moonshotai/Kimi-K2.6",
// Z.ai
"zai-org/GLM-5",
"deepseek-ai/DeepSeek-V3",
"deepseek-ai/DeepSeek-R1",
"deepseek-ai/DeepSeek-V2.5",
// Qwen
"Qwen/Qwen3.6-35B-A3B",
"Qwen/Qwen3-Coder-480B-A35B-Instruct",
"Qwen/Qwen3-30B-A3B-Instruct-2507",
"Qwen/Qwen3-VL-32B-Instruct",
// OpenAI open-weights
"openai/gpt-oss-120b",
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen2.5-Coder-32B-Instruct",
"Qwen/Qwen2.5-7B-Instruct",
"Qwen/Qwen2-VL-72B-Instruct",
"qwen3.5-plus",
],
sglang: [
// SGLang is OpenAI-compatible, models depend on deployment
"default",
],
gateway: [
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"openai/gpt-5.5",
"anthropic/claude-opus-4.7",
"google/gemini-3.1-pro-preview",
"xai/grok-4.3",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.4-mini",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-3-5-sonnet",
"google/gemini-2.0-flash",
],
edgeone: ["@tx/deepseek-ai/deepseek-v32"],
doubao: [
// ByteDance Doubao models (Volcengine Ark IDs use dash form)
"doubao-seed-2-0-pro-260215",
"doubao-seed-2-0-lite-260428",
"doubao-seed-2-0-mini-260428",
"doubao-seed-1-8-251228",
"doubao-seed-1-6-251015",
"doubao-seed-1-6-flash-250828",
"doubao-seed-1-6-vision-250815",
"doubao-1-5-pro-32k-250115",
"doubao-1-5-lite-32k-250115",
// ByteDance Doubao models
"doubao-1.5-thinking-pro-250415",
"doubao-1.5-thinking-pro-m-250428",
"doubao-1.5-pro-32k-250115",
"doubao-1.5-pro-256k-250115",
"doubao-pro-32k-241215",
"doubao-pro-256k-241215",
],
modelscope: [
// DeepSeek
"deepseek-ai/DeepSeek-V4-Pro",
"deepseek-ai/DeepSeek-V3.2",
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-R1",
// Qwen
"Qwen/Qwen2.5-72B-Instruct",
"Qwen/Qwen2.5-32B-Instruct",
"Qwen/Qwen3-235B-A22B-Instruct-2507",
"Qwen/Qwen3-VL-235B-A22B-Instruct",
"Qwen/Qwen3-Coder-30B-A3B-Instruct",
"Qwen/Qwen3-32B",
"Qwen/Qwen2.5-72B-Instruct",
"qwen3.5-plus",
// DeepSeek
"deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2",
],
minimax: [
// MiniMax models (Anthropic-compatible API)
"MiniMax-M3",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
],
novita: [
// Novita AI models (OpenAI-compatible API)
"minimax/minimax-m3",
"deepseek/deepseek-v4-pro",
"zai-org/glm-5.1",
"moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-flash",
"moonshotai/kimi-k2.5",
"zai-org/glm-5",
"minimax/minimax-m2.5",
],
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
}
// Helper to generate UUID
@@ -609,38 +440,3 @@ export function findModelById(
): FlattenedModel | undefined {
return flattenModels(config).find((m) => m.id === modelId)
}
/**
* A base URL the way the SDKs expect it: no spaces, no trailing slash, and
* no endpoint path users often paste along (".../v1/chat/completions"),
* which the SDK would append a second time.
*/
export function normalizeBaseUrl(url: string): string {
return url
.trim()
.replace(/\/+$/, "")
.replace(/\/(?:chat\/completions|completions|messages|responses)$/, "")
}
/** Where a chat request goes for a base URL, or null when the SDK decides */
export function chatRequestUrl(
provider: ProviderName,
baseUrl: string,
): string | null {
const url = normalizeBaseUrl(baseUrl)
if (!url) return null
if (provider === "anthropic") return `${url}/messages`
// These SDKs build their own paths (or, for MiniMax, pick the protocol
// from the URL)
const ownPaths: ProviderName[] = [
"google",
"vertexai",
"azure",
"bedrock",
"ollama",
"gateway",
"minimax",
"edgeone",
]
return ownPaths.includes(provider) ? null : `${url}/chat/completions`
}
+1 -6
View File
@@ -1,6 +1,5 @@
import { z } from "zod"
import { getApiEndpoint } from "@/lib/base-path"
import { STORAGE_KEYS } from "@/lib/storage"
export interface UrlData {
url: string
@@ -19,11 +18,7 @@ const UrlResponseSchema = z.object({
export async function extractUrlContent(url: string): Promise<UrlData> {
const response = await fetch(getApiEndpoint("/api/parse-url"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
})
+61 -55
View File
@@ -27,72 +27,78 @@ export function useFileProcessor() {
const handleFileChange = async (newFiles: File[]) => {
setFiles(newFiles)
const pending = newFiles.filter(
(file) =>
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file),
)
// Extract text immediately for new PDF/text files
for (const file of newFiles) {
const needsExtraction =
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file)
if (needsExtraction) {
// Mark as extracting
setPdfData((prev) => {
const next = new Map(prev)
next.set(file, {
text: "",
charCount: 0,
isExtracting: true,
})
return next
})
// Before any await: drop data for removed files and mark every new
// file as extracting, so queued files also block sending
setPdfData((prev) => {
const next = new Map<File, FileData>()
for (const file of newFiles) {
const existing = prev.get(file)
if (existing) next.set(file, existing)
}
for (const file of pending) {
next.set(file, { text: "", charCount: 0, isExtracting: true })
}
return next
})
// Extract text asynchronously
try {
let text: string
if (isPdfFile(file)) {
text = await extractPdfText(file)
} else {
text = await extractTextFileContent(file)
}
// Extract one file at a time
for (const file of pending) {
try {
let text: string
if (isPdfFile(file)) {
text = await extractPdfText(file)
} else {
text = await extractTextFileContent(file)
}
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
// Remove the file from the list
setFiles((prev) => prev.filter((f) => f !== file))
continue
}
// Check character limit
if (text.length > MAX_EXTRACTED_CHARS) {
const limitK = MAX_EXTRACTED_CHARS / 1000
toast.error(
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
)
setPdfData((prev) => {
const next = new Map(prev)
next.set(file, {
text,
charCount: text.length,
isExtracting: false,
})
return next
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(`Failed to read file: ${file.name}`)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
// Remove the file from the list
setFiles((prev) => prev.filter((f) => f !== file))
continue
}
setPdfData((prev) => {
// The file was removed while extracting
if (!prev.has(file)) return prev
const next = new Map(prev)
next.set(file, {
text,
charCount: text.length,
isExtracting: false,
})
return next
})
} catch (error) {
console.error("Failed to extract text:", error)
toast.error(`Failed to read file: ${file.name}`)
setPdfData((prev) => {
const next = new Map(prev)
next.delete(file)
return next
})
}
}
// Clean up pdfData for removed files
setPdfData((prev) => {
const next = new Map(prev)
for (const key of prev.keys()) {
if (!newFiles.includes(key)) {
next.delete(key)
}
}
return next
})
}
return {
+1375 -19
View File
File diff suppressed because it is too large Load Diff

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