Compare commits

..
Author SHA1 Message Date
dayuan.jiang 930699ec35 feat: support comma-separated AI_MODEL for quick multi-model setup
Users expected setting AI_MODEL to a comma-separated list to expose
multiple models in the picker, but the value was used verbatim as a
single model id, leaving the picker with only the "Server Default"
fallback.

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

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

Docs (en/cn/ja) and env.example updated; tests cover the new fallback
plus the no-comma / no-AI_PROVIDER negative cases.
2026-06-15 14:20:03 +09:00
201 changed files with 8717 additions and 27547 deletions
-5
View File
@@ -33,11 +33,6 @@
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"], "matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages", "groupName": "Core framework packages",
"automerge": false "automerge": false
},
{
"matchPackageNames": ["@biomejs/biome"],
"groupName": "Biome",
"automerge": false
} }
], ],
"vulnerabilityAlerts": { "vulnerabilityAlerts": {
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Run Biome format - name: Run Biome format
# Pin to the version in package.json so CI matches local/pre-commit # 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). # (npx @latest drifts — e.g. 2.5.0 broke this job on unrelated PRs).
run: npx @biomejs/biome@2.5.7 check --write --no-errors-on-unmatched . run: npx @biomejs/biome@2.4.13 check --write --no-errors-on-unmatched .
- name: Check for changes - name: Check for changes
id: changes id: changes
+1 -2
View File
@@ -58,8 +58,6 @@ jobs:
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
@@ -91,3 +89,4 @@ jobs:
docker pull ghcr.io/${REPO_LOWER}:latest docker pull ghcr.io/${REPO_LOWER}:latest
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
-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 - name: Run unit tests
run: npm run test -- --run run: npm run test -- --run
# The MCP server package ships its own vitest because its DOM polyfill
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
# for the Next.js app. Install + run its tests separately so CI catches
# multi-page mxfile regressions.
- name: Install MCP server dependencies
run: npm --prefix packages/mcp-server ci
- name: Run MCP server unit tests
run: npm --prefix packages/mcp-server test
# 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: e2e:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
-21
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! > 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 https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
@@ -138,13 +127,6 @@ Then ask Claude to create diagrams:
The diagram appears in your browser in real-time! 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. See the [MCP Server README](./packages/mcp-server/README.md) for VS Code, Cursor, and other client configurations.
## Getting Started ## Getting Started
@@ -235,7 +217,6 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
- ModelScope - ModelScope
- SGLang - SGLang
- Vercel AI Gateway - 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. All providers except AWS Bedrock and OpenRouter support custom endpoints.
@@ -272,8 +253,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 [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! 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: For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
+2 -20
View File
@@ -33,10 +33,8 @@ import {
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { STORAGE_KEYS } from "@/lib/storage"
import { import {
FIXED_CRED_PROVIDERS, FIXED_CRED_PROVIDERS,
generateId,
PROVIDER_INFO, PROVIDER_INFO,
type ProviderName, type ProviderName,
SUGGESTED_MODELS, SUGGESTED_MODELS,
@@ -89,11 +87,6 @@ function ProviderDetail({
try { try {
const data = await adminFetch("/api/admin/test-model", password, { const data = await adminFetch("/api/admin/test-model", password, {
method: "POST", method: "POST",
// EdgeOne's function also checks the access code
headers: {
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({ provider, modelId }), body: JSON.stringify({ provider, modelId }),
}) })
setTestResults((prev) => ({ setTestResults((prev) => ({
@@ -232,7 +225,6 @@ function ProviderDetail({
</Button> </Button>
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<Select <Select
value=""
disabled={disabled} disabled={disabled}
onValueChange={(v) => addModel(v)} onValueChange={(v) => addModel(v)}
> >
@@ -398,14 +390,12 @@ function ProviderDetail({
export function ModelsSection({ export function ModelsSection({
providers, providers,
envProviders, envProviders,
envHasDefaultModel,
disabled, disabled,
password, password,
onChange, onChange,
}: { }: {
providers: AdminProvider[] providers: AdminProvider[]
envProviders: EnvProvider[] envProviders: EnvProvider[]
envHasDefaultModel: boolean
disabled: boolean disabled: boolean
password: string password: string
onChange: (providers: AdminProvider[]) => void onChange: (providers: AdminProvider[]) => void
@@ -419,16 +409,10 @@ export function ModelsSection({
const addProvider = (provider: ProviderName) => { const addProvider = (provider: ProviderName) => {
const newProvider: AdminProvider = { const newProvider: AdminProvider = {
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS id: crypto.randomUUID(),
id: generateId(),
provider, provider,
models: [], models: [],
// Only the very first provider becomes the default, and only when isDefault: providers.length === 0,
// 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]) onChange([...providers, newProvider])
setSelectedId(newProvider.id) setSelectedId(newProvider.id)
@@ -512,9 +496,7 @@ export function ModelsSection({
))} ))}
</div> </div>
<div className="border-t p-2"> <div className="border-t p-2">
{/* Always empty so picking the same type again still fires */}
<Select <Select
value=""
disabled={disabled} disabled={disabled}
onValueChange={(v) => addProvider(v as ProviderName)} onValueChange={(v) => addProvider(v as ProviderName)}
> >
+14 -49
View File
@@ -37,19 +37,6 @@ import { SettingField } from "./setting-field"
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)] 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() { export default function AdminPage() {
const dict = useDictionary() const dict = useDictionary()
// Localized group title/description, keyed by group id // Localized group title/description, keyed by group id
@@ -75,8 +62,6 @@ export default function AdminPage() {
// Models section state // Models section state
const [providers, setProviders] = useState<AdminProvider[]>([]) const [providers, setProviders] = useState<AdminProvider[]>([])
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([]) 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 [savedProviders, setSavedProviders] = useState<string>("[]")
const providersDirty = JSON.stringify(providers) !== savedProviders const providersDirty = JSON.stringify(providers) !== savedProviders
@@ -103,13 +88,15 @@ export default function AdminPage() {
const map: SettingsMap = {} const map: SettingsMap = {}
for (const s of data.settings) map[s.key] = s for (const s of data.settings) map[s.key] = s
setSettings(map) setSettings(map)
// A group stays on while it still has values (e.g. from env vars // Seed each toggle once from whether the group has configured
// that saving can't remove); a user's explicit "on" for a group // values; don't stomp a user's explicit toggle on later saves
// with no values yet is kept across saves
setEnabledGroups((prev) => { setEnabledGroups((prev) => {
const next = groupsWithValues(map) const next = { ...prev }
for (const id of Object.keys(next)) { for (const group of SETTING_GROUPS) {
next[id] = next[id] || !!prev[id] if (!group.toggleable || group.id in next) continue
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
(d) => map[d.key]?.source !== "default",
)
} }
return next return next
}) })
@@ -121,12 +108,10 @@ export default function AdminPage() {
(data: { (data: {
providers: AdminProvider[] providers: AdminProvider[]
envProviders?: EnvProvider[] envProviders?: EnvProvider[]
envHasDefaultModel?: boolean
}) => { }) => {
setProviders(data.providers) setProviders(data.providers)
setSavedProviders(JSON.stringify(data.providers)) setSavedProviders(JSON.stringify(data.providers))
setEnvProviders(data.envProviders ?? []) setEnvProviders(data.envProviders ?? [])
setEnvHasDefaultModel(!!data.envHasDefaultModel)
}, },
[], [],
) )
@@ -196,9 +181,8 @@ export default function AdminPage() {
return () => observer.disconnect() return () => observer.disconnect()
}, [authedPassword]) }, [authedPassword])
// value undefined drops the pending change (back to the saved value)
const handleChange = useCallback( const handleChange = useCallback(
(key: string, value: string | null | undefined) => { (key: string, value: string | null) => {
setSaveMessage(null) setSaveMessage(null)
setErrors((prev) => { setErrors((prev) => {
if (!(key in prev)) return prev if (!(key in prev)) return prev
@@ -217,7 +201,7 @@ export default function AdminPage() {
value === "" && value === "" &&
(!state || state.source !== "file") && (!state || state.source !== "file") &&
!isSecretValue(state?.value) !isSecretValue(state?.value)
if (value === undefined || isRevert || isNoop) { if (isRevert || isNoop) {
const next = { ...prev } const next = { ...prev }
delete next[key] delete next[key]
return next return next
@@ -241,10 +225,9 @@ export default function AdminPage() {
const next = { ...prev } const next = { ...prev }
for (const key of keys) { for (const key of keys) {
if (!enabled) { if (!enabled) {
// Stage deletion of saved values; drop unsaved input // Stage deletion only for values currently set
if (settings[key]?.source === "default") if (settings[key]?.source !== "default")
delete next[key] next[key] = null
else next[key] = null
} else if (next[key] === null) { } else if (next[key] === null) {
delete next[key] delete next[key]
} }
@@ -464,7 +447,6 @@ export default function AdminPage() {
<ModelsSection <ModelsSection
providers={providers} providers={providers}
envProviders={envProviders} envProviders={envProviders}
envHasDefaultModel={envHasDefaultModel}
disabled={!writable || saving} disabled={!writable || saving}
password={authedPassword} password={authedPassword}
onChange={(next) => { onChange={(next) => {
@@ -480,11 +462,6 @@ export default function AdminPage() {
const defs = SETTINGS_BY_GROUP.get(group.id) ?? [] const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
const groupOff = const groupOff =
group.toggleable && !enabledGroups[group.id] 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 fieldsDisabled = !writable || saving || !!groupOff
const gt = groupText(group.id) const gt = groupText(group.id)
const title = gt?.title ?? group.title const title = gt?.title ?? group.title
@@ -503,11 +480,6 @@ export default function AdminPage() {
</h2> </h2>
{group.toggleable && ( {group.toggleable && (
<label <label
title={
envLocked
? dict.admin.sourceEnvTitle
: undefined
}
className={cn( 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", "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] enabledGroups[group.id]
@@ -522,11 +494,7 @@ export default function AdminPage() {
checked={ checked={
!!enabledGroups[group.id] !!enabledGroups[group.id]
} }
disabled={ disabled={!writable || saving}
!writable ||
saving ||
envLocked
}
aria-label={formatMessage( aria-label={formatMessage(
dict.admin.enableGroup, dict.admin.enableGroup,
{ group: title }, { group: title },
@@ -611,9 +579,6 @@ export default function AdminPage() {
setPending({}) setPending({})
setErrors({}) setErrors({})
setProviders(JSON.parse(savedProviders)) setProviders(JSON.parse(savedProviders))
setEnabledGroups(
groupsWithValues(settings),
)
}} }}
> >
{dict.admin.discard} {dict.admin.discard}
+5 -10
View File
@@ -73,10 +73,8 @@ export function SecretInput({
}) { }) {
const dict = useDictionary() const dict = useDictionary()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
// The stored marker to revert to on empty. Refreshed whenever the parent // The stored marker as it was at mount, to revert to on empty
// passes server state (a marker or nothing), e.g. after a save. const [original] = useState(value)
const [original, setOriginal] = useState(value)
if (typeof value !== "string" && value !== original) setOriginal(value)
const hadStored = isSecretValue(original) const hadStored = isSecretValue(original)
const text = typeof value === "string" ? value : "" const text = typeof value === "string" ? value : ""
const placeholder = isSecretValue(value) const placeholder = isSecretValue(value)
@@ -148,8 +146,7 @@ export function SettingField({
pendingValue: string | null | undefined pendingValue: string | null | undefined
error?: string error?: string
disabled: boolean disabled: boolean
// undefined drops the pending change (back to the saved value) onChange: (value: string | null) => void
onChange: (value: string | null | undefined) => void
}) { }) {
const dict = useDictionary() const dict = useDictionary()
const isDirty = pendingValue !== undefined const isDirty = pendingValue !== undefined
@@ -229,18 +226,16 @@ export function SettingField({
case "secret": case "secret":
control = ( control = (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Clearing a saved secret reverts to it; the X button deletes */}
<SecretInput <SecretInput
id={inputId} id={inputId}
keepOnEmpty={source === "file"}
value={ value={
isDirty isDirty
? (pendingValue ?? "") ? (pendingValue ?? "")
: (secretState ?? undefined) : (secretState ?? currentValue)
} }
disabled={disabled} disabled={disabled}
onChange={(v) => onChange={(v) =>
onChange(typeof v === "string" ? v : undefined) onChange(typeof v === "string" ? v : "")
} }
/> />
</div> </div>
+18 -20
View File
@@ -37,6 +37,7 @@ export default function Home() {
) )
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false)
// Load preferences from localStorage after mount // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
@@ -47,9 +48,7 @@ export default function Home() {
const currentLocale = pathParts[0] const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) { if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale pathParts[0] = savedLocale
// Keep the query (e.g. ?session=) and hash router.replace(`/${pathParts.join("/")}`)
const { search, hash } = window.location
router.replace(`/${pathParts.join("/")}${search}${hash}`)
return // Wait for redirect return // Wait for redirect
} }
} }
@@ -107,25 +106,27 @@ export default function Home() {
resetDrawioReady() resetDrawioReady()
} }
// Check mobile. No panel is remounted when crossing the breakpoint, so // Check mobile - reset draw.io before crossing breakpoint
// the draw.io ready state and the chat's turn stay as they are. const isInitialRenderRef = useRef(true)
useEffect(() => { useEffect(() => {
const checkMobile = () => { 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() checkMobile()
window.addEventListener("resize", checkMobile) window.addEventListener("resize", checkMobile)
return () => window.removeEventListener("resize", checkMobile) return () => window.removeEventListener("resize", checkMobile)
}, []) }, [resetDrawioReady])
// Give the chat panel the size of this side of the breakpoint. It is
// open on both sides: the mobile panel cannot be collapsed, and one
// collapsed on desktop comes back open
useEffect(() => {
chatPanelRef.current?.resize(isMobile ? 50 : 33)
setIsChatVisible(true)
}, [isMobile])
const toggleChatPanel = () => { const toggleChatPanel = () => {
const panel = chatPanelRef.current const panel = chatPanelRef.current
@@ -192,11 +193,7 @@ export default function Home() {
noExitBtn: true, noExitBtn: true,
dark: dark:
darkMode || drawioUi === "dark", darkMode || drawioUi === "dark",
// draw.io names Traditional Chinese "zh-tw" lang: currentLang,
lang:
currentLang === "zh-Hant"
? "zh-tw"
: currentLang,
// Enable offline mode in Electron to disable external service calls // Enable offline mode in Electron to disable external service calls
...(isElectron && { ...(isElectron && {
offline: true, offline: true,
@@ -220,6 +217,7 @@ export default function Home() {
{/* Chat Panel */} {/* Chat Panel */}
<ResizablePanel <ResizablePanel
key={isMobile ? "mobile" : "desktop"}
id="chat-panel" id="chat-panel"
ref={chatPanelRef} ref={chatPanelRef}
defaultSize={isMobile ? 50 : 33} defaultSize={isMobile ? 50 : 33}
+1 -8
View File
@@ -7,11 +7,7 @@ import {
mergeSecrets, mergeSecrets,
validateAdminProviders, validateAdminProviders,
} from "@/lib/admin/providers" } from "@/lib/admin/providers"
import { import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
getEnvFallback,
isSettingsWritable,
saveSettings,
} from "@/lib/admin/settings"
import { loadEnvServerModelsConfig } from "@/lib/server-model-config" import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
export const runtime = "nodejs" export const runtime = "nodejs"
@@ -37,9 +33,6 @@ async function payload() {
models: p.models, models: p.models,
isDefault: !!p.default && !adminHasDefault, 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"),
} }
} }
+2 -20
View File
@@ -5,7 +5,6 @@ import {
loadAdminProviders, loadAdminProviders,
mergeSecrets, mergeSecrets,
} from "@/lib/admin/providers" } from "@/lib/admin/providers"
import { globalBaseUrl } from "@/lib/ai-providers"
export const runtime = "nodejs" export const runtime = "nodejs"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -48,31 +47,14 @@ export async function POST(req: Request) {
sameEndpoint && stored ? [stored] : [], sameEndpoint && stored ? [stored] : [],
) )
const serverUrl = globalBaseUrl(resolved.provider)
return validateModel( return validateModel(
new Request(new URL("/api/validate-model", req.url), { new Request(new URL("/api/validate-model", req.url), {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
// Checked again there, in place of an access code
"x-admin-password": req.headers.get("x-admin-password") || "",
// The EdgeOne function checks the access code and Pages
// cookies, and its URL is built from the page's origin
"x-access-code": req.headers.get("x-access-code") || "",
cookie: req.headers.get("cookie") || "",
...(req.headers.get("origin") && {
origin: req.headers.get("origin") as string,
}),
},
body: JSON.stringify({ body: JSON.stringify({
provider: resolved.provider, provider: resolved.provider,
apiKey: resolved.apiKey, apiKey: resolved.apiKey,
// Without a URL of its own, chat sends the entry's key to baseUrl: resolved.baseUrl,
// the server's <P>_BASE_URL: test that endpoint, not
// another one. It is the server's own, which chat uses
// without the checks for a URL a user typed.
baseUrl: resolved.baseUrl || serverUrl,
...(!resolved.baseUrl && serverUrl && { serverBaseUrl: true }),
modelId: body.modelId, modelId: body.modelId,
awsAccessKeyId: resolved.awsAccessKeyId, awsAccessKeyId: resolved.awsAccessKeyId,
awsSecretAccessKey: resolved.awsSecretAccessKey, awsSecretAccessKey: resolved.awsSecretAccessKey,
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import {
AIHUBMIX_MODELS_ENDPOINT,
extractAihubmixModelIds,
} from "@/lib/aihubmix-models"
import { SUGGESTED_MODELS } from "@/lib/types/model-config"
const SUCCESS_CACHE_CONTROL =
"public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"
function fallbackResponse() {
return NextResponse.json(
{
models: SUGGESTED_MODELS.aihubmix || [],
source: "fallback",
},
{
headers: {
"Cache-Control": "no-store",
},
},
)
}
export async function GET() {
try {
const response = await fetch(AIHUBMIX_MODELS_ENDPOINT, {
next: { revalidate: 3600 },
})
if (!response.ok) {
console.warn(
`[aihubmix-models] Failed to fetch models: ${response.status}`,
)
return fallbackResponse()
}
const payload = await response.json()
const models = extractAihubmixModelIds(payload)
if (models.length === 0) {
console.warn("[aihubmix-models] Model list response was empty")
return fallbackResponse()
}
return NextResponse.json(
{
models,
source: "aihubmix",
},
{
headers: {
"Cache-Control": SUCCESS_CACHE_CONTROL,
},
},
)
} catch (error) {
console.warn("[aihubmix-models] Failed to load models:", error)
return fallbackResponse()
}
}
+396 -362
View File
@@ -4,66 +4,42 @@ import {
createUIMessageStream, createUIMessageStream,
createUIMessageStreamResponse, createUIMessageStreamResponse,
InvalidToolInputError, InvalidToolInputError,
LoadAPIKeyError,
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai" } from "ai"
import fs from "fs/promises"
import { jsonrepair } from "jsonrepair" import { jsonrepair } from "jsonrepair"
import path from "path" import path from "path"
import { z } from "zod" import { z } from "zod"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { import {
CACHE_POINT,
edgeOneEndpoint,
getAIModel, getAIModel,
getServerProvider,
SINGLE_SYSTEM_PROVIDERS, SINGLE_SYSTEM_PROVIDERS,
supportsImageInput,
supportsPromptCaching, supportsPromptCaching,
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import { import {
dropInvalidToolCalls, isMinimalDiagram,
fixToolInputJson,
replaceHistoricalToolInputs, replaceHistoricalToolInputs,
validateFileParts, validateFileParts,
} from "@/lib/chat-helpers" } from "@/lib/chat-helpers"
import { withDeprecatedParamsFallback } from "@/lib/deprecated-params"
import { import {
checkAndIncrementRequest, checkAndIncrementRequest,
isQuotaEnabled, isQuotaEnabled,
recordTokenUsage, recordTokenUsage,
} from "@/lib/dynamo-quota-manager" } from "@/lib/dynamo-quota-manager"
import { import {
endTrace,
getTelemetryConfig, getTelemetryConfig,
setTraceInput, setTraceInput,
setTraceOutput, setTraceOutput,
wrapWithObserve, wrapWithObserve,
} from "@/lib/langfuse" } from "@/lib/langfuse"
import { classifyLLMError, streamErrorText } from "@/lib/llm-errors" import { findServerModelById } from "@/lib/server-model-config"
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 { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { normalizeBaseUrl } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id" import { getUserIdFromRequest } from "@/lib/user-id"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import {
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 export const maxDuration = 120
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
// which is also where Node's response-body timeout on the upstream stream lands.
// Helper function to create cached stream response // Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response { function createCachedStreamResponse(xml: string): Response {
@@ -95,18 +71,24 @@ function createCachedStreamResponse(xml: string): Response {
return createUIMessageStreamResponse({ stream }) return createUIMessageStreamResponse({ stream })
} }
// Responses streamed from the model, whose trace streamText's callbacks end
const modelStreamResponses = new WeakSet<Response>()
// Inner handler function // Inner handler function
const DEBUG_LLM_PAYLOAD = process.env.DEBUG_LLM_PAYLOAD === "true"
async function handleChatRequest(req: Request): Promise<Response> { async function handleChatRequest(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Check for access code // Check for access code
const accessDenied = checkAccessCode(req) const accessCodes =
if (accessDenied) return accessDenied 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 body = await req.json()
const { messages, xml, previousXml, sessionId } = body const { messages, xml, previousXml, sessionId } = body
@@ -139,165 +121,17 @@ async function handleChatRequest(req: Request): Promise<Response> {
userId: userId, userId: userId,
}) })
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages)
if (!fileValidation.valid) {
return Response.json({ error: fileValidation.error }, { status: 400 })
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || !hasCells(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || "", !!filePart)
if (cached) {
return createCachedStreamResponse(cached.xml)
}
}
// === CACHE CHECK END ===
// Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
const selectedModelId = req.headers.get("x-selected-model-id")
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string | string[]
baseUrlEnv?: string
provider?: string
} = {}
let serverModel: FlattenedServerModel | null = null
if (selectedModelId?.startsWith("server:")) {
serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
if (serverModel) {
serverModelConfig = {
apiKeyEnv: serverModel.apiKeyEnv,
baseUrlEnv: serverModel.baseUrlEnv,
// Use actual provider from config (client header may have incorrect value due to ID format change)
provider: serverModel.provider,
}
}
}
// A server model's provider comes from its config: for one set up in
// the admin panel the header holds the provider name's slug. Without
// either, the server's own AI_PROVIDER.
const isEdgeOne =
(serverModelConfig.provider || provider || getServerProvider()) ===
"edgeone"
// EdgeOne is this deployment's own function, whatever URL the request
// names: another host would get the user's EdgeOne cookies, and the
// quota counts it. Absolute, as the SDK needs.
if (isEdgeOne) baseUrl = edgeOneEndpoint(req)
// 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")
const clientOverrides = {
// Server model provider takes precedence over client header; EdgeOne
// named only in AI_PROVIDER is named here, for its own base URL
provider:
serverModelConfig.provider ||
provider ||
(isEdgeOne ? "edgeone" : null),
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"),
// AWS Bedrock credentials
awsAccessKeyId: req.headers.get("x-aws-access-key-id"),
awsSecretAccessKey: req.headers.get("x-aws-secret-access-key"),
awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"),
// Server model custom env var names
...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
...(isEdgeOne && {
headers: {
...(cookieHeader && { cookie: cookieHeader }),
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
}
// Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true"
console.log(
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
)
// Get AI model with optional client overrides
const {
model: baseModel,
providerOptions,
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
// on AI_PROVIDER. 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()) || []
const offeredInEnv =
envModels.includes(modelId) && resolvedProvider === getServerProvider()
if (onServerCredentials && !serverModel && !offeredInEnv) {
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 === // === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in (DYNAMODB_QUOTA_TABLE) and counts what runs on the // Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
// server's keys, or on the server's own endpoints: EdgeOne, its keyless const hasOwnApiKey = !!(
// Ollama, and anything at a private address (the server's network, req.headers.get("x-ai-provider") &&
// which ignores a dummy key header). Bedrock and EdgeOne never use the (req.headers.get("x-ai-api-key") ||
// base URL header. In the desktop app every endpoint is the user's. req.headers.get("x-aws-access-key-id") ||
const clientBaseUrl = normalizeBaseUrl( req.headers.get("x-vertex-api-key"))
req.headers.get("x-ai-base-url") ?? "",
) )
const onServerEndpoint = await usesServerEndpoint(
resolvedProvider, // Skip quota check if: quota disabled, user has own API key, or is anonymous
clientBaseUrl, if (isQuotaEnabled() && !hasOwnApiKey && userId !== "anonymous") {
clientOverrides.apiKey,
)
const countsQuota =
isQuotaEnabled() &&
(onServerCredentials || onServerEndpoint) &&
userId !== "anonymous"
if (countsQuota) {
const quotaCheck = await checkAndIncrementRequest(userId, { const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10, requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000, tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
@@ -317,20 +151,103 @@ async function handleChatRequest(req: Request): Promise<Response> {
} }
// === SERVER-SIDE QUOTA CHECK END === // === SERVER-SIDE QUOTA CHECK END ===
// Retry once if the provider rejects the requested budget, or (newer // === FILE VALIDATION START ===
// Claude models) the sampling or thinking settings const fileValidation = validateFileParts(messages)
const model = withOutputTokenLimitFallback( if (!fileValidation.valid) {
withDeprecatedParamsFallback(baseModel), return Response.json({ error: fileValidation.error }, { status: 400 })
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1
const isEmptyDiagram = !xml || xml.trim() === "" || isMinimalDiagram(xml)
if (isFirstMessage && isEmptyDiagram) {
const lastMessage = messages[0]
const textPart = lastMessage.parts?.find((p: any) => p.type === "text")
const filePart = lastMessage.parts?.find((p: any) => p.type === "file")
const cached = findCachedResponse(textPart?.text || "", !!filePart)
if (cached) {
return createCachedStreamResponse(cached.xml)
}
}
// === CACHE CHECK END ===
// Read client AI provider overrides from headers
const provider = req.headers.get("x-ai-provider")
let baseUrl = req.headers.get("x-ai-base-url")
const selectedModelId = req.headers.get("x-selected-model-id")
// For EdgeOne provider, construct full URL from request origin
// because createOpenAI needs absolute URL, not relative path
if (provider === "edgeone" && !baseUrl) {
const origin = req.headers.get("origin") || new URL(req.url).origin
baseUrl = `${origin}/api/edgeai`
}
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
const cookieHeader = req.headers.get("cookie")
// Check if this is a server model with custom env var names
let serverModelConfig: {
apiKeyEnv?: string | string[]
baseUrlEnv?: string
provider?: string
} = {}
if (selectedModelId?.startsWith("server:")) {
const serverModel = await findServerModelById(selectedModelId)
console.log(
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
)
if (serverModel) {
serverModelConfig = {
apiKeyEnv: serverModel.apiKeyEnv,
baseUrlEnv: serverModel.baseUrlEnv,
// Use actual provider from config (client header may have incorrect value due to ID format change)
provider: serverModel.provider,
}
}
}
const clientOverrides = {
// Server model provider takes precedence over client header
provider: serverModelConfig.provider || provider,
baseUrl,
apiKey: req.headers.get("x-ai-api-key"),
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"),
awsRegion: req.headers.get("x-aws-region"),
awsSessionToken: req.headers.get("x-aws-session-token"),
// Server model custom env var names
...serverModelConfig,
// Vertex AI credentials (Express Mode)
vertexApiKey: req.headers.get("x-vertex-api-key"),
// Pass cookies for EdgeOne Pages authentication
...(provider === "edgeone" &&
cookieHeader && {
headers: { cookie: cookieHeader },
}),
}
// Read minimal style preference from header
const minimalStyle = req.headers.get("x-minimal-style") === "true"
console.log(
`[Client Overrides] provider: ${clientOverrides.provider}, modelId: ${clientOverrides.modelId}`,
) )
// The user setting can raise the budget only on their own key (in the // Get AI model with optional client overrides
// desktop app every key is the user's); on the server's keys or own const {
// endpoints it can only lower it model,
const maxOutputTokens = resolveMaxOutputTokens( providerOptions,
req.headers.get("x-max-output-tokens"), headers,
onServerCredentials || onServerEndpoint, modelId,
) provider: resolvedProvider,
console.log(`[maxOutputTokens] ${maxOutputTokens}`) } = getAIModel(clientOverrides)
// Check if model supports prompt caching // Check if model supports prompt caching
const shouldCache = supportsPromptCaching(modelId) const shouldCache = supportsPromptCaching(modelId)
@@ -349,10 +266,16 @@ async function handleChatRequest(req: Request): Promise<Response> {
lastUserMessage?.parts?.filter((part: any) => part.type === "file") || lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
[] []
// Note: we used to pre-emptively reject images for models we guessed were // Check if user is sending images to a model that doesn't support them
// text-only (by name matching). That heuristic misfired on newer models // AI SDK silently drops unsupported parts, so we need to catch this early
// (see issue #874), so we now let the request through and surface the real if (fileParts.length > 0 && !supportsImageInput(modelId)) {
// provider error if the model genuinely can't accept images. 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 // User input only - XML is now in a separate cached system message
const formattedUserInput = `User input: const formattedUserInput = `User input:
@@ -360,45 +283,38 @@ async function handleChatRequest(req: Request): Promise<Response> {
${userInputText} ${userInputText}
"""` """`
// Convert UIMessages to ModelMessages and add system message. A tool // Convert UIMessages to ModelMessages and add system message
// call that never got its result (the user stopped while it ran) is const modelMessages = await convertToModelMessages(messages)
// left out: the SDK would refuse this and every later request of the
// chat (MissingToolResultsError)
const modelMessages = await convertToModelMessages(messages, {
ignoreIncompleteToolCalls: true,
})
// DEBUG_LLM_PAYLOAD=true logs the incoming message structure // DEBUG: Log incoming messages structure
if (DEBUG_LLM_PAYLOAD) { console.log("[route.ts] Incoming messages count:", messages.length)
console.log("[route.ts] Incoming messages count:", messages.length) messages.forEach((msg: any, idx: number) => {
messages.forEach((msg: any, idx: number) => { console.log(
console.log( `[route.ts] Message ${idx} role:`,
`[route.ts] Message ${idx} role:`, msg.role,
msg.role, "parts count:",
"parts count:", msg.parts?.length,
msg.parts?.length, )
) if (msg.parts) {
if (msg.parts) { msg.parts.forEach((part: any, partIdx: number) => {
msg.parts.forEach((part: any, partIdx: number) => { if (
if ( part.type === "tool-invocation" ||
part.type === "tool-invocation" || part.type === "tool-result"
part.type === "tool-result" ) {
) { console.log(`[route.ts] Part ${partIdx}:`, {
console.log(`[route.ts] Part ${partIdx}:`, { type: part.type,
type: part.type, toolName: part.toolName,
toolName: part.toolName, hasInput: !!part.input,
hasInput: !!part.input, inputType: typeof part.input,
inputType: typeof part.input, inputKeys:
inputKeys: part.input && typeof part.input === "object"
part.input && typeof part.input === "object" ? Object.keys(part.input)
? Object.keys(part.input) : null,
: null, })
}) }
} })
}) }
} })
})
}
// Replace historical tool call XML with placeholders to reduce tokens // Replace historical tool call XML with placeholders to reduce tokens
// Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML // Disabled by default - some models (e.g. minimax) copy placeholders instead of generating XML
@@ -416,43 +332,61 @@ ${userInputText}
) )
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming) // Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
// and their results. Bedrock API rejects messages where toolUse.input is not a valid // Bedrock API rejects messages where toolUse.input is not a valid JSON object
// JSON object, and every provider rejects a tool result whose call is gone. enhancedMessages = enhancedMessages
enhancedMessages = dropInvalidToolCalls(enhancedMessages) .map((msg: any) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
// DEBUG_LLM_PAYLOAD=true logs what is sent to the model return msg
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",
})
}
})
} }
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) // Update the last message with user input only (XML moved to separate cached system message)
if (enhancedMessages.length >= 1) { if (enhancedMessages.length >= 1) {
@@ -468,7 +402,7 @@ ${userInputText}
contentParts.push({ contentParts.push({
type: "image", type: "image",
image: filePart.url, image: filePart.url,
mediaType: filePart.mediaType, mimeType: filePart.mediaType,
}) })
} }
@@ -488,7 +422,9 @@ ${userInputText}
if (enhancedMessages[i].role === "assistant") { if (enhancedMessages[i].role === "assistant") {
enhancedMessages[i] = { enhancedMessages[i] = {
...enhancedMessages[i], ...enhancedMessages[i],
providerOptions: CACHE_POINT, providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
} }
break // Only cache the last assistant message break // Only cache the last assistant message
} }
@@ -527,7 +463,7 @@ ${previousXml}
${xml || ""} ${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 const systemMessages = isSingleSystemProvider
? [ ? [
@@ -541,30 +477,32 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
{ {
role: "system" as const, role: "system" as const,
content: finalSystemMessage, content: finalSystemMessage,
...(shouldCache && { providerOptions: CACHE_POINT }), ...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
}, },
// Cache breakpoint 2: Previous and Current diagram XML context // Cache breakpoint 2: Previous and Current diagram XML context
{ {
role: "system" as const, role: "system" as const,
content: xmlContext, content: xmlContext,
...(shouldCache && { providerOptions: CACHE_POINT }), ...(shouldCache && {
providerOptions: {
bedrock: { cachePoint: { type: "default" } },
},
}),
}, },
] ]
const allMessages = [...systemMessages, ...enhancedMessages] const allMessages = [...systemMessages, ...enhancedMessages]
// Set by onAbort, which records the finished steps' tokens itself
let stopped = false
const result = streamText({ const result = streamText({
model, model,
// The system messages carry cache points, so they go in messages.
// A client's own system messages have string content and were
// dropped by the empty-content filter above.
allowSystemInMessages: true,
abortSignal: req.signal, abortSignal: req.signal,
// Must be sent: unset means the provider's own default, and Bedrock's is ...(process.env.MAX_OUTPUT_TOKENS && {
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute. maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
maxOutputTokens, }),
stopWhen: stepCountIs(5), stopWhen: stepCountIs(5),
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON // Repair truncated tool calls when maxOutputTokens is reached mid-JSON
experimental_repairToolCall: async ({ toolCall, error }) => { experimental_repairToolCall: async ({ toolCall, error }) => {
@@ -582,11 +520,23 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
error.name === "AI_InvalidToolInputError" error.name === "AI_InvalidToolInputError"
) { ) {
try { try {
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle, // Pre-process to fix common LLM JSON errors that jsonrepair can't handle
// then use jsonrepair to fix truncated JSON let inputToRepair = toolCall.input
const repairedInput = jsonrepair( if (typeof inputToRepair === "string") {
fixToolInputJson(toolCall.input), // 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( console.log(
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`, `[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
) )
@@ -596,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}`, `[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
repairError, repairError,
) )
// Keep the original error, so the model and the client see why // Return a placeholder input to avoid API errors in multi-step
// the input was rejected and the model can retry the call // 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 return null
} }
} }
@@ -606,6 +574,7 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
}, },
messages: allMessages, messages: allMessages,
...(providerOptions && { providerOptions }), // This now includes all reasoning configs ...(providerOptions && { providerOptions }), // This now includes all reasoning configs
...(headers && { headers }),
// Langfuse telemetry config (returns undefined if not configured) // Langfuse telemetry config (returns undefined if not configured)
...(getTelemetryConfig({ sessionId: validSessionId, userId }) && { ...(getTelemetryConfig({ sessionId: validSessionId, userId }) && {
experimental_telemetry: getTelemetryConfig({ experimental_telemetry: getTelemetryConfig({
@@ -619,36 +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) // Record token usage for server-side quota tracking (if enabled)
// Use totalUsage (cumulative across all steps) instead of usage (final step only) // Use totalUsage (cumulative across all steps) instead of usage (final step only)
// inputTokens already includes cache reads and writes in AI SDK 6 // Include all 4 token types: input, output, cache read, cache write
if (countsQuota && totalUsage && !stopped) { if (
isQuotaEnabled() &&
!hasOwnApiKey &&
userId !== "anonymous" &&
totalUsage
) {
const totalTokens = const totalTokens =
(totalUsage.inputTokens || 0) + (totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0) (totalUsage.outputTokens || 0) +
(totalUsage.cachedInputTokens || 0) +
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
recordTokenUsage(userId, totalTokens) 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: ({ steps }) => {
stopped = true
endTrace()
// Stopped (or disconnected) after some steps finished: their
// tokens were used, or stopping every request after a costly
// first step would get around the token limits
if (countsQuota) {
const tokens = steps.reduce(
(sum, step) =>
sum +
(step.usage.inputTokens || 0) +
(step.usage.outputTokens || 0),
0,
)
if (tokens > 0) recordTokenUsage(userId, tokens)
}
},
tools: { tools: {
// Client-side tool that will be executed on the client // Client-side tool that will be executed on the client
display_diagram: { display_diagram: {
@@ -663,7 +617,21 @@ VALIDATION RULES (XML will be rejected if violated):
6. Escape special chars in values: &lt; &gt; &amp; &quot; 6. Escape special chars in values: &lt; &gt; &amp; &quot;
Example (generate ONLY this - no wrapper tags): 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: Notes:
- For AWS diagrams, use **AWS 2025 icons**. - For AWS diagrams, use **AWS 2025 icons**.
@@ -741,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. description: `Get draw.io shape/icon library documentation with style syntax and shape names.
Available libraries: 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.`, Call this tool to get shape names and usage syntax for a specific library.`,
inputSchema: z.object({ inputSchema: z.object({
@@ -752,12 +727,45 @@ Call this tool to get shape names and usage syntax for a specific library.`,
), ),
}), }),
execute: async ({ library }) => { execute: async ({ library }) => {
// Only known library names reach the file system // Sanitize input - prevent path traversal attacks
const result = await getShapeLibrary( const sanitizedLibrary = library
library, .toLowerCase()
path.join(process.cwd(), "docs/shape-libraries"), .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.`
}
}, },
}, },
}, },
@@ -766,12 +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, sendReasoning: true,
// On the server's keys the provider's text can name its account.
// Keyless endpoints keep theirs: the desktop app's Ollama is the
// user's own, and EdgeOne's text is our function's explanation.
onError: (error) => streamErrorText(error, onServerCredentials),
messageMetadata: ({ part }) => { messageMetadata: ({ part }) => {
if (part.type === "finish") { if (part.type === "finish") {
const usage = (part as any).totalUsage const usage = (part as any).totalUsage
@@ -784,28 +788,63 @@ Call this tool to get shape names and usage syntax for a specific library.`,
return undefined 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 { function handleError(error: unknown): Response {
console.error("Error in chat route:", error) console.error("Error in chat route:", error)
const isDev = process.env.NODE_ENV === "development" const isDev = process.env.NODE_ENV === "development"
const classified = classifyLLMError(error)
const status = // Check for specific AI SDK error types
(error as { statusCode?: number })?.statusCode || if (APICallError.isInstance(error)) {
(error as { status?: number })?.status || return Response.json(
(classified.code === "invalid_api_key" ? 401 : 500) {
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( return Response.json(
{ {
...classified, error: safeMessage,
...(isDev && { ...(isDev && {
details: APICallError.isInstance(error) details: message,
? error.responseBody
: undefined,
stack: error instanceof Error ? error.stack : undefined, stack: error instanceof Error ? error.stack : undefined,
}), }),
}, },
@@ -815,16 +854,11 @@ function handleError(error: unknown): Response {
// Wrap handler with error handling // Wrap handler with error handling
async function safeHandler(req: Request): Promise<Response> { async function safeHandler(req: Request): Promise<Response> {
let response: Response
try { try {
response = await handleChatRequest(req) return await handleChatRequest(req)
} catch (error) { } 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) // Wrap with Langfuse observe (if configured)
+1 -1
View File
@@ -4,7 +4,7 @@ import { getLangfuseClient } from "@/lib/langfuse"
const saveSchema = z.object({ const saveSchema = z.object({
filename: z.string().min(1).max(255), filename: z.string().min(1).max(255),
format: z.enum(["drawio", "png", "svg", "xmlsvg"]), format: z.enum(["drawio", "png", "svg"]),
sessionId: z.string().min(1).max(200).optional(), sessionId: z.string().min(1).max(200).optional(),
}) })
+28 -86
View File
@@ -1,46 +1,13 @@
import { extractFromHtml } from "@extractus/article-extractor" import { extract } from "@extractus/article-extractor"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import TurndownService from "turndown" import TurndownService from "turndown"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { readLimitedBody } from "@/lib/read-limited-body"
import { isPrivateUrl } from "@/lib/ssrf-protection" import { isPrivateUrl } from "@/lib/ssrf-protection"
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
const EXTRACT_TIMEOUT_MS = 15000 const EXTRACT_TIMEOUT_MS = 15000
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)" const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
// would produce mojibake; the article-extractor library does the same detection
// when it fetches the page itself, which we no longer rely on.
function detectCharset(
contentType: string | null,
buffer: ArrayBuffer,
): string {
// 1. HTTP Content-Type header charset (most authoritative).
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
const metaCharset =
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
try {
new TextDecoder(charset)
return charset
} catch {
return "utf-8"
}
}
export async function POST(req: Request) { export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
const accessError = checkAccessCode(req)
if (accessError) return accessError
try { try {
const { url } = await req.json() const { url } = await req.json()
@@ -64,31 +31,21 @@ export async function POST(req: Request) {
// SSRF protection: parse-url has no use case for fetching internal // SSRF protection: parse-url has no use case for fetching internal
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only // hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
// governs LLM provider baseUrl overrides (validate-model, chat). // governs LLM provider baseUrl overrides (validate-model, chat).
if (await isPrivateUrl(url)) { if (isPrivateUrl(url)) {
return NextResponse.json( return NextResponse.json(
{ error: "Cannot access private/internal URLs" }, { error: "Cannot access private/internal URLs" },
{ status: 400 }, { status: 400 },
) )
} }
// Fetch the page ourselves so we control redirect handling. The const headController = new AbortController()
// article-extractor library follows redirects internally and ignores a const headTimeout = setTimeout(() => headController.abort(), 3000)
// `redirect` option, which would let a public URL 302 to an internal
// host and bypass the SSRF check above. `redirect: "error"` rejects any
// redirect outright.
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, EXTRACT_TIMEOUT_MS)
let html: string
try { try {
const response = await fetch(url, { const headResponse = await fetch(url, {
method: "HEAD",
headers: { "User-Agent": USER_AGENT }, headers: { "User-Agent": USER_AGENT },
redirect: "error", signal: headController.signal,
signal: controller.signal,
}) })
const contentType = headResponse.headers.get("content-type")
const contentType = response.headers.get("content-type")
if (contentType?.includes("application/pdf")) { if (contentType?.includes("application/pdf")) {
return NextResponse.json( return NextResponse.json(
{ {
@@ -97,25 +54,27 @@ export async function POST(req: Request) {
{ status: 422 }, { status: 422 },
) )
} }
} catch (err) {
console.warn(
"HEAD pre-check failed, proceeding with extraction:",
err,
)
} finally {
clearTimeout(headTimeout)
}
if (!response.ok) { // Extract article content with timeout to avoid tying up server resources
return NextResponse.json( const controller = new AbortController()
{ error: "Could not fetch URL content" }, const timeoutId = setTimeout(() => {
{ status: 400 }, controller.abort()
) }, EXTRACT_TIMEOUT_MS)
}
const buffer = await readLimitedBody(response, MAX_RESPONSE_BYTES) let article
if (!buffer) { try {
return NextResponse.json( article = await extract(url, undefined, {
{ headers: { "User-Agent": USER_AGENT },
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`, signal: controller.signal,
}, })
{ status: 413 },
)
}
const charset = detectCharset(contentType, buffer)
html = new TextDecoder(charset).decode(buffer)
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") { if (err?.name === "AbortError") {
return NextResponse.json( return NextResponse.json(
@@ -123,26 +82,9 @@ export async function POST(req: Request) {
{ status: 504 }, { status: 504 },
) )
} }
// Redirects are rejected with a TypeError ("failed to fetch" / throw err
// "unexpected redirect") when redirect: "error" is set.
return NextResponse.json(
{ error: "Could not fetch URL content" },
{ status: 400 },
)
} finally { } finally {
clearTimeout(timeoutId) clearTimeout(timeoutId)
// Ends a download left unread (too large, PDF, error status);
// a body already read is not affected
controller.abort()
}
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
// so map any parse error to the same 400 as the no-content case.
let article: Awaited<ReturnType<typeof extractFromHtml>>
try {
article = await extractFromHtml(html, url)
} catch {
article = null
} }
if (!article || !article.content) { if (!article || !article.content) {
-79
View File
@@ -1,79 +0,0 @@
import { NextResponse } from "next/server"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { classifyLLMError } from "@/lib/llm-errors"
import {
canListModels,
listProviderModels,
ModelListError,
} from "@/lib/provider-models"
import {
allowPrivateUrls,
isPrivateUrl,
RedirectRefusedError,
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) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// 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)
// Only our own explanations go back: the URL may be an internal
// address, whose answer or host names must not reach the caller.
// The Gateway SDK wraps them, keeping ours as the cause.
const isOwn = (e: unknown): e is Error =>
e instanceof ModelListError || e instanceof RedirectRefusedError
const cause = (error as { cause?: unknown })?.cause
const own = isOwn(error) ? error : isOwn(cause) ? cause : null
const { code } = classifyLLMError(own ?? error)
return NextResponse.json({
code,
error: own?.message ?? "The model list request failed.",
})
}
}
+19 -67
View File
@@ -3,15 +3,8 @@
* Accepts a PNG image and streams validation results using useObject-compatible format. * Accepts a PNG image and streams validation results using useObject-compatible format.
*/ */
import { Output, streamText } from "ai" import { streamObject } from "ai"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { getValidationModel } from "@/lib/ai-providers" import { getValidationModel } from "@/lib/ai-providers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
recordTokenUsage,
} from "@/lib/dynamo-quota-manager"
import { getUserIdFromRequest } from "@/lib/user-id"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts" import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import { import {
type ValidationResult, type ValidationResult,
@@ -20,9 +13,6 @@ import {
export const maxDuration = 30 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 { interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL imageData: string // Base64 PNG data URL
sessionId?: string sessionId?: string
@@ -35,20 +25,25 @@ const DEFAULT_VALID_RESULT: ValidationResult = {
suggestions: [], 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 { 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" }, headers: { "Content-Type": "text/plain; charset=utf-8" },
}) })
} }
export async function POST(req: Request): Promise<Response> { export async function POST(req: Request): Promise<Response> {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Uses the server's model credentials, so require the access code
const accessError = checkAccessCode(req)
if (accessError) return accessError
try { try {
// Check if VLM validation is enabled (default: true) // Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false" const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
@@ -77,42 +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 },
)
}
// It runs the server's vision model: with the quota on, the daily
// and per-minute token limits apply, and its tokens are counted. Not
// the request limit, which is for chats: the day's last chat still
// gets its check, and a check does not count as a chat.
const userId = getUserIdFromRequest(req)
const countsQuota = isQuotaEnabled() && userId !== "anonymous"
if (countsQuota) {
const quotaCheck = await checkAndIncrementRequest(
userId,
{
requests: 0,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
},
0,
)
if (!quotaCheck.allowed) {
return Response.json(
{
error: quotaCheck.error,
type: quotaCheck.type,
used: quotaCheck.used,
limit: quotaCheck.limit,
},
{ status: 429 },
)
}
}
// Get the validation model // Get the validation model
let model let model
try { try {
@@ -134,9 +93,9 @@ export async function POST(req: Request): Promise<Response> {
) || 10000 ) || 10000
// Stream the VLM response for useObject consumption // Stream the VLM response for useObject consumption
const result = streamText({ const result = streamObject({
model, model,
output: Output.object({ schema: ValidationResultSchema }), schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT, system: VALIDATION_SYSTEM_PROMPT,
messages: [ messages: [
{ {
@@ -155,17 +114,10 @@ export async function POST(req: Request): Promise<Response> {
], ],
maxOutputTokens: 1024, maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout), abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ output, totalUsage }) => { onFinish: ({ object }) => {
if (countsQuota && totalUsage) { if (sessionId && object) {
recordTokenUsage(
userId,
(totalUsage.inputTokens || 0) +
(totalUsage.outputTokens || 0),
)
}
if (sessionId && output) {
console.log( 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}`,
) )
} }
}, },
+364 -153
View File
@@ -1,28 +1,27 @@
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 { createAihubmix } from "@aihubmix/ai-sdk-provider"
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { generateText } from "ai"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { z } from "zod" import { createOllama } from "ollama-ai-provider-v2"
import { checkAccessCode, rejectCrossSite } from "@/lib/access-code"
import { checkAdminAuth } from "@/lib/admin/auth"
import { import {
edgeOneEndpoint, AIHUBMIX_APP_CODE,
getAIModel, isAihubmixStandardBaseURL,
globalBaseUrl, normalizeMiniMaxBaseURL,
usesServerCredentials,
usesServerEndpoint,
} from "@/lib/ai-providers" } from "@/lib/ai-providers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
} from "@/lib/dynamo-quota-manager"
import { classifyLLMError } from "@/lib/llm-errors"
import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection" import { allowPrivateUrls, isPrivateUrl } from "@/lib/ssrf-protection"
import { normalizeBaseUrl, type ProviderName } from "@/lib/types/model-config" import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
import { getUserIdFromRequest } from "@/lib/user-id"
export const runtime = "nodejs" export const runtime = "nodejs"
interface ValidateRequest { interface ValidateRequest {
provider: ProviderName provider: string
apiKey: string apiKey: string
baseUrl?: string baseUrl?: string
modelId: string modelId: string
@@ -30,42 +29,21 @@ interface ValidateRequest {
awsAccessKeyId?: string awsAccessKeyId?: string
awsSecretAccessKey?: string awsSecretAccessKey?: string
awsRegion?: string awsRegion?: string
awsSessionToken?: string
// Vertex AI specific // Vertex AI specific
vertexApiKey?: string // Express Mode API key vertexApiKey?: string // Express Mode API key
// Set by the admin panel's Test: baseUrl is the server's <P>_BASE_URL
serverBaseUrl?: boolean
} }
const TEST_TIMEOUT_MS = 15_000
// Drawing works through tool calls, so the test asks for one
const PING_TOOL = tool({
description: "Report that the connection works.",
inputSchema: z.object({}),
})
const NO_TOOL_CALL_WARNING =
"Connected, but the model answered without calling a tool. It may not support tool calls, which drawing needs."
export async function POST(req: Request) { export async function POST(req: Request) {
const crossSite = rejectCrossSite(req)
if (crossSite) return crossSite
// Lets the server send requests to arbitrary URLs, so require the access
// code, or the admin password (the admin panel's Test button)
const accessError = checkAccessCode(req)
if (accessError && checkAdminAuth(req)) return accessError
try { try {
const body: ValidateRequest = await req.json() const body: ValidateRequest = await req.json()
const { const {
provider, provider,
apiKey, apiKey,
baseUrl,
modelId, modelId,
awsAccessKeyId, awsAccessKeyId,
awsSecretAccessKey, awsSecretAccessKey,
awsRegion, awsRegion,
awsSessionToken,
// Note: Express Mode only needs vertexApiKey // Note: Express Mode only needs vertexApiKey
vertexApiKey, vertexApiKey,
} = body } = body
@@ -76,26 +54,9 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
// EdgeOne is this site's own function, as in the chat; the admin
// panel's Test sends no URL, and a relative one cannot be fetched
const baseUrl =
provider === "edgeone" ? edgeOneEndpoint(req) : body.baseUrl
// The admin panel's Test of an entry without a URL sends the
// server's own <P>_BASE_URL, which chat uses as it is: not a URL a
// user chose, so no private-address or redirect rules
const serverUrl =
body.serverBaseUrl === true &&
!!baseUrl &&
baseUrl === globalBaseUrl(provider) &&
!checkAdminAuth(req)
// SECURITY: Block SSRF attacks via custom baseUrl // SECURITY: Block SSRF attacks via custom baseUrl
if ( if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
baseUrl &&
!serverUrl &&
!allowPrivateUrls() &&
(await isPrivateUrl(baseUrl))
) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Invalid base URL" }, { valid: false, error: "Invalid base URL" },
{ status: 400 }, { status: 400 },
@@ -129,117 +90,367 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
) )
} }
// The Test button checks the user's own provider. On the server's
// keys (Ollama Cloud without a key or URL) anyone could run any model.
if (
usesServerCredentials(provider, {
apiKey,
baseUrl,
awsAccessKeyId,
awsSecretAccessKey,
vertexApiKey,
})
) {
return NextResponse.json(
{ valid: false, error: "API key is required" },
{ status: 400 },
)
}
// On the deployment's own endpoints a Test runs a model as a chat let model: any
// does, so with the quota on it counts as a chat request (an
// admin's Test of the server's URL does not)
const userId = getUserIdFromRequest(req)
if (
isQuotaEnabled() &&
!serverUrl &&
userId !== "anonymous" &&
(await usesServerEndpoint(
provider,
normalizeBaseUrl(body.baseUrl ?? ""),
apiKey,
))
) {
const quotaCheck = await checkAndIncrementRequest(userId, {
requests: Number(process.env.DAILY_REQUEST_LIMIT) || 10,
tokens: Number(process.env.DAILY_TOKEN_LIMIT) || 200000,
tpm: Number(process.env.TPM_LIMIT) || 20000,
})
if (!quotaCheck.allowed) {
return NextResponse.json(
{ valid: false, error: quotaCheck.error },
{ status: 429 },
)
}
}
// The same model the chat would use. A client base URL makes it switch (provider) {
// refuse redirects to internal hosts. case "openai": {
const { model } = getAIModel({ const openai = createOpenAI({
provider, apiKey,
modelId, ...(baseUrl && { baseURL: baseUrl }),
apiKey, })
baseUrl, model = openai.chat(modelId)
trustedBaseUrl: serverUrl,
awsAccessKeyId,
awsSecretAccessKey,
awsRegion,
// Temporary AWS credentials need it, as in the chat
awsSessionToken,
vertexApiKey,
// EdgeOne checks the Pages cookies and the access code
...(provider === "edgeone" && {
headers: {
cookie: req.headers.get("cookie") || "",
"x-access-code": req.headers.get("x-access-code") || "",
},
}),
})
// Streaming, like the chat (some models only stream). Stop at the
// first tool call; a reasoning model that runs out of tokens first
// proves the connection but not tool support.
const startTime = Date.now()
const result = streamText({
model,
prompt: "Call the ping tool.",
tools: { ping: PING_TOOL },
maxOutputTokens: 1024,
maxRetries: 0,
abortSignal: AbortSignal.timeout(TEST_TIMEOUT_MS),
})
let calledTool = false
let finishReason: string | undefined
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
// The timeout ends the stream with an abort part, not an error
if (part.type === "abort") {
const timeout = new Error(
`The model did not answer within ${TEST_TIMEOUT_MS / 1000} s.`,
)
timeout.name = "TimeoutError"
throw timeout
}
if (part.type === "tool-call") {
calledTool = true
break 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 "aihubmix": {
const defaultBaseURL = PROVIDER_INFO.aihubmix.defaultBaseUrl
if (
isAihubmixStandardBaseURL(baseUrl) ||
baseUrl === defaultBaseURL
) {
const aihubmix = createAihubmix({
apiKey,
appCode: AIHUBMIX_APP_CODE,
})
model = aihubmix(modelId)
} else {
const aihubmixCompatible = createOpenAI({
apiKey,
baseURL: baseUrl,
})
model = aihubmixCompatible.chat(modelId)
}
break
}
case "deepseek": {
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 const responseTime = Date.now() - startTime
return NextResponse.json({ return NextResponse.json({
valid: true, valid: true,
responseTime, responseTime,
...(!calledTool &&
finishReason !== "length" && { warning: NO_TOOL_CALL_WARNING }),
}) })
} catch (error) { } catch (error) {
console.error("[validate-model] Error:", 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( return NextResponse.json(
{ valid: false, code, error: message }, { valid: false, error: errorMessage },
{ status: 200 }, // Return 200 so client can read error message { 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) { 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( 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 }, { status: 401 },
) )
} }
+1 -7
View File
@@ -6,13 +6,7 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false
"includes": [
"**",
"!public",
"!packages/mcp-server/src/preview",
"!lib/model-catalog.json"
]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
+39 -70
View File
@@ -11,9 +11,7 @@ import {
} from "lucide-react" } from "lucide-react"
import type React from "react" import type React from "react"
import { import {
type Dispatch,
forwardRef, forwardRef,
type SetStateAction,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
@@ -43,20 +41,9 @@ import { FilePreviewList } from "./file-preview-list"
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5 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 { function isValidFileType(file: File): boolean {
return ( return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
isPdfFile(file) ||
isTextFile(file)
)
} }
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -177,7 +164,7 @@ interface ChatInputProps {
{ text: string; charCount: number; isExtracting: boolean } { text: string; charCount: number; isExtracting: boolean }
> >
urlData?: Map<string, UrlData> urlData?: Map<string, UrlData>
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>> onUrlChange?: (data: Map<string, UrlData>) => void
sessionId?: string sessionId?: string
error?: Error | null 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") // Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = const isDisabled =
(status === "streaming" || status === "submitted") && !error (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 adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current const textarea = textareaRef.current
@@ -299,9 +281,6 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
} }
const handleKeyDown = (e: React.KeyboardEvent) => { 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 = const shouldSend =
sendShortcut === "enter" sendShortcut === "enter"
? e.key === "Enter" && ? e.key === "Enter" &&
@@ -313,12 +292,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (shouldSend) { if (shouldSend) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget.closest("form") const form = e.currentTarget.closest("form")
if ( if (form && input.trim() && !isDisabled) {
form &&
input.trim() &&
!isDisabled &&
!isExtractingAttachments
) {
form.requestSubmit() form.requestSubmit()
} }
} }
@@ -406,9 +380,13 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
if (isDisabled) return 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( const { validFiles, errors } = validateFiles(
Array.from(e.dataTransfer.files), supportedFiles,
files.length, files.length,
dict, dict,
) )
@@ -423,34 +401,33 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
setIsExtractingUrl(true) setIsExtractingUrl(true)
// Use functional updates so a removal or send made while extracting
// is not overwritten when the request finishes
try { try {
onUrlChange((prev) => const existing = urlData
new Map(prev).set(url, { ? new Map(urlData)
url, : new Map<string, UrlData>()
title: url, existing.set(url, {
content: "", url,
charCount: 0, title: url,
isExtracting: true, content: "",
}), charCount: 0,
) isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url) const data = await extractUrlContent(url)
// Skip if the URL was removed while extracting const newUrlData = new Map(existing)
onUrlChange((prev) => newUrlData.set(url, data)
prev.has(url) ? new Map(prev).set(url, data) : prev, onUrlChange(newUrlData)
)
setShowUrlDialog(false) setShowUrlDialog(false)
} catch (error) { } catch (error) {
// Remove the URL from the data map on error // Remove the URL from the data map on error
onUrlChange((prev) => { const newUrlData = urlData
const next = new Map(prev) ? new Map(urlData)
next.delete(url) : new Map<string, UrlData>()
return next newUrlData.delete(url)
}) onUrlChange(newUrlData)
showErrorToast( showErrorToast(
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{error instanceof Error {error instanceof Error
@@ -486,12 +463,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
urlData={urlData} urlData={urlData}
onRemoveUrl={ onRemoveUrl={
onUrlChange onUrlChange
? (url) => ? (url) => {
onUrlChange((prev) => { const next = new Map(urlData)
const next = new Map(prev) next.delete(url)
next.delete(url) onUrlChange(next)
return next }
})
: undefined : undefined
} }
/> />
@@ -583,7 +559,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
ref={fileInputRef} ref={fileInputRef}
className="hidden" className="hidden"
onChange={handleFileChange} 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 multiple
disabled={isDisabled} disabled={isDisabled}
/> />
@@ -612,11 +588,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
) : ( ) : (
<Button <Button
type="submit" type="submit"
disabled={ disabled={isDisabled || !input.trim()}
isDisabled ||
isExtractingAttachments ||
!input.trim()
}
size="sm" size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm" className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={dict.chat.send} aria-label={dict.chat.send}
@@ -642,8 +614,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
dict.save.savedSuccessfully, dict.save.savedSuccessfully,
) )
} }
// Local date as YYYY-MM-DD (toISOString would give UTC) defaultFilename={`diagram-${new Date()
defaultFilename={`diagram-${new Date().toLocaleDateString("sv-SE")}`} .toISOString()
.slice(0, 10)}`}
/> />
{onUrlChange && ( {onUrlChange && (
<UrlInputDialog <UrlInputDialog
@@ -656,11 +629,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
<TemplateCreateDialog <TemplateCreateDialog
open={showSaveAsTemplate} open={showSaveAsTemplate}
onOpenChange={setShowSaveAsTemplate} onOpenChange={setShowSaveAsTemplate}
onSuccess={() => { onSuccess={() => setShowSaveAsTemplate(false)}
setShowSaveAsTemplate(false)
// Let the template list in the lobby reload
window.dispatchEvent(new Event("templatesChanged"))
}}
initialPrompt={input.trim()} initialPrompt={input.trim()}
/> />
</form> </form>
+224 -187
View File
@@ -26,7 +26,6 @@ import {
ReasoningContent, ReasoningContent,
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning" } from "@/components/ai-elements/reasoning"
import { Shimmer } from "@/components/ai-elements/shimmer"
import { ChatLobby } from "@/components/chat/ChatLobby" import { ChatLobby } from "@/components/chat/ChatLobby"
import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog" import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog"
import { ToolCallCard } from "@/components/chat/ToolCallCard" import { ToolCallCard } from "@/components/chat/ToolCallCard"
@@ -38,12 +37,12 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { import {
applyDiagramOperations,
convertToLegalXml, convertToLegalXml,
extractCompleteMxCells, extractCompleteMxCells,
replaceNodes, replaceNodes,
validateAndFixXml,
} from "@/lib/utils" } 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 // Helper to extract complete operations from streaming input
function getCompleteOperations( function getCompleteOperations(
@@ -130,14 +129,12 @@ const getMessageTextContent = (message: UIMessage): string => {
.join("\n") .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 // Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => { const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message) 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 { interface SessionMetadata {
@@ -149,8 +146,6 @@ interface SessionMetadata {
interface ChatMessageDisplayProps { interface ChatMessageDisplayProps {
messages: UIMessage[] messages: UIMessage[]
// Shown on an error that a model setting can fix (bad key, unknown model)
onOpenModelConfig?: () => void
setInput: (input: string) => void setInput: (input: string) => void
setFiles: (files: File[]) => void setFiles: (files: File[]) => void
processedToolCallsRef: MutableRefObject<Set<string>> processedToolCallsRef: MutableRefObject<Set<string>>
@@ -174,7 +169,6 @@ interface ChatMessageDisplayProps {
export function ChatMessageDisplay({ export function ChatMessageDisplay({
messages, messages,
onOpenModelConfig,
setInput, setInput,
setFiles, setFiles,
processedToolCallsRef, processedToolCallsRef,
@@ -194,24 +188,7 @@ export function ChatMessageDisplay({
currentInput = "", currentInput = "",
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
// The thinking header in the page language const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
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, chartXMLRef, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTopRef = useRef<HTMLDivElement>(null) const scrollTopRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("") const previousXML = useRef<string>("")
@@ -231,6 +208,20 @@ export function ChatMessageDisplay({
scrollTopRef.current?.scrollIntoView({ behavior: "instant" }) scrollTopRef.current?.scrollIntoView({ behavior: "instant" })
} }
}, [messages.length, processedToolCalls]) }, [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>>( 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( const handleDisplayChart = useCallback(
(xml: string) => { (xml: string, showToast = false) => {
const completeCells = extractCompleteMxCells(xml || "") let currentXml = xml || ""
if (!completeCells) return
const convertedXml = convertToLegalXml(completeCells)
if (convertedXml === previousXML.current) return
// Skip this update while the cells written so far don't parse // During streaming (showToast=false), extract only complete mxCell elements
const testDoc = new DOMParser().parseFromString( // This allows progressive rendering even with partial/incomplete trailing XML
`<root>${convertedXml}</root>`, if (!showToast) {
"text/xml", const completeCells = extractCompleteMxCells(currentXml)
) if (!completeCells) {
if (testDoc.querySelector("parsererror")) return return
}
currentXml = completeCells
}
try { const convertedXml = convertToLegalXml(currentXml)
// An empty canvas gets a default mxfile to put the cells in if (convertedXml !== previousXML.current) {
const baseXML = chartXML || BLANK_MXFILE // Parse and validate XML BEFORE calling replaceNodes
const replacedXML = replaceNodes(baseXML, convertedXml) const parser = new DOMParser()
previousXML.current = convertedXml // Wrap in root element for parsing multiple mxCell elements
onDisplayChart(replacedXML, true) const testDoc = parser.parseFromString(
} catch (error) { `<root>${convertedXml}</root>`,
console.error("Error processing XML:", error) "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], [chartXML, onDisplayChart],
@@ -422,19 +456,8 @@ export function ChatMessageDisplay({
// Previous messages are already processed and won't change // Previous messages are already processed and won't change
const messagesToProcess = const messagesToProcess =
messages.length > 0 ? [messages[messages.length - 1]] : [] messages.length > 0 ? [messages[messages.length - 1]] : []
// The diagram without streamed previews, as loaded last: the tool
// handler's result of an earlier edit is there before the chartXML
// state catches up. Undoing a failed edit's preview below changes it
// too, and an edit streaming right after must start from the undone
// diagram.
let baseXml = chartXMLRef.current
messagesToProcess.forEach((message) => { 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) { if (message.parts) {
message.parts.forEach((part) => { message.parts.forEach((part) => {
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
@@ -452,87 +475,76 @@ export function ChatMessageDisplay({
}) })
} }
if (isRestoredMessage) return
if ( if (
part.type !== "tool-display_diagram" && part.type === "tool-display_diagram" &&
part.type !== "tool-edit_diagram" input?.xml
) { ) {
return const xml = input.xml as string
}
// Failed or stopped: if the original XML is still
// stored, the tool handler never ran (invalid JSON,
// or the user pressed stop), so undo the streamed
// preview here. Invalid JSON leaves no input, so
// check this first.
if (state === "output-error") {
const originalXml =
editDiagramOriginalXmlRef.current.get(
toolCallId,
)
if (originalXml) {
editDiagramOriginalXmlRef.current.delete(
toolCallId,
)
onDisplayChart(originalXml, true)
baseXml = originalXml
}
return
}
// Input complete, or the tool handler, a stop or an
// error took the call already: the tool handler loads
// the checked diagram (with the original XML). The
// messages update at most every 150 ms (useChat
// throttle in chat-panel), so they can still show the
// call streaming after that.
if (
state !== "input-streaming" ||
processedToolCalls.current.has(toolCallId)
) {
processedToolCalls.current.add(toolCallId)
lastProcessedXmlRef.current.delete(toolCallId)
lastProcessedXmlRef.current.delete(
`${toolCallId}-opCount`,
)
return
}
if (part.type === "tool-display_diagram") {
const xml = input?.xml as string | undefined
// Skip if XML hasn't changed since last processing // Skip if XML hasn't changed since last processing
if ( const lastXml =
!xml || lastProcessedXmlRef.current.get(toolCallId)
lastProcessedXmlRef.current.get(toolCallId) === if (lastXml === xml) {
xml return // Skip redundant processing
) {
return
} }
// Keep the diagram from before the preview, to
// undo it on a stop or an error
if ( if (
!editDiagramOriginalXmlRef.current.has( state === "input-streaming" ||
toolCallId, state === "input-available"
)
) { ) {
editDiagramOriginalXmlRef.current.set( // Debounce streaming updates - queue the XML and process after delay
toolCallId, pendingXmlRef.current = xml
baseXml || BLANK_MXFILE,
) 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)
) {
// 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)
} }
handleDisplayChart(xml)
lastProcessedXmlRef.current.set(toolCallId, xml)
return
} }
// Handle edit_diagram streaming - apply operations incrementally for preview // Handle edit_diagram streaming - apply operations incrementally for preview
// Uses shared editDiagramOriginalXmlRef to coordinate with tool handler // Uses shared editDiagramOriginalXmlRef to coordinate with tool handler
if (part.type === "tool-edit_diagram") { if (
if (!input?.operations) return part.type === "tool-edit_diagram" &&
input?.operations
) {
const completeOps = getCompleteOperations( const completeOps = getCompleteOperations(
input.operations as DiagramOperation[], input.operations as DiagramOperation[],
) )
if (completeOps.length === 0) return if (completeOps.length === 0) return
// Capture original XML when streaming starts (store in shared ref) // Capture original XML when streaming starts (store in shared ref)
@@ -541,7 +553,7 @@ export function ChatMessageDisplay({
toolCallId, toolCallId,
) )
) { ) {
if (!baseXml) { if (!chartXML) {
console.warn( console.warn(
"[edit_diagram streaming] No chart XML available", "[edit_diagram streaming] No chart XML available",
) )
@@ -549,9 +561,10 @@ export function ChatMessageDisplay({
} }
editDiagramOriginalXmlRef.current.set( editDiagramOriginalXmlRef.current.set(
toolCallId, toolCallId,
baseXml, chartXML,
) )
} }
const originalXml = const originalXml =
editDiagramOriginalXmlRef.current.get( editDiagramOriginalXmlRef.current.get(
toolCallId, toolCallId,
@@ -559,37 +572,95 @@ export function ChatMessageDisplay({
if (!originalXml) return if (!originalXml) return
// Skip if no change from last processed state // Skip if no change from last processed state
const countKey = `${toolCallId}-opCount` const lastCount = lastProcessedXmlRef.current.get(
const opCount = String(completeOps.length) toolCallId + "-opCount",
)
if (lastCount === String(completeOps.length)) return
if ( if (
lastProcessedXmlRef.current.get(countKey) === state === "input-streaming" ||
opCount state === "input-available"
) { ) {
return // Queue the operations for debounced processing
} pendingEditRef.current = {
try { operations: completeOps,
const { result } = applyDiagramOperations( toolCallId,
originalXml, }
completeOps,
) if (!editDebounceTimeoutRef.current) {
// Load the full document so other pages stay intact editDebounceTimeoutRef.current = setTimeout(
onDisplayChart(result, true) () => {
lastProcessedXmlRef.current.set( const pending =
countKey, pendingEditRef.current
opCount, editDebounceTimeoutRef.current =
) null
} catch (e) { pendingEditRef.current = null
console.warn(
"[edit_diagram streaming] Operation failed:", if (pending) {
e instanceof Error ? e.message : e, 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
} }
} }
} }
}) })
} }
}) })
}, [messages, handleDisplayChart, chartXMLRef])
// 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 ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
@@ -774,11 +845,7 @@ export function ChatMessageDisplay({
!isRestoredMessage !isRestoredMessage
} }
> >
<ReasoningTrigger <ReasoningTrigger />
getThinkingMessage={
thinkingMessage
}
/>
<ReasoningContent> <ReasoningContent>
{ {
reasoningPart.text reasoningPart.text
@@ -884,12 +951,8 @@ export function ChatMessageDisplay({
part.type?.startsWith( part.type?.startsWith(
"tool-", "tool-",
) )
// Blank text (some models send
// a lone space) gets no bubble
const isContentPart = const isContentPart =
(part.type === "text" && part.type === "text" ||
part.text.trim() !==
"") ||
part.type === "file" part.type === "file"
if (isToolPart) { if (isToolPart) {
@@ -1239,32 +1302,6 @@ export function ChatMessageDisplay({
return null 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> </div>
) )
}, },
+204 -377
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useChat } from "@ai-sdk/react" import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport, isToolUIPart, type UIMessage } from "ai" import { DefaultChatTransport } from "ai"
import { import {
MessageSquarePlus, MessageSquarePlus,
PanelRightClose, PanelRightClose,
@@ -41,13 +41,8 @@ import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils" 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 type { ValidationState } from "./chat/ValidationCard"
import { import { ChatMessageDisplay } from "./chat-message-display"
APPENDED_FILE_SECTIONS_PATTERN,
ChatMessageDisplay,
} from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator" import { DevXmlSimulator } from "./dev-xml-simulator"
// localStorage keys for persistence // localStorage keys for persistence
@@ -56,6 +51,21 @@ const STORAGE_SESSION_ID_KEY = "next-ai-draw-io-session-id"
// sessionStorage keys // sessionStorage keys
const SESSION_STORAGE_INPUT_KEY = "next-ai-draw-io-input" 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 { interface ChatPanelProps {
isVisible: boolean isVisible: boolean
onToggleVisibility: () => void onToggleVisibility: () => void
@@ -78,23 +88,23 @@ const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retr
* Check if auto-resubmit should happen based on tool errors. * Check if auto-resubmit should happen based on tool errors.
* Only checks the LAST tool part (most recent tool call), not all tool parts. * 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] const lastMessage = messages[messages.length - 1]
if (lastMessage?.role !== "assistant") return false if (!lastMessage || lastMessage.role !== "assistant") {
const lastToolPart = lastMessage.parts.filter(isToolUIPart).at(-1) return false
return lastToolPart?.state === TOOL_ERROR_STATE }
}
/** const toolParts =
* Snapshots keep the full multi-page document, but the model only sees and (lastMessage.parts as MessagePart[] | undefined)?.filter((part) =>
* edits the first page, so give it the first page's mxGraphModel. part.type?.startsWith("tool-"),
* Older snapshots already hold a single mxGraphModel and are returned as is. ) || []
*/
function getFirstPageXml(xml: string): string { if (toolParts.length === 0) {
if (!xml.includes("<mxfile")) return xml return false
const doc = new DOMParser().parseFromString(xml, "text/xml") }
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml const lastToolPart = toolParts[toolParts.length - 1]
return lastToolPart?.state === TOOL_ERROR_STATE
} }
export default function ChatPanel({ export default function ChatPanel({
@@ -110,9 +120,8 @@ export default function ChatPanel({
loadDiagram: onDisplayChart, loadDiagram: onDisplayChart,
handleExport: onExport, handleExport: onExport,
handleExportWithoutHistory, handleExportWithoutHistory,
exportResolversRef, resolverRef,
chartXML, chartXML,
chartXMLRef: liveChartXMLRef,
latestSvg, latestSvg,
clearDiagram, clearDiagram,
getThumbnailSvg, getThumbnailSvg,
@@ -128,15 +137,21 @@ export default function ChatPanel({
const urlSessionId = searchParams.get("session") const urlSessionId = searchParams.get("session")
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
// Waits for the reply to its own export, by its tag
const tag = saveToHistory ? onExport() : handleExportWithoutHistory()
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
if (tag) exportResolversRef.current[tag] = resolve resolverRef.current = resolve
if (saveToHistory) {
onExport()
} else {
handleExportWithoutHistory()
}
}), }),
new Promise<string>((_, reject) => { new Promise<string>((_, reject) => {
const currentResolver = resolverRef.current
setTimeout(() => { setTimeout(() => {
delete exportResolversRef.current[tag] if (resolverRef.current === currentResolver) {
resolverRef.current = null
}
reject(new Error("Chart export timed out after 10 seconds")) reject(new Error("Chart export timed out after 10 seconds"))
}, 10000) }, 10000)
}), }),
@@ -163,7 +178,6 @@ export default function ChatPanel({
const [minimalStyle, setMinimalStyle] = useState(false) const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false) const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [customSystemMessage, setCustomSystemMessage] = useState("") const [customSystemMessage, setCustomSystemMessage] = useState("")
const [maxOutputTokens, setMaxOutputTokens] = useState("")
const [shouldFocusInput, setShouldFocusInput] = useState(false) const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change) // Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -190,14 +204,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 // Check config on mount
useEffect(() => { useEffect(() => {
fetch(getApiEndpoint("/api/config")) fetch(getApiEndpoint("/api/config"))
@@ -275,16 +281,8 @@ export default function ChatPanel({
// Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs // Persist processed tool call IDs so collapsing the chat doesn't replay old tool outputs
const processedToolCallsRef = useRef<Set<string>>(new Set()) const processedToolCallsRef = useRef<Set<string>>(new Set())
// Set by Stop until the user sends the next message // Store original XML for edit_diagram streaming - shared between streaming preview and tool handler
const stoppedRef = useRef(false) // Key: toolCallId, Value: original XML before any operations applied
const preparingSendRef = useRef(false)
// Presses of Stop: a check that began before one still knows of it after
// the next message clears stoppedRef
const stopCountRef = useRef(0)
// Store original XML for display_diagram and edit_diagram streaming -
// shared between streaming preview and tool handler
// Key: toolCallId, Value: XML before the call's preview was drawn
const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map()) const editDiagramOriginalXmlRef = useRef<Map<string, string>>(new Map())
// Debounce timeout for localStorage writes (prevents blocking during streaming) // Debounce timeout for localStorage writes (prevents blocking during streaming)
@@ -322,28 +320,28 @@ export default function ChatPanel({
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value) localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
}, []) }, [])
// Handler for output token budget change (empty string = use server default) // Ref to store the sendMessage function for use in callbacks
const handleMaxOutputTokensChange = useCallback((value: string) => { const sendMessageRef = useRef<typeof sendMessage | null>(null)
const digitsOnly = value.replace(/\D/g, "")
setMaxOutputTokens(digitsOnly) // Callback to improve diagram with validation suggestions
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly) 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 }],
})
}
}, []) }, [])
// Failed VLM validations in the current user turn (reset on user action)
const validationRetryCountRef = useRef(0)
// VLM validation hook using AI SDK's useObject // VLM validation hook using AI SDK's useObject
const { validateWithFallback, cancel: cancelValidation } = const { validateWithFallback } = useValidateDiagram()
useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram) // Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({ const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef, partialXmlRef,
editDiagramOriginalXmlRef, editDiagramOriginalXmlRef,
processedToolCallsRef, chartXMLRef,
validationRetryCountRef,
// A preview undone just before the tool call is in this one already
chartXMLRef: liveChartXMLRef,
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
onExport, onExport,
@@ -351,11 +349,6 @@ export default function ChatPanel({
validateDiagram: validateWithFallback, validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled, enableVlmValidation: vlmValidationEnabled,
sessionId, sessionId,
watchStop: () => {
const stopsBefore = stopCountRef.current
return () =>
stoppedRef.current || stopCountRef.current !== stopsBefore
},
onValidationStateChange: handleValidationStateChange, onValidationStateChange: handleValidationStateChange,
}) })
@@ -374,104 +367,96 @@ export default function ChatPanel({
onToolCall: async ({ toolCall }) => { onToolCall: async ({ toolCall }) => {
await handleToolCall({ toolCall }, addToolOutput) await handleToolCall({ toolCall }, addToolOutput)
}, },
onFinish: ({ message, isAbort, isError }) => {
// Stopped or failed: tool calls still streaming never reach the
// tool handler. Mark them handled so a later render of the
// stream does not draw their preview again.
if (!isAbort && !isError) return
for (const part of message.parts as any[]) {
if (part.state === "input-streaming" && part.toolCallId) {
processedToolCallsRef.current.add(part.toolCallId)
}
}
},
onError: (error) => { onError: (error) => {
// A diagram still streaming when the request failed never // Handle server-side quota limit (429 response)
// reaches the tool handler: undo its preview. Only previews not // AI SDK puts the full response body in error.message for non-OK responses
// handled yet are stored, and the first one holds 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
try { 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 { } 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 return
} }
if (data?.type === "token") { if (error.message.includes("Daily token limit")) {
quotaManager.showTokenLimitToast(data.used, data.limit) quotaManager.showTokenLimitToast()
return return
} }
if (data?.type === "tpm") { if (
quotaManager.showTPMLimitToast(data.limit) error.message.includes("Rate limit exceeded") ||
error.message.includes("tokens per minute")
) {
quotaManager.showTPMLimitToast()
return 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 // 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 // Translate technical errors into user-friendly messages
let text: string = error.message // The server now handles detailed error messages, so we can display them directly.
let openModelConfig = false // But we still handle connection/network errors that happen before reaching the server.
if (data?.type === "provider") { let friendlyMessage = error.message
const hints = dict.errors.llm as Record<string, string>
text = hints[data.code] // Simple check for network errors if message is generic
? `${hints[data.code]}\n\n${data.message}` if (friendlyMessage === "Failed to fetch") {
: data.message friendlyMessage = "Network error. Please check your connection."
openModelConfig = [ }
"invalid_api_key",
"forbidden", // Truncated tool input error (model output limit too low)
"model_not_found", if (friendlyMessage.includes("toolUse.input is invalid")) {
].includes(data.code) friendlyMessage =
} else if (typeof data?.error === "string") { "Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
text = data.error }
} else if (error.message === "Failed to fetch") {
text = dict.errors.networkError // 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 // Add system message for error so it can be cleared
setMessages((currentMessages) => [ setMessages((currentMessages) => {
...currentMessages, const errorMessage = {
{
id: `error-${Date.now()}`, id: `error-${Date.now()}`,
role: "system" as const, role: "system" as const,
content: text, content: friendlyMessage,
parts: [{ type: "text" as const, text }], parts: [{ type: "text" as const, text: friendlyMessage }],
// The message shows a button that opens model settings }
...(openModelConfig && { return [...currentMessages, errorMessage]
metadata: { openModelConfig: true }, })
}),
},
])
if (isAccessCodeError) { if (error.message.includes("Invalid or missing access code")) {
// Show settings dialog to help user fix it // Show settings dialog to help user fix it
setShowSettingsDialog(true) setShowSettingsDialog(true)
} }
}, },
// Re-render streamed messages at most every 150 ms. The streaming onFinish: () => {},
// diagram preview draws on each update, so this also limits redraws
experimental_throttle: 150,
sendAutomaticallyWhen: ({ messages }) => { sendAutomaticallyWhen: ({ messages }) => {
// The user stopped: a tool result that arrives later (a VLM
// check still running) must not start a new request
if (stoppedRef.current) return false
const isInContinuationMode = partialXmlRef.current.length > 0 const isInContinuationMode = partialXmlRef.current.length > 0
const shouldRetry = hasToolErrors(messages) const shouldRetry = hasToolErrors(
messages as unknown as ChatMessage[],
)
if (!shouldRetry) { if (!shouldRetry) {
// No error, reset retry count and clear state // No error, reset retry count and clear state
@@ -517,6 +502,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 // Ref to track latest messages for unload persistence
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
useEffect(() => { useEffect(() => {
@@ -525,10 +515,6 @@ export default function ChatPanel({
// Track last synced session ID to detect external changes (e.g., URL back/forward) // Track last synced session ID to detect external changes (e.g., URL back/forward)
const lastSyncedSessionIdRef = useRef<string | null>(null) const lastSyncedSessionIdRef = useRef<string | null>(null)
// Message arrays of our own saves. A session holding one of them was
// created by our own save, so it must not be treated as an external
// switch (with two saves of a new chat at once, the first creates it).
const savedMessagesRef = useRef(new WeakSet<object>())
// Helper: Sync UI state with session data (eliminates duplication) // Helper: Sync UI state with session data (eliminates duplication)
// Track message IDs that are being loaded from session (to skip animations/scroll) // Track message IDs that are being loaded from session (to skip animations/scroll)
@@ -595,10 +581,8 @@ export default function ChatPanel({
thumbnailDataUrl = latestSvgRef.current thumbnailDataUrl = latestSvgRef.current
} }
} }
const messages = sanitizeMessages(messagesRef.current)
savedMessagesRef.current.add(messages)
return { return {
messages, messages: sanitizeMessages(messagesRef.current),
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()), xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
diagramXml: currentDiagramXml, diagramXml: currentDiagramXml,
thumbnailDataUrl, thumbnailDataUrl,
@@ -651,13 +635,8 @@ export default function ChatPanel({
// Skip if session ID hasn't changed (our own saves don't change the ID) // Skip if session ID hasn't changed (our own saves don't change the ID)
if (newSessionId === lastSyncedSessionIdRef.current) return if (newSessionId === lastSyncedSessionIdRef.current) return
// Our own save created this session; the UI already shows its content
const isOwnNewSession =
!!newSession && savedMessagesRef.current.has(newSession.messages)
// Update last synced ID // Update last synced ID
lastSyncedSessionIdRef.current = newSessionId lastSyncedSessionIdRef.current = newSessionId
if (isOwnNewSession) return
// Sync UI with new session // Sync UI with new session
if (newSession) { if (newSession) {
@@ -680,8 +659,6 @@ export default function ChatPanel({
isAvailable: sessionIsAvailable, isAvailable: sessionIsAvailable,
currentSessionId, currentSessionId,
saveCurrentSession, saveCurrentSession,
getChatGeneration,
getSaveTicket,
} = sessionManager } = sessionManager
// Use ref for saveCurrentSession to avoid infinite loop // Use ref for saveCurrentSession to avoid infinite loop
@@ -706,30 +683,26 @@ export default function ChatPanel({
clearTimeout(localStorageDebounceRef.current) clearTimeout(localStorageDebounceRef.current)
} }
// Capture the chat on screen at schedule time; the save is dropped // Capture current session ID at schedule time to verify at save time
// if another chat is on screen by the time it runs const scheduledForSessionId = currentSessionId
const scheduledForChat = getChatGeneration()
// Capture whether there's a REAL diagram NOW (not just empty template) // Capture whether there's a REAL diagram NOW (not just empty template)
const hasDiagramNow = isRealDiagram(chartXMLRef.current) const hasDiagramNow = isRealDiagram(chartXMLRef.current)
// Check if this session was just loaded without a diagram // Check if this session was just loaded without a diagram
const isNodiagramSession = const isNodiagramSession =
justLoadedSessionIdRef.current === currentSessionId justLoadedSessionIdRef.current === scheduledForSessionId
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => { localStorageDebounceRef.current = setTimeout(async () => {
try { try {
if (messages.length > 0 || hasDiagramNow) { if (messages.length > 0 || hasDiagramNow) {
// Taken before the data is read, for the chat it was
// scheduled for
const ticket = {
...getSaveTicket(),
generation: scheduledForChat,
}
const sessionData = await buildSessionData({ const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession, withThumbnail: hasDiagramNow && !isNodiagramSession,
}) })
await saveCurrentSessionRef.current(sessionData, ticket) await saveCurrentSessionRef.current(
sessionData,
scheduledForSessionId,
)
} }
} catch (error) { } catch (error) {
console.error("Failed to save session:", error) console.error("Failed to save session:", error)
@@ -748,8 +721,6 @@ export default function ChatPanel({
status, status,
sessionIsAvailable, sessionIsAvailable,
currentSessionId, currentSessionId,
getChatGeneration,
getSaveTicket,
buildSessionData, buildSessionData,
]) ])
@@ -782,11 +753,10 @@ export default function ChatPanel({
try { try {
// Attempt to save session - browser may not wait for completion // Attempt to save session - browser may not wait for completion
// Skip thumbnail capture as it may not complete in time // Skip thumbnail capture as it may not complete in time
const ticket = sessionManager.getSaveTicket()
const sessionData = await buildSessionData({ const sessionData = await buildSessionData({
withThumbnail: false, withThumbnail: false,
}) })
await sessionManager.saveCurrentSession(sessionData, ticket) await sessionManager.saveCurrentSession(sessionData)
} catch (error) { } catch (error) {
console.error( console.error(
"Failed to save session on visibility change:", "Failed to save session on visibility change:",
@@ -804,27 +774,19 @@ export default function ChatPanel({
) )
}, [sessionManager, buildSessionData]) }, [sessionManager, buildSessionData])
const submitInput = async () => { const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted" const isProcessing = status === "streaming" || status === "submitted"
// Attachments still extracting have no text yet. Template sends call if (input.trim() && !isProcessing) {
// requestSubmit() and skip the disabled send button, so check here too. // Check if input matches a cached example (only when no messages yet)
const isExtracting = if (messages.length === 0) {
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
const cached = findCachedResponse( const cached = findCachedResponse(
input.trim(), input.trim(),
files.length > 0, files.length > 0,
files.length === 1 ? files[0].name : undefined,
) )
if (cached) { if (cached) {
// Add the user message and a finished display_diagram // Add user message and fake assistant response to messages
// answer, and load its diagram here: these messages never // The chat-message-display useEffect will handle displaying the diagram
// reach the tool handler
const toolCallId = `cached-${Date.now()}` const toolCallId = `cached-${Date.now()}`
// Build user message text including any file content // Build user message text including any file content
@@ -856,16 +818,6 @@ export default function ChatPanel({
], ],
}, },
] as any) ] 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("") setInput("")
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
setFiles([]) setFiles([])
@@ -875,6 +827,13 @@ export default function ChatPanel({
} }
try { 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 // Build user text by concatenating input with pre-extracted text
// (Backend only reads first text part, so we must combine them) // (Backend only reads first text part, so we must combine them)
const parts: any[] = [] const parts: any[] = []
@@ -889,102 +848,48 @@ export default function ChatPanel({
// Add the combined text as the first part // Add the combined text as the first part
parts.unshift({ type: "text", text: userText }) parts.unshift({ type: "text", text: userText })
await sendWithCurrentDiagram(parts, () => { // Get previous XML from the last snapshot (before this message)
setInput("") const snapshotKeys = Array.from(
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY) xmlSnapshotsRef.current.keys(),
setFiles([]) ).sort((a, b) => b - a)
setUrlData(new Map()) 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) { } catch (error) {
console.error("Error fetching chart data:", error) console.error("Error fetching chart data:", error)
toast.error(dict.errors.failedToExport)
} }
} }
} }
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// While a send is prepared (attachments read, diagram exported) the
// status is still "ready": a second Enter or click would send the
// message again
if (preparingSendRef.current) return
preparingSendRef.current = true
try {
await submitInput()
} finally {
preparingSendRef.current = false
}
}
// Export the current diagram, snapshot it for this message, and send.
// onSent runs right after sending, so the input empties as the message
// shows in the chat
const sendWithCurrentDiagram = async (
parts: any[],
onSent?: () => void,
) => {
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)
onSent?.()
}
// 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)
}
}
// The current chat could not be saved (storage full). The list where
// old chats can be deleted shows only in an empty chat, so let the user
// go on without saving. It replaces the plain message, and has its own
// id so a later failed auto-save does not take its button away.
const offerToContinueUnsaved = useCallback(
(proceed: () => void) => {
toast.dismiss("session-save-failed")
toast.error(dict.errors.sessionSaveFailedLeave, {
id: "session-save-leave",
duration: 15000,
action: {
label: dict.errors.continueWithoutSaving,
onClick: proceed,
},
})
},
[dict],
)
// A new turn makes the offer stale: going on would clear the chat while
// the answer streams in
useEffect(() => {
if (status === "submitted" || status === "streaming") {
toast.dismiss("session-save-leave")
}
}, [status])
// Handle session switching from history dropdown // Handle session switching from history dropdown
const handleSelectSession = useCallback( const handleSelectSession = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
if (!sessionManager.isAvailable) return if (!sessionManager.isAvailable) return
// Save current session before switching
if (messages.length > 0) {
const sessionData = await buildSessionData({
withThumbnail: true,
})
await sessionManager.saveCurrentSession(sessionData)
}
// Switch to selected session // Switch to selected session
const open = async () => { const sessionData = await sessionManager.switchSession(sessionId)
const sessionData = if (sessionData) {
await sessionManager.switchSession(sessionId)
if (!sessionData) return
const hasRealDiagram = isRealDiagram(sessionData.diagramXml) const hasRealDiagram = isRealDiagram(sessionData.diagramXml)
justLoadedSessionRef.current = true justLoadedSessionRef.current = true
@@ -1002,37 +907,8 @@ export default function ChatPanel({
syncUIWithSession(sessionData) syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false }) router.replace(`?session=${sessionId}`, { scroll: false })
} }
// Save current session before switching (also a diagram drawn
// without messages); if that failed (storage full), stay on it
// unless the user goes on without saving it
if (messages.length > 0 || isRealDiagram(chartXMLRef.current)) {
// Of the chat on screen now, also if another one comes on
// screen while the thumbnail is taken
const ticket = sessionManager.getSaveTicket()
const sessionData = await buildSessionData({
withThumbnail: true,
})
if (
!(await sessionManager.saveCurrentSession(
sessionData,
ticket,
))
) {
offerToContinueUnsaved(open)
return
}
}
await open()
}, },
[ [sessionManager, messages, buildSessionData, syncUIWithSession, router],
sessionManager,
messages,
buildSessionData,
syncUIWithSession,
router,
offerToContinueUnsaved,
],
) )
// Handle session deletion from history dropdown // Handle session deletion from history dropdown
@@ -1050,7 +926,15 @@ export default function ChatPanel({
[sessionManager, syncUIWithSession, router, pathname], [sessionManager, syncUIWithSession, router, pathname],
) )
const startNewChat = useCallback(() => { const handleNewChat = useCallback(async () => {
// Save current session before creating new one
if (sessionManager.isAvailable && messages.length > 0) {
const sessionData = await buildSessionData({ withThumbnail: true })
await sessionManager.saveCurrentSession(sessionData)
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
// Clear session manager state BEFORE clearing URL to prevent race condition // Clear session manager state BEFORE clearing URL to prevent race condition
// (otherwise the URL update effect would restore the old session URL) // (otherwise the URL update effect would restore the old session URL)
sessionManager.clearCurrentSession() sessionManager.clearCurrentSession()
@@ -1082,47 +966,21 @@ export default function ChatPanel({
setMessages, setMessages,
setSessionId, setSessionId,
sessionManager, sessionManager,
messages,
router, router,
dict.dialogs.clearSuccess, dict.dialogs.clearSuccess,
buildSessionData,
setDiagramHistory, setDiagramHistory,
pathname, pathname,
]) ])
const handleNewChat = useCallback(async () => {
// Save current session before creating new one (also a diagram
// drawn without messages)
if (
sessionManager.isAvailable &&
(messages.length > 0 || isRealDiagram(chartXMLRef.current))
) {
const ticket = sessionManager.getSaveTicket()
const sessionData = await buildSessionData({ withThumbnail: true })
// Not saved (storage full): keep the chat on screen, unless the
// user goes on without saving it
if (
!(await sessionManager.saveCurrentSession(sessionData, ticket))
) {
offerToContinueUnsaved(startNewChat)
return
}
// Refresh sessions list to ensure dropdown shows the saved session
await sessionManager.refreshSessions()
}
startNewChat()
}, [
sessionManager,
messages,
buildSessionData,
offerToContinueUnsaved,
startNewChat,
])
// Handle sending a template directly (called from TemplatePanel) // Handle sending a template directly (called from TemplatePanel)
const handleSendTemplate = useCallback( const handleSendTemplate = useCallback(
async (template: { prompt: string }) => { async (template: { prompt: string }) => {
// Keep attachments: they are sent along with the template prompt
flushSync(() => { flushSync(() => {
setInput(template.prompt) setInput(template.prompt)
setFiles([])
setUrlData(new Map())
}) })
const formElement = document.getElementById( const formElement = document.getElementById(
@@ -1132,7 +990,7 @@ export default function ChatPanel({
formElement.requestSubmit() formElement.requestSubmit()
} }
}, },
[setInput], [setInput, setFiles, setUrlData],
) )
const handleInputChange = ( const handleInputChange = (
@@ -1147,15 +1005,13 @@ export default function ChatPanel({
} }
// Helper functions for message actions (regenerate/edit) // 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 getPreviousXml = (beforeIndex: number): string => {
const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys()) const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys())
.filter((k) => k < beforeIndex) .filter((k) => k < beforeIndex)
.sort((a, b) => b - a) .sort((a, b) => b - a)
return snapshotKeys.length > 0 return snapshotKeys.length > 0
? getFirstPageXml( ? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
)
: "" : ""
} }
@@ -1176,19 +1032,11 @@ export default function ChatPanel({
// Handle stop button click // Handle stop button click
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
stoppedRef.current = true
stopCountRef.current++
// A running screenshot check holds up the chat (the SDK waits for
// the tool handler): end it, so the call gets its result now
cancelValidation()
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1]
// Calls the tool handler already took can still show as streaming:
// the messages update at most every 150 ms (useChat throttle)
const toolParts = lastMessage?.parts?.filter( const toolParts = lastMessage?.parts?.filter(
(part: any) => (part: any) =>
part.type?.startsWith("tool-") && part.type?.startsWith("tool-") &&
part.state === "input-streaming" && part.state === "input-streaming",
!processedToolCallsRef.current.has(part.toolCallId),
) )
toolParts?.forEach((part: any) => { toolParts?.forEach((part: any) => {
@@ -1203,7 +1051,7 @@ export default function ChatPanel({
}) })
stop() stop()
}, [messages, addToolOutput, stop, cancelValidation]) }, [messages, addToolOutput, stop])
// Send chat message with headers // Send chat message with headers
const sendChatMessage = ( const sendChatMessage = (
@@ -1215,9 +1063,7 @@ export default function ChatPanel({
// Reset all retry/continuation state on user-initiated message // Reset all retry/continuation state on user-initiated message
autoRetryCountRef.current = 0 autoRetryCountRef.current = 0
continuationRetryCountRef.current = 0 continuationRetryCountRef.current = 0
validationRetryCountRef.current = 0
partialXmlRef.current = "" partialXmlRef.current = ""
stoppedRef.current = false
const config = getSelectedAIConfig() const config = getSelectedAIConfig()
@@ -1262,9 +1108,6 @@ export default function ChatPanel({
...(minimalStyle && { ...(minimalStyle && {
"x-minimal-style": "true", "x-minimal-style": "true",
}), }),
...(maxOutputTokens && {
"x-max-output-tokens": maxOutputTokens,
}),
}, },
}, },
) )
@@ -1365,12 +1208,7 @@ export default function ChatPanel({
}) })
// Now send the message after state is guaranteed to be updated // Now send the message after state is guaranteed to be updated
sendChatMessage( sendChatMessage(userParts, savedXml, previousXml, sessionId)
userParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
} }
const handleEditMessage = async (messageIndex: number, newText: string) => { const handleEditMessage = async (messageIndex: number, newText: string) => {
@@ -1397,13 +1235,10 @@ export default function ChatPanel({
// Clean up snapshots for messages after the user message (they will be removed) // Clean up snapshots for messages after the user message (they will be removed)
cleanupSnapshotsAfter(messageIndex) cleanupSnapshotsAfter(messageIndex)
// Create new parts with updated text. The edit box only shows the typed // Create new parts with updated text
// text, so keep the appended PDF/file/URL content
const newParts = message.parts?.map((part: any) => { const newParts = message.parts?.map((part: any) => {
if (part.type === "text") { if (part.type === "text") {
const appended = return { ...part, text: newText }
part.text.match(APPENDED_FILE_SECTIONS_PATTERN)?.[0] ?? ""
return { ...part, text: newText + appended }
} }
return part return part
}) || [{ type: "text", text: newText }] }) || [{ type: "text", text: newText }]
@@ -1416,12 +1251,7 @@ export default function ChatPanel({
}) })
// Now send the edited message after state is guaranteed to be updated // Now send the edited message after state is guaranteed to be updated
sendChatMessage( sendChatMessage(newParts, savedXml, previousXml, sessionId)
newParts,
getFirstPageXml(savedXml),
previousXml,
sessionId,
)
} }
// Collapsed view (desktop only) // Collapsed view (desktop only)
@@ -1550,7 +1380,6 @@ export default function ChatPanel({
{/* Messages */} {/* Messages */}
<main className="flex-1 w-full overflow-hidden"> <main className="flex-1 w-full overflow-hidden">
<ChatMessageDisplay <ChatMessageDisplay
onOpenModelConfig={() => setShowModelConfigDialog(true)}
messages={messages} messages={messages}
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
@@ -1623,8 +1452,6 @@ export default function ChatPanel({
onVlmValidationChange={handleVlmValidationChange} onVlmValidationChange={handleVlmValidationChange}
customSystemMessage={customSystemMessage} customSystemMessage={customSystemMessage}
onCustomSystemMessageChange={handleCustomSystemMessageChange} onCustomSystemMessageChange={handleCustomSystemMessageChange}
maxOutputTokens={maxOutputTokens}
onMaxOutputTokensChange={handleMaxOutputTokensChange}
onOpenModelConfig={() => setShowModelConfigDialog(true)} 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" 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)} onClick={() => onSelectSession(session.id)}
onKeyDown={(e) => { onKeyDown={(e) => {
// Ignore keys bubbling up from the delete button
if (e.target !== e.currentTarget) return
if ( if (
e.key === "Enter" || e.key === "Enter" ||
e.key === " " e.key === " "
-3
View File
@@ -55,9 +55,6 @@ export function TemplateCreateDialog({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// React submit events bubble through the portal; keep them away from
// the chat form this dialog may be rendered in
e.stopPropagation()
const trimmedPrompt = prompt.trim() const trimmedPrompt = prompt.trim()
if (!trimmedPrompt) { if (!trimmedPrompt) {
+3 -6
View File
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Populate form each time the dialog opens, dropping any cancelled edits // Populate form when template changes
useEffect(() => { useEffect(() => {
if (open && template) { if (template) {
setTitle(template.title || "") setTitle(template.title || "")
setDescription(template.description || "") setDescription(template.description || "")
setPrompt(template.prompt || "") setPrompt(template.prompt || "")
setPinned(template.pinned || false) setPinned(template.pinned || false)
setError(null) setError(null)
} }
}, [open, template]) }, [template])
const handleOpenChange = (newOpen: boolean) => { const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) { if (!newOpen) {
@@ -59,9 +59,6 @@ export function TemplateEditDialog({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// React submit events bubble through the portal; keep them away from
// any form this dialog may be rendered in
e.stopPropagation()
if (!template) return if (!template) return
+19 -44
View File
@@ -110,10 +110,6 @@ export function TemplatePanel({
useEffect(() => { useEffect(() => {
loadTemplates() loadTemplates()
// Reload when a template is saved elsewhere, e.g. from the chat input
window.addEventListener("templatesChanged", loadTemplates)
return () =>
window.removeEventListener("templatesChanged", loadTemplates)
}, [loadTemplates]) }, [loadTemplates])
const handleCreateSuccess = () => { const handleCreateSuccess = () => {
@@ -224,8 +220,7 @@ export function TemplatePanel({
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
// Local date as YYYY-MM-DD (toISOString would give UTC) a.download = `templates-${new Date().toISOString().split("T")[0]}.json`
a.download = `templates-${new Date().toLocaleDateString("sv-SE")}.json`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) 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 // Empty state: no templates at all
if (!loading && templates.length === 0) { if (!loading && templates.length === 0) {
return ( return (
@@ -359,18 +332,6 @@ export function TemplatePanel({
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
{dict.templates.createFirst} {dict.templates.createFirst}
</button> </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 <TemplateCreateDialog
open={createDialogOpen} open={createDialogOpen}
@@ -428,11 +389,27 @@ export function TemplatePanel({
<Upload className="w-3.5 h-3.5" /> <Upload className="w-3.5 h-3.5" />
{dict.templates.importTemplates} {dict.templates.importTemplates}
</button> </button>
{importInput} <input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleImport}
className="hidden"
/>
</div> </div>
{/* Import message */} {/* 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"> <div className="space-y-2">
{loading {loading
@@ -470,8 +447,6 @@ export function TemplatePanel({
handleTemplateClick(template) handleTemplateClick(template)
} }
onKeyDown={(e) => { onKeyDown={(e) => {
// Ignore keys bubbling up from the action buttons
if (e.target !== e.currentTarget) return
if ( if (
e.key === "Enter" || e.key === "Enter" ||
e.key === " " e.key === " "
+39 -41
View File
@@ -20,17 +20,11 @@ interface ToolCallCardProps {
} }
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) { function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
// Streamed or invalid input can hold anything: show only what React can
// render (an object in place of a string would crash the whole chat)
const shown = operations.filter(
(op) => typeof (op as { operation?: unknown })?.operation === "string",
)
const text = (value: unknown) => (typeof value === "string" ? value : "")
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{shown.map((op, index) => ( {operations.map((op, index) => (
<div <div
key={`${op.operation}-${text(op.cell_id)}-${index}`} key={`${op.operation}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50" className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
> >
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
@@ -46,13 +40,13 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
{op.operation} {op.operation}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
cell_id: {text(op.cell_id)} cell_id: {op.cell_id}
</span> </span>
</div> </div>
{text(op.new_xml) && ( {op.new_xml && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all"> <pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{text(op.new_xml)} {op.new_xml}
</pre> </pre>
</div> </div>
)} )}
@@ -72,7 +66,7 @@ export function ToolCallCard({
dict, dict,
}: ToolCallCardProps) { }: ToolCallCardProps) {
const callId = part.toolCallId 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) // Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? true const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "") const toolName = part.type?.replace("tool-", "")
@@ -98,16 +92,6 @@ export function ToolCallCard({
} }
} }
// Incomplete XML means the output hit the length limit, unless the user
// stopped the generation themselves. Without an input the JSON was
// broken (the server repairs JSON cut short by the limit).
const isTruncated =
state === "output-error" &&
errorText !== "Stopped by user" &&
(toolName === "display_diagram" || toolName === "append_diagram") &&
typeof input?.xml === "string" &&
!isMxCellXmlComplete(input.xml)
const handleCopy = () => { const handleCopy = () => {
let textToCopy = "" let textToCopy = ""
@@ -177,15 +161,22 @@ export function ToolCallCard({
</> </>
)} )}
{state === "output-error" && {state === "output-error" &&
(isTruncated ? ( (() => {
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full"> // Check if this is a truncation (incomplete XML) vs real error
Truncated const isTruncated =
</span> (toolName === "display_diagram" ||
) : ( toolName === "append_diagram") &&
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full"> !isMxCellXmlComplete(input?.xml)
Error return isTruncated ? (
</span> <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 && ( {input && Object.keys(input).length > 0 && (
<button <button
type="button" type="button"
@@ -233,16 +224,23 @@ export function ToolCallCard({
) : null} ) : null}
</div> </div>
)} )}
{/* AI SDK stores tool errors in errorText */} {output &&
{state === "output-error" && (errorText || output) && ( state === "output-error" &&
<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"}`} const isTruncated =
> (toolName === "display_diagram" ||
{isTruncated toolName === "append_diagram") &&
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings." !isMxCellXmlComplete(input?.xml)
: (errorText ?? output)} return (
</div> <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 */} {/* Show get_shape_library output on success */}
{output && {output &&
toolName === "get_shape_library" && toolName === "get_shape_library" &&
+5 -4
View File
@@ -1,6 +1,8 @@
import type { DiagramOperation } from "@/packages/mcp-server/src/diagram-operations.ts" export interface DiagramOperation {
operation: "update" | "add" | "delete"
export type { DiagramOperation } cell_id: string
new_xml?: string
}
export interface ToolPartLike { export interface ToolPartLike {
type: string type: string
@@ -11,5 +13,4 @@ export interface ToolPartLike {
operations?: DiagramOperation[] operations?: DiagramOperation[]
} & Record<string, unknown> } & Record<string, unknown>
output?: string output?: string
errorText?: string
} }
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary" 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 // Dev XML presets for streaming simulator
const DEV_XML_PRESETS: Record<string, string> = { const DEV_XML_PRESETS: Record<string, string> = {
@@ -237,8 +237,8 @@ export function DevXmlSimulator({
}) })
// Display the final diagram // Display the final diagram
const prepared = prepareNewDiagram(xml) const fullXml = wrapWithMxFile(xml)
if (prepared.ok) onDisplayChart(prepared.xml) onDisplayChart(fullXml)
setIsSimulating(false) setIsSimulating(false)
} }
+203 -491
View File
@@ -4,12 +4,12 @@ import {
AlertCircle, AlertCircle,
Check, Check,
ChevronRight, ChevronRight,
Clock,
Eye, Eye,
EyeOff, EyeOff,
Key, Key,
Loader2, Loader2,
Plus, Plus,
RefreshCw,
Server, Server,
Settings2, Settings2,
Sparkles, Sparkles,
@@ -34,13 +34,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog" } from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -50,11 +43,6 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { import {
Select, Select,
@@ -68,13 +56,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
import type { UseModelConfigReturn } from "@/hooks/use-model-config" import type { UseModelConfigReturn } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import type { ListedModel } from "@/lib/provider-models" import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
import { STORAGE_KEYS } from "@/lib/storage"
import type {
ModelConfig,
ProviderConfig,
ProviderName,
} from "@/lib/types/model-config"
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config" import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -143,31 +125,22 @@ export function ModelConfigDialog({
> | null>(null) > | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState("") const [deleteConfirmText, setDeleteConfirmText] = useState("")
// Models whose test is running (they are all tested at once) const [validatingModelIndex, setValidatingModelIndex] = useState<
const [validatingModelIds, setValidatingModelIds] = useState<Set<string>>( number | null
() => new Set(), >(null)
)
const [duplicateError, setDuplicateError] = useState<string>("") const [duplicateError, setDuplicateError] = useState<string>("")
const [editError, setEditError] = useState<{ const [editError, setEditError] = useState<{
modelId: string modelId: string
message: string message: string
} | null>(null) } | null>(null)
// Model ID being typed; written to the config only when valid on blur const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
const [modelIdDraft, setModelIdDraft] = useState<{ Partial<Record<ProviderName, string[]>>
id: string
value: string
} | null>(null)
// Models fetched from the provider, per provider config
const [fetchedModels, setFetchedModels] = useState<
Record<string, ListedModel[]>
>({}) >({})
const [fetchingModels, setFetchingModels] = useState(false) const [loadedSuggestedProviders, setLoadedSuggestedProviders] = useState<
const [fetchModelsError, setFetchModelsError] = useState("") Partial<Record<ProviderName, boolean>>
const [modelPickerOpen, setModelPickerOpen] = useState(false) >({})
// models.dev data for hints, loaded with the dialog (it is ~180 KB) const [loadingSuggestedProvider, setLoadingSuggestedProvider] =
const [getModelInfo, setGetModelInfo] = useState< useState<ProviderName | null>(null)
typeof import("@/lib/model-catalog").getModelInfo | null
>(null)
const { const {
config, config,
@@ -183,35 +156,6 @@ export function ModelConfigDialog({
const selectedProvider = config.providers.find( const selectedProvider = config.providers.find(
(p) => p.id === selectedProviderId, (p) => p.id === selectedProviderId,
) )
// For requests that finish after the user switched provider or edited
// a model id
const selectedProviderIdRef = useRef(selectedProviderId)
selectedProviderIdRef.current = selectedProviderId
const configRef = useRef(config)
configRef.current = config
// Number of the latest Test click: only that test may reset the busy
// state when its credentials changed meanwhile
const validationRunRef = useRef(0)
// A model list or test result belongs to the credentials it was asked
// with; they can change meanwhile, here or in another tab
const credentialsOf = (providerId: string) => {
const p = configRef.current.providers.find((x) => x.id === providerId)
return JSON.stringify([
p?.provider,
p?.apiKey,
p?.baseUrl,
p?.awsAccessKeyId,
p?.awsSecretAccessKey,
p?.awsRegion,
p?.awsSessionToken,
p?.vertexApiKey,
])
}
// Discard an unfinished model ID edit when the dialog closes
useEffect(() => {
if (!open) setModelIdDraft(null)
}, [open])
// Cleanup validation reset timeout on unmount // Cleanup validation reset timeout on unmount
useEffect(() => { useEffect(() => {
@@ -223,83 +167,73 @@ export function ModelConfigDialog({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!open || getModelInfo) return if (
import("@/lib/model-catalog").then((catalog) => !open ||
setGetModelInfo(() => catalog.getModelInfo), selectedProvider?.provider !== "aihubmix" ||
) loadedSuggestedProviders.aihubmix
}, [open, getModelInfo]) ) {
return
const handleFetchModels = async () => {
if (!selectedProvider) return
const providerId = selectedProvider.id
const askedWith = credentialsOf(providerId)
setFetchingModels(true)
setFetchModelsError("")
try {
const response = await fetch(
getApiEndpoint("/api/provider-models"),
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({
provider: selectedProvider.provider,
apiKey: selectedProvider.apiKey,
baseUrl: selectedProvider.baseUrl,
}),
},
)
const data = await response.json().catch(() => ({}))
if (credentialsOf(providerId) !== askedWith) return
// The picker and the error belong to the provider shown
const stillShown = selectedProviderIdRef.current === providerId
if (Array.isArray(data.models)) {
setFetchedModels((current) => ({
...current,
[providerId]: data.models,
}))
if (stillShown) setModelPickerOpen(true)
} else if (stillShown) {
const hints = dict.errors.llm as Record<string, string>
setFetchModelsError(
[hints[data.code], data.error].filter(Boolean).join(" ") ||
`Request failed (${response.status})`,
)
}
} catch {
if (
selectedProviderIdRef.current === providerId &&
credentialsOf(providerId) === askedWith
) {
setFetchModelsError(dict.errors.networkError)
}
} finally {
setFetchingModels(false)
} }
}
// The provider's own list once fetched, else the suggested models let cancelled = false
const suggestedModels: ListedModel[] = selectedProvider setLoadingSuggestedProvider("aihubmix")
? fetchedModels[selectedProvider.id] ||
(SUGGESTED_MODELS[selectedProvider.provider] || []).map((id) => ({ fetch(getApiEndpoint("/api/aihubmix-models"))
id, .then((response) => {
})) if (!response.ok) {
throw new Error(`Failed to load models: ${response.status}`)
}
return response.json()
})
.then((data: { models?: unknown }) => {
if (cancelled || !Array.isArray(data.models)) {
return
}
const models = data.models.filter(
(model): model is string => typeof model === "string",
)
if (models.length > 0) {
setDynamicSuggestedModels((current) => ({
...current,
aihubmix: models,
}))
}
})
.catch((error) => {
console.warn("Failed to load AIHubMix models:", error)
})
.finally(() => {
if (cancelled) {
return
}
setLoadedSuggestedProviders((current) => ({
...current,
aihubmix: true,
}))
setLoadingSuggestedProvider(null)
})
return () => {
cancelled = true
}
}, [open, selectedProvider?.provider, loadedSuggestedProviders.aihubmix])
// Get suggested models for current provider
const suggestedModels = selectedProvider
? dynamicSuggestedModels[selectedProvider.provider] ||
SUGGESTED_MODELS[selectedProvider.provider] ||
[]
: [] : []
// Tool calls are what drawing needs: false when known to be missing const isLoadingSuggestedModels =
const supportsTools = (model: ListedModel) => selectedProvider?.provider === loadingSuggestedProvider
selectedProvider
? (model.tools ??
getModelInfo?.(selectedProvider.provider, model.id)?.tools)
: undefined
// Filter out already-added models from suggestions // Filter out already-added models from suggestions
const existingModelIds = const existingModelIds =
selectedProvider?.models.map((m) => m.modelId) || [] selectedProvider?.models.map((m) => m.modelId) || []
const availableSuggestions = suggestedModels.filter( const availableSuggestions = suggestedModels.filter(
(model) => !existingModelIds.includes(model.id), (modelId) => !existingModelIds.includes(modelId),
) )
const emptyStateSuggestions = selectedProvider const emptyStateSuggestions = selectedProvider
? (SUGGESTED_MODELS[selectedProvider.provider] || []) ? (SUGGESTED_MODELS[selectedProvider.provider] || [])
@@ -312,8 +246,6 @@ export function ModelConfigDialog({
const newProvider = addProvider(providerType) const newProvider = addProvider(providerType)
setSelectedProviderId(newProvider.id) setSelectedProviderId(newProvider.id)
setValidationStatus("idle") setValidationStatus("idle")
setFetchModelsError("")
setModelPickerOpen(false)
} }
// Handle provider field updates // Handle provider field updates
@@ -321,9 +253,9 @@ export function ModelConfigDialog({
field: keyof ProviderConfig, field: keyof ProviderConfig,
value: string | boolean, value: string | boolean,
) => { ) => {
if (!selectedProviderId || !selectedProvider) return if (!selectedProviderId) return
const updates: Partial<ProviderConfig> = { [field]: value } updateProvider(selectedProviderId, { [field]: value })
// Reset validation of the provider and its models when credentials change // Reset validation when credentials change
const credentialFields = [ const credentialFields = [
"apiKey", "apiKey",
"baseUrl", "baseUrl",
@@ -334,19 +266,8 @@ export function ModelConfigDialog({
] ]
if (credentialFields.includes(field)) { if (credentialFields.includes(field)) {
setValidationStatus("idle") setValidationStatus("idle")
setValidatingModelIds(new Set()) updateProvider(selectedProviderId, { validated: false })
setFetchedModels(({ [selectedProviderId]: _, ...rest }) => rest)
setFetchModelsError("")
updates.validated = false
updates.models = selectedProvider.models.map((m) => ({
...m,
validated: undefined,
validationError: undefined,
validationWarning: undefined,
responseTime: undefined,
}))
} }
updateProvider(selectedProviderId, updates)
} }
// Handle adding a model to current provider // Handle adding a model to current provider
@@ -416,164 +337,77 @@ export function ModelConfigDialog({
let allValid = true let allValid = true
let errorCount = 0 let errorCount = 0
let idChanged = false
const askedWith = credentialsOf(selectedProviderId)
const run = ++validationRunRef.current
// For EdgeOne, construct baseUrl from current origin // Validate each model
const baseUrl = isEdgeOne for (let i = 0; i < selectedProvider.models.length; i++) {
? `${window.location.origin}/api/edgeai` const model = selectedProvider.models[i]
: selectedProvider.baseUrl setValidatingModelIndex(i)
// Test every model at once; each row updates when its answer arrives try {
setValidatingModelIds(new Set(selectedProvider.models.map((m) => m.id))) // For EdgeOne, construct baseUrl from current origin
await Promise.all( const baseUrl = isEdgeOne
selectedProvider.models.map(async (model) => { ? `${window.location.origin}/api/edgeai`
let update: Partial<ModelConfig> : selectedProvider.baseUrl
try {
const response = await fetch( const response = await fetch("/api/validate-model", {
getApiEndpoint("/api/validate-model"), method: "POST",
{ headers: { "Content-Type": "application/json" },
method: "POST", body: JSON.stringify({
headers: { provider: selectedProvider.provider,
"Content-Type": "application/json", apiKey: selectedProvider.apiKey,
"x-access-code": baseUrl,
localStorage.getItem( modelId: model.modelId,
STORAGE_KEYS.accessCode, // AWS Bedrock credentials
) || "", awsAccessKeyId: selectedProvider.awsAccessKeyId,
}, awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
body: JSON.stringify({ awsRegion: selectedProvider.awsRegion,
provider: selectedProvider.provider, // Vertex AI credentials (Express Mode)
apiKey: selectedProvider.apiKey, vertexApiKey: selectedProvider.vertexApiKey,
baseUrl, }),
modelId: model.modelId, })
// AWS Bedrock credentials const data = await response.json()
awsAccessKeyId: selectedProvider.awsAccessKeyId,
awsSecretAccessKey: if (data.valid) {
selectedProvider.awsSecretAccessKey, updateModel(selectedProviderId, model.id, {
awsRegion: selectedProvider.awsRegion, validated: true,
// Temporary AWS credentials, as the chat sends validationError: undefined,
awsSessionToken:
selectedProvider.awsSessionToken,
// Vertex AI credentials (Express Mode)
vertexApiKey: selectedProvider.vertexApiKey,
}),
},
)
const data = await response.json().catch(() => ({}))
update = data.valid
? {
validated: true,
validationError: undefined,
validationWarning: data.warning,
responseTime: data.responseTime,
}
: {
validated: false,
// The hint for the error's kind, then the
// provider's own message
validationError:
[
(
dict.errors.llm as Record<
string,
string
>
)[data.code],
data.error,
]
.filter(Boolean)
.join(" ") ||
(response.ok
? "Validation failed"
: `Request failed (${response.status})`),
validationWarning: undefined,
}
} catch {
update = {
validated: false,
validationError: "Network error",
validationWarning: undefined,
}
}
// A newer test started: its own results and spinners count,
// whatever the credentials are now (they may have come back)
if (run !== validationRunRef.current) return
// Credentials changed during the test: drop the result. A
// change in another tab left the spinner on, so clear it
// (model ids are unique, whatever provider is shown).
if (credentialsOf(selectedProviderId) !== askedWith) {
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
}) })
return } else {
}
// So did this model's id: the result is for the old one
const current = configRef.current.providers
.find((p) => p.id === selectedProviderId)
?.models.find((m) => m.id === model.id)
if (current?.modelId !== model.modelId) {
idChanged = true
setValidatingModelIds((prev) => {
const next = new Set(prev)
next.delete(model.id)
return next
})
return
}
if (update.validated === false) {
allValid = false allValid = false
errorCount++ errorCount++
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
} }
updateModel(selectedProviderId, model.id, update) } catch {
setValidatingModelIds((prev) => { allValid = false
const next = new Set(prev) errorCount++
next.delete(model.id) updateModel(selectedProviderId, model.id, {
return next validated: false,
validationError: "Network error",
}) })
}),
)
if (run !== validationRunRef.current) return
if (credentialsOf(selectedProviderId) !== askedWith) {
// The status line is about the provider shown now
if (selectedProviderIdRef.current === selectedProviderId) {
setValidationStatus("idle")
} }
return
} }
// A model whose id changed was not tested setValidatingModelIndex(null)
if (allValid && !idChanged) {
updateProvider(selectedProviderId, { validated: true }) if (allValid) {
}
// The status line is about the provider shown now
if (selectedProviderIdRef.current !== selectedProviderId) return
if (idChanged) {
setValidationStatus("idle")
} else if (allValid) {
setValidationStatus("success") setValidationStatus("success")
updateProvider(selectedProviderId, { validated: true })
// Reset to idle after showing success briefly (with cleanup) // Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) { if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current) clearTimeout(validationResetTimeoutRef.current)
} }
validationResetTimeoutRef.current = setTimeout(() => { validationResetTimeoutRef.current = setTimeout(() => {
validationResetTimeoutRef.current = null
if (run !== validationRunRef.current) return
setValidationStatus("idle") setValidationStatus("idle")
validationResetTimeoutRef.current = null
}, 1500) }, 1500)
} else { } else {
setValidationStatus("error") setValidationStatus("error")
setValidationError(`${errorCount} model(s) failed validation`) setValidationError(`${errorCount} model(s) failed validation`)
} }
}, [ }, [selectedProvider, selectedProviderId, updateProvider, updateModel])
selectedProvider,
selectedProviderId,
updateProvider,
updateModel,
dict,
])
// Get all available provider types // Get all available provider types
const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[] const availableProviders = Object.keys(PROVIDER_INFO) as ProviderName[]
@@ -729,10 +563,6 @@ export function ModelConfigDialog({
) )
setValidationStatus("idle") setValidationStatus("idle")
setShowApiKey(false) setShowApiKey(false)
// These belong to the
// provider shown before
setFetchModelsError("")
setModelPickerOpen(false)
}} }}
className={cn( className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full", "group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
@@ -785,9 +615,7 @@ export function ModelConfigDialog({
{/* Add Provider */} {/* Add Provider */}
<div className="p-3 border-t border-border-subtle"> <div className="p-3 border-t border-border-subtle">
{/* Always empty so picking the same type again still fires */}
<Select <Select
value=""
onValueChange={(v) => onValueChange={(v) =>
handleAddProvider(v as ProviderName) handleAddProvider(v as ProviderName)
} }
@@ -1008,136 +836,57 @@ export function ModelConfigDialog({
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
{PROVIDER_INFO[ <Select
selectedProvider.provider onValueChange={(value) => {
].modelList && ( if (value) {
<Button handleAddModel(
variant="outline" value,
size="sm" )
className="h-8 rounded-lg"
onClick={
handleFetchModels
} }
disabled={ }}
fetchingModels disabled={
} isLoadingSuggestedModels ||
title={ availableSuggestions.length ===
dict.modelConfig 0
.fetchModels
}
aria-label={
dict.modelConfig
.fetchModels
}
>
{fetchingModels ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
)}
{/* modal: the dialog blocks the
wheel outside itself, and the
list is rendered outside it */}
<Popover
modal
open={modelPickerOpen}
onOpenChange={
setModelPickerOpen
} }
> >
<PopoverTrigger asChild> <SelectTrigger className="w-28 h-8 rounded-lg hover:bg-interactive-hover">
<Button {isLoadingSuggestedModels ? (
variant="outline" <Loader2 className="h-3.5 w-3.5 animate-spin" />
size="sm" ) : (
className="w-28 h-8 rounded-lg text-xs" <span className="text-xs">
disabled={ {availableSuggestions.length ===
availableSuggestions.length ===
0 0
} ? dict
> .modelConfig
{availableSuggestions.length === .allAdded
0 : dict
? dict .modelConfig
.modelConfig .suggested}
.allAdded </span>
: dict )}
.modelConfig </SelectTrigger>
.suggested} <SelectContent className="max-h-72">
</Button> {availableSuggestions.map(
</PopoverTrigger> (modelId) => (
<PopoverContent <SelectItem
className="w-80 p-0" key={
align="end" modelId
>
<Command>
<CommandInput
placeholder={
dict
.modelConfig
.searchModels
}
/>
<CommandList className="max-h-72">
<CommandEmpty>
{
dict
.modelConfig
.noModelsFound
} }
</CommandEmpty> value={
{availableSuggestions.map( modelId
(model) => ( }
<CommandItem className="font-mono text-xs"
key={ >
model.id {modelId}
} </SelectItem>
value={ ),
model.id )}
} </SelectContent>
onSelect={() => { </Select>
handleAddModel(
model.id,
)
setModelPickerOpen(
false,
)
}}
className="font-mono text-xs"
>
<span className="truncate">
{
model.id
}
</span>
{supportsTools(
model,
) ===
false && (
<span className="ml-auto shrink-0 font-sans text-[10px] text-amber-600 dark:text-amber-400">
{
dict
.modelConfig
.noTools
}
</span>
)}
</CommandItem>
),
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
} }
> >
{fetchModelsError && (
<p className="mb-2 text-xs text-destructive">
{fetchModelsError}
</p>
)}
{/* Model List */} {/* Model List */}
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]"> <div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
{selectedProvider.models.length === {selectedProvider.models.length ===
@@ -1191,7 +940,7 @@ export function ModelConfigDialog({
) : ( ) : (
<div className="divide-y divide-border-subtle"> <div className="divide-y divide-border-subtle">
{selectedProvider.models.map( {selectedProvider.models.map(
(model) => ( (model, index) => (
<div <div
key={model.id} key={model.id}
className={cn( className={cn(
@@ -1201,24 +950,28 @@ export function ModelConfigDialog({
<div className="flex items-center gap-3 p-3 min-w-0"> <div className="flex items-center gap-3 p-3 min-w-0">
{/* Status icon */} {/* Status icon */}
<div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0"> <div className="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0">
{validatingModelIds.has( {validatingModelIndex !==
model.id, null &&
) ? ( index ===
validatingModelIndex ? (
// Currently validating // Currently validating
<div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center"> <div className="w-full h-full rounded-lg bg-blue-500/10 flex items-center justify-center">
<Loader2 className="h-4 w-4 text-blue-500 animate-spin" /> <Loader2 className="h-4 w-4 text-blue-500 animate-spin" />
</div> </div>
) : validatingModelIndex !==
null &&
index >
validatingModelIndex &&
model.validated ===
undefined ? (
// Queued
<div className="w-full h-full rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
) : model.validated === ) : model.validated ===
true ? ( true ? (
// Valid, with the time the test took // Valid
<div <div className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center">
className="w-full h-full rounded-lg bg-success-muted flex items-center justify-center"
title={
model.responseTime
? `${(model.responseTime / 1000).toFixed(1)} s`
: undefined
}
>
<Check className="h-4 w-4 text-success" /> <Check className="h-4 w-4 text-success" />
</div> </div>
) : model.validated === ) : model.validated ===
@@ -1236,10 +989,7 @@ export function ModelConfigDialog({
</div> </div>
<Input <Input
value={ value={
modelIdDraft?.id === model.modelId
model.id
? modelIdDraft.value
: model.modelId
} }
title={ title={
model.modelId model.modelId
@@ -1257,14 +1007,24 @@ export function ModelConfigDialog({
null, null,
) )
} }
setModelIdDraft( if (
{ selectedProviderId
id: model.id, ) {
value: e updateModel(
.target selectedProviderId,
.value, model.id,
}, {
) modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
}} }}
onKeyDown={( onKeyDown={(
e, e,
@@ -1281,10 +1041,6 @@ export function ModelConfigDialog({
) => { ) => {
const newModelId = const newModelId =
e.target.value.trim() e.target.value.trim()
// Drop the draft; an invalid ID falls back to the saved one
setModelIdDraft(
null,
)
// Helper to show error with shake // Helper to show error with shake
const showError = const showError =
@@ -1379,28 +1135,6 @@ export function ModelConfigDialog({
setEditError( setEditError(
null, null,
) )
if (
selectedProviderId &&
newModelId !==
model.modelId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
newModelId,
validated:
undefined,
validationError:
undefined,
validationWarning:
undefined,
responseTime:
undefined,
},
)
}
}} }}
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1" className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
/> />
@@ -1428,28 +1162,6 @@ export function ModelConfigDialog({
} }
</p> </p>
)} )}
{!model.validationWarning &&
getModelInfo?.(
selectedProvider.provider,
model.modelId,
)?.tools ===
false && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
{
dict
.modelConfig
.mayNotDraw
}
</p>
)}
{model.validated &&
model.validationWarning && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 px-3 pb-2 pl-14">
{
model.validationWarning
}
</p>
)}
{/* Show edit error inline */} {/* Show edit error inline */}
{editError?.modelId === {editError?.modelId ===
model.id && ( model.id && (
+9 -15
View File
@@ -158,7 +158,7 @@ export function ModelSelector({
}, []) }, [])
return ( return (
<div ref={wrapperRef} className="min-w-0 max-w-48"> <div ref={wrapperRef} className="inline-block">
<ModelSelectorRoot open={open} onOpenChange={setOpen}> <ModelSelectorRoot open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild> <ModelSelectorTrigger asChild>
<ButtonWithTooltip <ButtonWithTooltip
@@ -167,7 +167,7 @@ export function ModelSelector({
size="sm" size="sm"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"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", !showLabel && "px-1.5 justify-center",
)} )}
// accessibility: expose label to screen readers // accessibility: expose label to screen readers
@@ -176,7 +176,7 @@ export function ModelSelector({
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> <Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{/* show/hide visible label based on measured width */} {/* show/hide visible label based on measured width */}
{showLabel ? ( {showLabel ? (
<span className="min-w-0 truncate text-xs"> <span className="text-xs truncate">
{selectedModel {selectedModel
? selectedModel.modelId ? selectedModel.modelId
: dict.modelConfig.default} : dict.modelConfig.default}
@@ -264,13 +264,9 @@ export function ModelSelector({
(model) => ( (model) => (
<ModelSelectorItem <ModelSelectorItem
key={model.id} key={model.id}
// Unique value so same-named models highlight value={
// separately; keywords keep search by name model.modelId
value={model.id} }
keywords={[
model.modelId,
providerLabel,
]}
onSelect={() => onSelect={() =>
handleSelect( handleSelect(
model.id, model.id,
@@ -355,11 +351,9 @@ export function ModelSelector({
(model) => ( (model) => (
<ModelSelectorItem <ModelSelectorItem
key={model.id} key={model.id}
value={model.id} value={
keywords={[ model.modelId
model.modelId, }
providerLabel,
]}
onSelect={() => onSelect={() =>
handleSelect( handleSelect(
model.id, model.id,
+12 -51
View File
@@ -13,12 +13,7 @@ import {
} from "@/components/ui/select" } from "@/components/ui/select"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
chatRequestUrl,
normalizeBaseUrl,
PROVIDER_INFO,
type ProviderName,
} from "@/lib/types/model-config"
// Logical secret field. The caller owns the actual input — plaintext for the // Logical secret field. The caller owns the actual input — plaintext for the
// user dialog, write-only masked for the admin panel — supplied via // user dialog, write-only masked for the admin panel — supplied via
@@ -31,7 +26,7 @@ export type SecretField =
| "vertexApiKey" | "vertexApiKey"
// AWS regions offered for Bedrock (shared by both screens) // AWS regions offered for Bedrock (shared by both screens)
export const AWS_REGIONS: Array<[string, string]> = [ const AWS_REGIONS: Array<[string, string]> = [
["us-east-1", "N. Virginia"], ["us-east-1", "N. Virginia"],
["us-east-2", "Ohio"], ["us-east-2", "Ohio"],
["us-west-2", "Oregon"], ["us-west-2", "Oregon"],
@@ -82,7 +77,6 @@ export function ProviderCredentialsFields({
const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, { const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, {
example: info.defaultBaseUrl || "https://api.example.com/v1", example: info.defaultBaseUrl || "https://api.example.com/v1",
}) })
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
// EdgeOne needs no credentials — the caller supplies just a test button // EdgeOne needs no credentials — the caller supplies just a test button
if (provider === "edgeone") { if (provider === "edgeone") {
@@ -214,29 +208,17 @@ export function ProviderCredentialsFields({
</> </>
) : ( ) : (
<> <>
{/* API Key, with a link to where the provider issues keys */} {/* API Key */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <Label
<Label htmlFor="api-key"
htmlFor="api-key" className="text-xs font-medium flex items-center gap-1.5"
className="text-xs font-medium flex items-center gap-1.5" >
> <Key className="h-3.5 w-3.5 text-muted-foreground" />
<Key className="h-3.5 w-3.5 text-muted-foreground" /> {dict.modelConfig.apiKey}
{dict.modelConfig.apiKey} {provider === "ollama" &&
{provider === "ollama" && ` ${dict.modelConfig.optional}`}
` ${dict.modelConfig.optional}`} </Label>
</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" })} {renderSecret({ field: "apiKey", id: "api-key" })}
</div> </div>
@@ -256,38 +238,17 @@ export function ProviderCredentialsFields({
onChange={(e) => onChange={(e) =>
onChange("baseUrl", e.target.value) 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={ placeholder={
info.defaultBaseUrl || info.defaultBaseUrl ||
dict.modelConfig.customEndpoint dict.modelConfig.customEndpoint
} }
className="h-9 rounded-xl font-mono text-xs" 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" && ( {provider === "minimax" && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{dict.modelConfig.minimaxBaseUrlHint} {dict.modelConfig.minimaxBaseUrlHint}
</p> </p>
)} )}
{provider === "mimo" && (
<p className="text-xs text-muted-foreground">
{dict.modelConfig.mimoBaseUrlHint}
</p>
)}
</div> </div>
</> </>
)} )}
-22
View File
@@ -75,8 +75,6 @@ interface SettingsDialogProps {
onOpenModelConfig?: () => void onOpenModelConfig?: () => void
customSystemMessage?: string customSystemMessage?: string
onCustomSystemMessageChange?: (value: string) => void onCustomSystemMessageChange?: (value: string) => void
maxOutputTokens?: string
onMaxOutputTokensChange?: (value: string) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -103,8 +101,6 @@ function SettingsContent({
onOpenModelConfig, onOpenModelConfig,
customSystemMessage = "", customSystemMessage = "",
onCustomSystemMessageChange = () => {}, onCustomSystemMessageChange = () => {},
maxOutputTokens = "",
onMaxOutputTokensChange = () => {},
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
@@ -595,24 +591,6 @@ function SettingsContent({
/> />
</div> </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 */} {/* Send Shortcut */}
<SettingItem <SettingItem
label={dict.settings.sendShortcut} label={dict.settings.sendShortcut}
+185 -203
View File
@@ -1,36 +1,28 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { createContext, useCallback, useContext, useRef, useState } from "react" import { createContext, useContext, useEffect, useRef, useState } from "react"
import type { DrawIoEmbedRef, EventExport } from "react-drawio" import type { DrawIoEmbedRef } from "react-drawio"
import { toast } from "sonner" import { toast } from "sonner"
import type { ExportFormat } from "@/components/save-dialog" import type { ExportFormat } from "@/components/save-dialog"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { import {
BLANK_MXFILE, extractDiagramXML,
normalizeToMxfile, isRealDiagram,
} from "@/packages/mcp-server/src/pages.ts" validateAndFixXml,
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts" } from "../lib/utils"
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
interface DiagramContextType { interface DiagramContextType {
chartXML: string chartXML: string
// chartXML right away, before the re-render (loadDiagram sets both)
chartXMLRef: React.MutableRefObject<string>
latestSvg: string latestSvg: string
diagramHistory: { svg: string; xml: string }[] diagramHistory: { svg: string; xml: string }[]
setDiagramHistory: (history: { svg: string; xml: string }[]) => void setDiagramHistory: (history: { svg: string; xml: string }[]) => void
loadDiagram: (chart: string, skipValidation?: boolean) => string | null loadDiagram: (chart: string, skipValidation?: boolean) => string | null
// Both return the export's tag (empty when draw.io is not there yet) handleExport: () => void
handleExport: () => string handleExportWithoutHistory: () => void
handleExportWithoutHistory: () => string resolverRef: React.MutableRefObject<((value: string) => void) | null>
// Pending exports by tag; a history or plain export's resolver gets the
// first page's XML
exportResolversRef: React.MutableRefObject<
Record<string, (data: string, xml?: string) => void>
>
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null> drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: EventExport) => void handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
@@ -50,12 +42,6 @@ interface DiagramContextType {
const DiagramContext = createContext<DiagramContextType | undefined>(undefined) const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
// Every export carries 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. Tags end in a request number, so a late result never answers
// a newer request.
type ExportTag = "thumbnail" | "validation"
export function DiagramProvider({ children }: { children: React.ReactNode }) { export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [chartXML, setChartXML] = useState<string>("") const [chartXML, setChartXML] = useState<string>("")
const [latestSvg, setLatestSvg] = useState<string>("") const [latestSvg, setLatestSvg] = useState<string>("")
@@ -66,13 +52,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const [showSaveDialog, setShowSaveDialog] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false)
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
// Pending exports, keyed by their export tag const resolverRef = useRef<((value: string) => void) | null>(null)
const exportResolversRef = useRef< // Resolver for PNG export (used for VLM validation)
Record<string, (data: string, xml?: string) => void> const pngResolverRef = useRef<((value: string) => void) | null>(null)
>({}) // Track if we're expecting an export for history (user-initiated)
// Pending history exports: the document each one was asked for const expectHistoryExportRef = useRef<boolean>(false)
const historyXmlRef = useRef(new Map<string, string>())
const exportSeqRef = useRef(0)
// Track latest chartXML for restoration after remount // Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("") const chartXMLRef = useRef<string>("")
@@ -92,85 +76,94 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
setIsDrawioReady(false) setIsDrawioReady(false)
} }
// Update chartXML and its ref together, so callbacks that read the ref // Keep chartXMLRef in sync with state for restoration after remount
// (export handler, autosave) see the new value right away useEffect(() => {
const updateChartXML = (xml: string) => { chartXMLRef.current = chartXML
chartXMLRef.current = xml }, [chartXML])
setChartXML(xml)
} // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{
resolver: ((data: string) => void) | null
format: ExportFormat | null
}>({ resolver: null, format: null })
const handleExport = () => { const handleExport = () => {
if (!drawioRef.current) return "" if (drawioRef.current) {
// Save this export to history, with the document shown now: // Mark that this export should be saved to history
// chartXML can change before the result comes back expectHistoryExportRef.current = true
const tag = `history-${++exportSeqRef.current}` drawioRef.current.exportDiagram({
historyXmlRef.current.set(tag, chartXMLRef.current) format: "xmlsvg",
drawioRef.current.exportDiagram({ })
format: "xmlsvg", }
message: tag,
})
return tag
} }
const handleExportWithoutHistory = () => { const handleExportWithoutHistory = () => {
if (!drawioRef.current) return "" if (drawioRef.current) {
// Export without saving to history (for edit_diagram fetching current state) // Export without saving to history (for edit_diagram fetching current state)
const tag = `fetch-${++exportSeqRef.current}` drawioRef.current.exportDiagram({
drawioRef.current.exportDiagram({ format: "xmlsvg",
format: "xmlsvg", })
message: tag, }
})
return tag
} }
// Export with a tag in `message` (draw.io echoes it back in the export // Get current diagram as SVG for thumbnail (used by session storage)
// event) and wait for that result. Resolves to null on timeout, which is const getThumbnailSvg = async (): Promise<string | null> => {
// expected occasionally.
// (Reads refs only, so it keeps one identity)
const requestTaggedExport = useCallback(
(tag: ExportTag, format: "xmlsvg" | "png", timeoutMs: number) =>
new Promise<string | null>((resolve) => {
const id = `${tag}-${++exportSeqRef.current}`
const finish = (value: string | null) => {
clearTimeout(timer)
delete exportResolversRef.current[id]
resolve(value)
}
const timer = setTimeout(() => finish(null), timeoutMs)
exportResolversRef.current[id] = finish
drawioRef.current?.exportDiagram({ format, message: id })
}),
[],
)
// Get current diagram as SVG for thumbnail (used by session storage).
// One identity: the chat's auto-save depends on it, and each thumbnail
// renders this provider again (latestSvg), which would otherwise start
// the next save
const getThumbnailSvg = useCallback(async (): Promise<string | null> => {
if (!drawioRef.current) return null if (!drawioRef.current) return null
// Don't export if diagram is empty // Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null if (!isRealDiagram(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 // Update latestSvg so it's available for future saves
setLatestSvg(svgData) if (svgData?.includes("<svg")) {
return svgData setLatestSvg(svgData)
return svgData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
} }
return null }
}, [requestTaggedExport])
// Capture current diagram as PNG for VLM validation // Capture current diagram as PNG for VLM validation
const captureValidationPng = async (): Promise<string | null> => { const captureValidationPng = async (): Promise<string | null> => {
if (!drawioRef.current) return null if (!drawioRef.current) return null
// Don't export if diagram is empty // Don't export if diagram is empty
if (!isRealDiagram(chartXMLRef.current)) return null if (!isRealDiagram(chartXML)) return null
const pngData = await requestTaggedExport("validation", "png", 5000) try {
// PNG data should be a base64 data URL const pngData = await Promise.race([
return pngData?.startsWith("data:image/png") ? pngData : null 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 = ( const loadDiagram = (
@@ -179,11 +172,9 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
): string | null => { ): string | null => {
let xmlToLoad = chart let xmlToLoad = chart
// Validate XML structure before loading (unless skipped for internal // Validate XML structure before loading (unless skipped for internal use)
// use). Not strict: the XML may hold the user's own diagram, and the
// tool handlers check model XML strictly before it gets here.
if (!skipValidation) { if (!skipValidation) {
const validation = validateAndFixXml(chart, { strict: false }) const validation = validateAndFixXml(chart)
if (!validation.valid) { if (!validation.valid) {
console.warn( console.warn(
"[loadDiagram] Validation error:", "[loadDiagram] Validation error:",
@@ -202,7 +193,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool) // Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
updateChartXML(xmlToLoad) setChartXML(xmlToLoad)
if (drawioRef.current) { if (drawioRef.current) {
drawioRef.current.load({ drawioRef.current.load({
@@ -213,68 +204,67 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
return null return null
} }
const handleDiagramExport = (data: EventExport) => { const handleDiagramExport = (data: any) => {
// Thumbnail, validation PNG and file save exports go only to their // Handle PNG export for VLM validation
// own caller if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
const tag = data.message?.message pngResolverRef.current(data.data)
if (/^(thumbnail|validation|save)-/.test(tag ?? "")) { pngResolverRef.current = null
exportResolversRef.current[tag as string]?.(data.data, data.xml)
return return
} }
// Don't write chartXML here: exports don't change the diagram, and // Handle save to file if requested (process raw data before extraction)
// data.xml from xmlsvg exports has compressed <diagram> payloads that if (saveResolverRef.current.resolver) {
// would break edit_diagram/display_diagram. Autosave keeps chartXML const format = saveResolverRef.current.format
// up to date with the full uncompressed multi-page document (#879). 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) const extractedXML = extractDiagramXML(data.data)
setChartXML(extractedXML)
setLatestSvg(data.data) setLatestSvg(data.data)
// Only add to history if this was a user-initiated export // Only add to history if this was a user-initiated export
// Limit to 20 entries to prevent memory leaks during long sessions // Limit to 20 entries to prevent memory leaks during long sessions
const MAX_HISTORY_SIZE = 20 const MAX_HISTORY_SIZE = 20
const askedXml = if (expectHistoryExportRef.current) {
tag !== undefined ? historyXmlRef.current.get(tag) : undefined
if (askedXml !== undefined) {
historyXmlRef.current.delete(tag as string)
// Store the full multi-page document (extractedXML is only the
// first page), so restoring a version keeps every page
const historyXml = askedXml || extractedXML
setDiagramHistory((prev) => { setDiagramHistory((prev) => {
const newHistory = [ const newHistory = [
...prev, ...prev,
{ {
svg: data.data, svg: data.data,
xml: historyXml, xml: extractedXML,
}, },
] ]
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer) // Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
return newHistory.slice(-MAX_HISTORY_SIZE) return newHistory.slice(-MAX_HISTORY_SIZE)
}) })
expectHistoryExportRef.current = false
} }
// The chat's own export (onFetchChart), not another one in flight if (resolverRef.current) {
const resolve = resolverRef.current(extractedXML)
tag !== undefined ? exportResolversRef.current[tag] : undefined resolverRef.current = null
if (resolve) {
delete exportResolversRef.current[tag as string]
resolve(extractedXML)
} }
} }
// react-drawio registers this callback once per iframe mount, so it must
// read refs: state captured in its closure would stay stale after a remount
const handleDiagramAutoSave = (data: { xml?: string }) => { const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return if (!data?.xml) return
// Don't overwrite a pending restore - if we have a real diagram but // Don't overwrite a pending restore - if we have a real diagram in state
// DrawIO hasn't loaded yet, it means we're waiting to restore // but DrawIO isn't ready yet, it means we're waiting to restore
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) { if (!isDrawioReady && isRealDiagram(chartXML)) {
return return
} }
updateChartXML(data.xml) setChartXML(data.xml)
} }
const clearDiagram = () => { 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) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
loadDiagram(emptyDiagram, true) loadDiagram(emptyDiagram, true)
setLatestSvg("") setLatestSvg("")
@@ -296,88 +286,81 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const drawioFormat = const drawioFormat =
format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format format === "drawio" || format === "xmlsvg" ? "xmlsvg" : format
// Each save has its own tag, so two at once never swap results // Set up the resolver before triggering export
const tag = `save-${++exportSeqRef.current}` saveResolverRef.current = {
exportResolversRef.current[tag] = ( resolver: (exportData: string) => {
exportData: string, let fileContent: string | Blob
fullDiagramXML?: string, let mimeType: string
) => { let extension: string
delete exportResolversRef.current[tag]
let fileContent: string | Blob
let mimeType: string
let extension: string
if (format === "drawio") { if (format === "drawio") {
// Prefer the complete document from the export event so all pages are saved. // Extract XML from SVG for .drawio format
const xml = fullDiagramXML?.trim() const xml = extractDiagramXML(exportData)
? fullDiagramXML let xmlContent = xml
: extractDiagramXML(exportData) if (!xml.includes("<mxfile")) {
fileContent = xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
normalizeToMxfile(xml, { }
pageId: "page-1", fileContent = xmlContent
pageName: "Page-1", mimeType = "application/xml"
}) ?? xml extension = ".drawio"
mimeType = "application/xml" } else if (format === "png") {
extension = ".drawio" // PNG data comes as base64 data URL
} else if (format === "png") { fileContent = exportData
// PNG data comes as base64 data URL mimeType = "image/png"
fileContent = exportData extension = ".png"
mimeType = "image/png" } else if (format === "xmlsvg") {
extension = ".png" // Editable SVG: pass data URL directly (like PNG)
} else if (format === "xmlsvg") { fileContent = exportData
// Editable SVG: pass data URL directly (like PNG) mimeType = "image/svg+xml"
fileContent = exportData extension = ".drawio.svg"
mimeType = "image/svg+xml" } else {
extension = ".drawio.svg" // SVG format (view-only)
} else { fileContent = exportData
// SVG format (view-only) mimeType = "image/svg+xml"
fileContent = exportData extension = ".svg"
mimeType = "image/svg+xml" }
extension = ".svg"
}
// Log save event to Langfuse (flags the trace) // Log save event to Langfuse (flags the trace)
logSaveToLangfuse(filename, format, sessionId) logSaveToLangfuse(filename, format, sessionId)
// Handle download // Handle download
let url: string let url: string
if ( if (
typeof fileContent === "string" && typeof fileContent === "string" &&
fileContent.startsWith("data:") fileContent.startsWith("data:")
) { ) {
// Already a data URL (PNG) // Already a data URL (PNG)
url = fileContent url = fileContent
} else { } else {
const blob = new Blob([fileContent], { type: mimeType }) const blob = new Blob([fileContent], { type: mimeType })
url = URL.createObjectURL(blob) url = URL.createObjectURL(blob)
} }
const a = document.createElement("a") const a = document.createElement("a")
a.href = url a.href = url
a.download = `${filename}${extension}` a.download = `${filename}${extension}`
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
// Show success toast after download is initiated // Show success toast after download is initiated
if (successMessage) { if (successMessage) {
toast.success(successMessage, { toast.success(successMessage, {
position: "bottom-left", position: "bottom-left",
duration: 2500, duration: 2500,
}) })
} }
// Delay URL revocation to ensure download completes // Delay URL revocation to ensure download completes
if (!url.startsWith("data:")) { if (!url.startsWith("data:")) {
setTimeout(() => URL.revokeObjectURL(url), 100) setTimeout(() => URL.revokeObjectURL(url), 100)
} }
},
format,
} }
// Export diagram - callback will be handled in handleDiagramExport // Export diagram - callback will be handled in handleDiagramExport
drawioRef.current.exportDiagram({ drawioRef.current.exportDiagram({ format: drawioFormat })
format: drawioFormat,
message: tag,
})
} }
// Log save event to Langfuse (just flags the trace, doesn't send content) // Log save event to Langfuse (just flags the trace, doesn't send content)
@@ -401,14 +384,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
<DiagramContext.Provider <DiagramContext.Provider
value={{ value={{
chartXML, chartXML,
chartXMLRef,
latestSvg, latestSvg,
diagramHistory, diagramHistory,
setDiagramHistory, setDiagramHistory,
loadDiagram, loadDiagram,
handleExport, handleExport,
handleExportWithoutHistory, handleExportWithoutHistory,
exportResolversRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave, handleDiagramAutoSave,
-7
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等客户端配置。 详情请参阅[MCP服务器README](../../packages/mcp-server/README.md),了解VS Code、Cursor等客户端配置。
## 快速开始 ## 快速开始
+2 -17
View File
@@ -149,11 +149,9 @@ AZURE_REASONING_SUMMARY=detailed # 可选:none, brief, detailed
AWS_REGION=us-west-2 AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key 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 角色获取。 注意:在 AWS 环境(Lambda、带有 IAM 角色的 EC2)中,凭证会自动从 IAM 角色获取。
### OpenRouter ### OpenRouter
@@ -310,19 +308,6 @@ AI_MODEL=your_model_id
QINIU_BASE_URL=https://your-custom-endpoint QINIU_BASE_URL=https://your-custom-endpoint
``` ```
### MiMo (小米)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自动检测 ## 自动检测
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。 如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
@@ -330,7 +315,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`: 如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
``` ```
## 服务端多模型配置 ## 服务端多模型配置
+2 -17
View File
@@ -164,11 +164,9 @@ AZURE_REASONING_SUMMARY=detailed # Optional: none, brief, detailed
AWS_REGION=us-west-2 AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key 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. Note: On AWS (Lambda, EC2 with IAM role), credentials are automatically obtained from the IAM role.
### OpenRouter ### OpenRouter
@@ -325,19 +323,6 @@ Optional custom endpoint:
QINIU_BASE_URL=https://your-custom-endpoint QINIU_BASE_URL=https://your-custom-endpoint
``` ```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## Auto-Detection ## Auto-Detection
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`. If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
@@ -345,7 +330,7 @@ If you only configure **one** provider's API key, the system will automatically
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`: If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
```bash ```bash
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
``` ```
## Server-Side Multi-Model Configuration ## Server-Side Multi-Model Configuration
-7
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などのクライアント設定も含む)。 詳細は[MCPサーバーREADME](../../packages/mcp-server/README.md)をご覧ください(VS Code、Cursorなどのクライアント設定も含む)。
## はじめに ## はじめに
+2 -17
View File
@@ -149,11 +149,9 @@ AZURE_REASONING_SUMMARY=detailed # 任意: none, brief, detailed
AWS_REGION=us-west-2 AWS_REGION=us-west-2
AWS_ACCESS_KEY_ID=your_access_key_id AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key 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 ロールから自動的に取得されます。 注: AWS 上(IAM ロールを持つ Lambda や EC2)では、認証情報は IAM ロールから自動的に取得されます。
### OpenRouter ### OpenRouter
@@ -310,19 +308,6 @@ AI_MODEL=your_model_id
QINIU_BASE_URL=https://your-custom-endpoint QINIU_BASE_URL=https://your-custom-endpoint
``` ```
### MiMo (Xiaomi)
```bash
MIMO_API_KEY=your_api_key
AI_MODEL=mimo-v2.5-pro
```
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
```bash
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
```
## 自動検出 ## 自動検出
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。 **1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
@@ -330,7 +315,7 @@ MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります: **複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
```bash ```bash
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
``` ```
## サーバーサイドマルチモデル設定 ## サーバーサイドマルチモデル設定
+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) | | 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) | | 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) | | 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) | | salesforce | 96 | `mxgraph.salesforce` | Salesforce platform icons | [salesforce.md](./salesforce.md) |
## Networking & Infrastructure ## 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) | | 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) | | 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) | | 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) | | 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) | | 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 | | Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------| |---------|--------|--------|-------------|------|
| bpmn | 39 | `mxgraph.bpmn` | Business Process Model and Notation - events, gateways, tasks | [bpmn.md](./bpmn.md) | | 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) | | lean_mapping | 13 | `mxgraph.lean_mapping` | Lean/Value Stream Mapping symbols | [lean_mapping.md](./lean_mapping.md) |
## General Diagrams ## General Diagrams
@@ -45,7 +48,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File | | Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------| |---------|--------|--------|-------------|------|
| android | 17 | `mxgraph.android` | Android UI mockup components | [android.md](./android.md) | | 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 ## Enterprise Software
@@ -71,5 +73,6 @@ Reference: `style="shape=mxgraph.<library>.<shape_name>"`
| Library | Shapes | Prefix | Description | File | | Library | Shapes | Prefix | Description | File |
|---------|--------|--------|-------------|------| |---------|--------|--------|-------------|------|
| webicons | 176 | `mxgraph.webicons` | Web/social media logos - GitHub, Twitter, AWS, etc. | [webicons.md](./webicons.md) | | 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 -45
View File
@@ -67,64 +67,41 @@ const MODEL_ALIASES: Record<string, string> = {
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324", "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 { function createResponse(body: any, status = 200, extraHeaders = {}): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status, status,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...CORS_HEADERS,
...extraHeaders, ...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 * Handle OPTIONS request for CORS preflight
// 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. function handleOptionsRequest(): Response {
// No codes configured (or env unavailable) means no check. return new Response(null, {
function hasValidAccessCode(request: Request, env: any): boolean { headers: {
const accessCodes: string[] = ...CORS_HEADERS,
env?.ACCESS_CODE_LIST?.split(",") "Access-Control-Max-Age": "86400",
.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)
} }
export async function onRequest({ request, env }: any) { export async function onRequest({ request, env: _env }: any) {
// Requiring JSON also makes any cross-site browser request need a CORS if (request.method === "OPTIONS") {
// preflight, which fails without CORS headers. Only the type before any return handleOptionsRequest()
// parameters counts: "text/plain; x=application/json" needs none.
const mediaType = (request.headers.get("content-type") ?? "")
.split(";")[0]
.trim()
.toLowerCase()
if (request.method !== "POST" || mediaType !== "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,
)
} }
request.headers.delete("accept-encoding") request.headers.delete("accept-encoding")
@@ -176,7 +153,7 @@ export async function onRequest({ request, env }: any) {
type: "invalid_request_error", type: "invalid_request_error",
}, },
}, },
400, 429,
) )
} }
@@ -239,6 +216,7 @@ export async function onRequest({ request, env }: any) {
"Cache-Control": "no-cache, no-store, no-transform", "Cache-Control": "no-cache, no-store, no-transform",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
Connection: "keep-alive", Connection: "keep-alive",
...CORS_HEADERS,
}, },
}) })
} catch (error: any) { } catch (error: any) {
+4 -9
View File
@@ -60,6 +60,10 @@ declare global {
maximize: () => void maximize: () => void
/** Close the window */ /** Close the window */
close: () => void 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 */ /** Get proxy configuration */
getProxy: () => Promise<ProxyConfig> getProxy: () => Promise<ProxyConfig>
/** Set proxy configuration (saves and restarts server) */ /** Set proxy configuration (saves and restarts server) */
@@ -70,15 +74,6 @@ declare global {
> >
/** Set user's preferred locale */ /** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult> setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
/**
* Call back after the server restarted on the same port (another
* preset); returns a function that stops the calls
*/
onServerRestarted?: (callback: () => void) => () => void
/** A chat was saved: open this port next launch */
chatSaved?: () => Promise<void>
/** The page loaded with this many chats */
chatsLoaded?: (count: number) => Promise<void>
} }
/** Settings window Electron API */ /** Settings window Electron API */
+26 -70
View File
@@ -32,68 +32,6 @@ export function rebuildAppMenu(): void {
buildAppMenu() buildAppMenu()
} }
// Number of the latest preset switch
let lastSwitch = 0
/**
* Apply a preset and restart the server so it takes effect.
* If the restart fails, go back to the previous preset and restart again,
* so the running server always matches the saved current preset.
* Throws an error describing the outcome on failure.
*/
export async function switchPreset(
id: string,
): Promise<Record<string, string>> {
const previousPresetId = getCurrentPresetId()
const env = applyPresetToEnv(id)
if (!env) {
throw new Error("Preset not found")
}
const switchNumber = ++lastSwitch
rebuildAppMenu()
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
if (!app.isPackaged) {
return env
}
try {
await restartNextServer()
return env
} catch (error) {
console.error("Failed to restart server:", error)
const reason = error instanceof Error ? error.message : String(error)
// A newer switch started meanwhile (also of this same preset), or
// the preset was deleted: its own restart follows, and undoing
// would lose that choice
if (switchNumber !== lastSwitch || getCurrentPresetId() !== id) {
throw new Error(
`The server could not be restarted.\n\nError: ${reason}`,
)
}
// Revert to previous preset on failure
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
setCurrentPreset(null)
}
// Rebuild menu to restore previous checkmark state
rebuildAppMenu()
try {
await restartNextServer()
} catch (retryError) {
console.error("Failed to restart server again:", retryError)
throw new Error(
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
)
}
throw new Error(
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
)
}
}
/** /**
* Get the menu template with translations * Get the menu template with translations
*/ */
@@ -254,14 +192,32 @@ function buildConfigMenu(
type: "radio", type: "radio",
checked: preset.id === currentPresetId, checked: preset.id === currentPresetId,
click: async () => { click: async () => {
try { const previousPresetId = getCurrentPresetId()
await switchPreset(preset.id) const env = applyPresetToEnv(preset.id)
} catch (error) {
// Show error dialog to notify user if (env) {
dialog.showErrorBox( try {
"Configuration Error", await restartNextServer()
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`, 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)}`,
)
}
} }
}, },
})) }))
+70 -153
View File
@@ -1,11 +1,5 @@
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs"
import path from "node:path" import path from "node:path"
import { app, safeStorage } from "electron" import { app, safeStorage } from "electron"
@@ -36,9 +30,7 @@ let hasWarnedAboutPlaintext = false
* Warns if encryption is not available (API key stored in plaintext) * Warns if encryption is not available (API key stored in plaintext)
*/ */
function encryptValue(value: string): string { function encryptValue(value: string): string {
// Already encrypted (a value that could not be decrypted): keep it as is if (!value) {
// instead of wrapping it in a second layer of encryption
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
return value return value
} }
@@ -69,7 +61,6 @@ function encryptValue(value: string): string {
/** /**
* Decrypt a sensitive value using safeStorage * Decrypt a sensitive value using safeStorage
* Returns the original value if it's not encrypted or decryption fails * 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 { function decryptValue(value: string): string {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) { if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
@@ -159,10 +150,6 @@ function getConfigFilePath(): string {
return path.join(userDataPath, CONFIG_FILE_NAME) return path.join(userDataPath, CONFIG_FILE_NAME)
} }
// The presets file exists but the last read failed: a save now would
// replace the user's presets with the empty list that read returned
let presetsUnreadable = false
/** /**
* Load presets from the config file * Load presets from the config file
* Decrypts sensitive fields automatically * Decrypts sensitive fields automatically
@@ -171,25 +158,6 @@ export function loadPresets(): ConfigPresetsFile {
const configPath = getConfigFilePath() const configPath = getConfigFilePath()
if (!existsSync(configPath)) { if (!existsSync(configPath)) {
// Nothing left that a save could overwrite
presetsUnreadable = false
return {
version: 1,
currentPresetId: null,
presets: [],
userLocale: undefined,
}
}
let content: string
try {
content = readFileSync(configPath, "utf-8")
presetsUnreadable = false
} catch (error) {
// Often only for now (on Windows an antivirus scanner can hold the
// file): keep the file, and refuse saves based on this empty list
console.error("Failed to read config presets:", error)
presetsUnreadable = true
return { return {
version: 1, version: 1,
currentPresetId: null, currentPresetId: null,
@@ -199,6 +167,7 @@ export function loadPresets(): ConfigPresetsFile {
} }
try { try {
const content = readFileSync(configPath, "utf-8")
const data = JSON.parse(content) as ConfigPresetsFile const data = JSON.parse(content) as ConfigPresetsFile
// Decrypt sensitive fields in each preset // Decrypt sensitive fields in each preset
@@ -210,17 +179,6 @@ export function loadPresets(): ConfigPresetsFile {
return data return data
} catch (error) { } catch (error) {
console.error("Failed to load config presets:", error) console.error("Failed to load config presets:", error)
// Move the unreadable file aside so the next save can't overwrite
// the user's presets with an empty list
const backupPath = `${configPath}.corrupt-${Date.now()}`
try {
renameSync(configPath, backupPath)
console.error(`Unreadable config presets moved to ${backupPath}`)
} catch (renameError) {
// Still there: refuse saves that would overwrite it
console.error("Failed to back up config presets:", renameError)
presetsUnreadable = true
}
return { return {
version: 1, version: 1,
currentPresetId: null, currentPresetId: null,
@@ -235,11 +193,6 @@ export function loadPresets(): ConfigPresetsFile {
* Encrypts sensitive fields automatically * Encrypts sensitive fields automatically
*/ */
export function savePresets(data: ConfigPresetsFile): void { export function savePresets(data: ConfigPresetsFile): void {
if (presetsUnreadable) {
throw new Error(
"The presets file could not be read, so it was not overwritten. Please try again.",
)
}
const configPath = getConfigFilePath() const configPath = getConfigFilePath()
const userDataPath = app.getPath("userData") const userDataPath = app.getPath("userData")
@@ -258,11 +211,7 @@ export function savePresets(data: ConfigPresetsFile): void {
} }
try { try {
// Write a temp file and rename it, so a crash mid-write can't leave writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
// a truncated config file
const tempPath = `${configPath}.tmp`
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
renameSync(tempPath, configPath)
} catch (error) { } catch (error) {
console.error("Failed to save config presets:", error) console.error("Failed to save config presets:", error)
throw error throw error
@@ -358,10 +307,9 @@ export function deletePreset(id: string): boolean {
data.presets.splice(index, 1) 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) { if (data.currentPresetId === id) {
data.currentPresetId = null data.currentPresetId = null
setPresetEnv(null)
} }
savePresets(data) savePresets(data)
@@ -374,15 +322,13 @@ export function deletePreset(id: string): boolean {
export function setCurrentPreset(id: string | null): boolean { export function setCurrentPreset(id: string | null): boolean {
const data = loadPresets() const data = loadPresets()
let preset: ConfigPreset | null = null
if (id !== null) { if (id !== null) {
preset = data.presets.find((p) => p.id === id) || null const preset = data.presets.find((p) => p.id === id)
if (!preset) { if (!preset) {
return false return false
} }
} }
setPresetEnv(preset)
data.currentPresetId = id data.currentPresetId = id
savePresets(data) savePresets(data)
return true return true
@@ -419,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 * 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 env: Record<string, string> = {}
const provider = preset.config.AI_PROVIDER?.toLowerCase() const provider = preset.config.AI_PROVIDER?.toLowerCase()
for (const [key, value] of Object.entries(preset.config)) { for (const [key, value] of Object.entries(preset.config)) {
if (value !== undefined && value !== "") { 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 // Map generic AI_API_KEY to provider-specific key
else if ( if (
key === "AI_API_KEY" && key === "AI_API_KEY" &&
provider && provider &&
PROVIDER_ENV_MAP[provider] PROVIDER_ENV_MAP[provider]
@@ -465,90 +466,6 @@ function presetToEnv(preset: ConfigPreset): Record<string, string> {
return env 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 * Get user's preferred locale from config
* Returns undefined if not set * Returns undefined if not set
+4 -39
View File
@@ -28,25 +28,6 @@ export function loadEnvFile(): void {
console.log("No .env file found, using system environment variables") console.log("No .env file found, using system environment variables")
} }
/**
* Index of the quote that closes a value starting with a quote, or -1. A
* backslash before the quote character escapes it, as in dotenv; the
* backslash stays in the value. As in dotenv, an escaped quote with only a
* comment or nothing after it still closes the value when no other quote
* does ("C:\dir\" keeps its trailing backslash).
*/
function findClosingQuote(value: string): number {
const quote = value[0]
let lastEscaped = -1
for (let i = 1; i < value.length; i++) {
if (value[i] === "\\" && value[i + 1] === quote) {
i++
if (/^\s*(#.*)?$/.test(value.slice(i + 1))) lastEscaped = i
} else if (value[i] === quote) return i
}
return lastEscaped
}
/** /**
* Parse and load environment variables from a file * Parse and load environment variables from a file
*/ */
@@ -67,28 +48,12 @@ function loadEnvFromFile(filePath: string): void {
const key = trimmed.slice(0, equalIndex).trim() const key = trimmed.slice(0, equalIndex).trim()
let value = trimmed.slice(equalIndex + 1).trim() let value = trimmed.slice(equalIndex + 1).trim()
const quote = value[0] // Remove surrounding quotes
const closingQuote =
quote === '"' || quote === "'" ? findClosingQuote(value) : -1
if ( if (
closingQuote > 0 && (value.startsWith('"') && value.endsWith('"')) ||
/^\s*(#.*)?$/.test(value.slice(closingQuote + 1)) (value.startsWith("'") && value.endsWith("'"))
) { ) {
// Quoted value, then nothing or a comment: keep what is value = value.slice(1, -1)
// inside the quotes, as dotenv reads it
value = value.slice(1, closingQuote)
} else {
// Unquoted value: drop an inline comment ("value # comment").
// A value quoted from start to end with quotes inside (JSON
// with an apostrophe) loses only the outer two, as in dotenv.
value = value.replace(/\s+#.*$/, "")
if (
closingQuote > 0 &&
value.length > 1 &&
value.endsWith(quote)
) {
value = value.slice(1, -1)
}
} }
// Don't override existing environment variables // Don't override existing environment variables
+20 -50
View File
@@ -1,17 +1,12 @@
import { app, BrowserWindow, dialog, shell } from "electron" import { app, BrowserWindow, dialog, shell } from "electron"
import { buildAppMenu } from "./app-menu" import { buildAppMenu } from "./app-menu"
import { applyCurrentPresetToEnv } from "./config-manager" import { getCurrentPresetEnv } from "./config-manager"
import { loadEnvFile } from "./env-loader" import { loadEnvFile } from "./env-loader"
import { registerIpcHandlers } from "./ipc-handlers" import { registerIpcHandlers } from "./ipc-handlers"
import { startNextServer, stopNextServer } from "./next-server" import { startNextServer, stopNextServer } from "./next-server"
import { applyProxyToEnv } from "./proxy-manager" import { applyProxyToEnv } from "./proxy-manager"
import { registerSettingsWindowHandlers } from "./settings-window" import { registerSettingsWindowHandlers } from "./settings-window"
import { import { createWindow, getMainWindow } from "./window-manager"
createWindow,
getAppUrl,
getMainWindow,
isAppUrl,
} from "./window-manager"
// Single instance lock // Single instance lock
const gotTheLock = app.requestSingleInstanceLock() const gotTheLock = app.requestSingleInstanceLock()
@@ -33,14 +28,16 @@ if (!gotTheLock) {
// Apply proxy settings from saved config // Apply proxy settings from saved config
applyProxyToEnv() 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 () => { 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 // Register IPC handlers
registerIpcHandlers() registerIpcHandlers()
registerSettingsWindowHandlers() registerSettingsWindowHandlers()
@@ -49,7 +46,6 @@ if (!gotTheLock) {
buildAppMenu() buildAppMenu()
try { try {
let serverUrl: string
if (isDev) { if (isDev) {
// Development: use the dev server URL // Development: use the dev server URL
serverUrl = serverUrl =
@@ -73,9 +69,8 @@ if (!gotTheLock) {
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
const appUrl = getAppUrl() if (serverUrl) {
if (appUrl) { createWindow(serverUrl)
createWindow(appUrl)
} }
} }
}) })
@@ -92,49 +87,24 @@ if (!gotTheLock) {
stopNextServer() 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 // Open external links in default browser
app.on("web-contents-created", (_, contents) => { app.on("web-contents-created", (_, contents) => {
contents.setWindowOpenHandler(({ url }) => { 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" } return { action: "allow" }
} }
// Open other links in external browser // Open other links in external browser
if (isWebUrl(url)) { if (url.startsWith("http://") || url.startsWith("https://")) {
shell.openExternal(url) shell.openExternal(url)
return { action: "deny" } return { action: "deny" }
} }
return { action: "allow" } return { action: "allow" }
}) })
// Clicking a plain link would otherwise replace the app page with
// an external site that keeps the preload API. Only the page
// itself may navigate there; draw.io stays in its frame (this event
// is for the main frame only)
contents.on("will-navigate", (event) => {
if (isAppUrl(event.url)) {
return
}
event.preventDefault()
if (isWebUrl(event.url)) {
shell.openExternal(event.url)
}
})
}) })
} }
+100 -90
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, type IpcMainInvokeEvent, ipcMain } from "electron" import { app, BrowserWindow, dialog, ipcMain } from "electron"
import { rebuildAppMenu, switchPreset } from "./app-menu" import { rebuildAppMenu } from "./app-menu"
import { import {
applyPresetToEnv,
type ConfigPreset, type ConfigPreset,
createPreset, createPreset,
deletePreset, deletePreset,
@@ -13,14 +14,12 @@ import {
updatePreset, updatePreset,
} from "./config-manager" } from "./config-manager"
import { restartNextServer } from "./next-server" import { restartNextServer } from "./next-server"
import { noteNoChats, rememberChatPort } from "./port-manager"
import { import {
applyProxyToEnv, applyProxyToEnv,
getProxyConfig, getProxyConfig,
type ProxyConfig, type ProxyConfig,
saveProxyConfig, saveProxyConfig,
} from "./proxy-manager" } from "./proxy-manager"
import { isAppUrl } from "./window-manager"
/** /**
* Allowed configuration keys for presets * Allowed configuration keys for presets
@@ -49,44 +48,16 @@ function sanitizePresetConfig(
return sanitized 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 * Register all IPC handlers
*/ */
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
// ==================== App Info ==================== // ==================== App Info ====================
handle("get-version", () => { ipcMain.handle("get-version", () => {
return app.getVersion() return app.getVersion()
}) })
// ==================== Where the chats are ====================
// The page saved a chat, or loaded without any: decides which port
// (and so which origin's chats) the next launch opens
handle("chat-saved", () => rememberChatPort())
handle("chats-loaded", (_event, count: unknown) => {
if (count === 0) noteNoChats()
})
// ==================== Window Controls ==================== // ==================== Window Controls ====================
ipcMain.on("window-minimize", (event) => { ipcMain.on("window-minimize", (event) => {
@@ -108,30 +79,84 @@ export function registerIpcHandlers(): void {
win?.close() 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 ==================== // ==================== Config Presets ====================
handle("config-presets:get-all", () => { ipcMain.handle("config-presets:get-all", () => {
return getAllPresets() return getAllPresets()
}) })
handle("config-presets:get-current", () => { ipcMain.handle("config-presets:get-current", () => {
return getCurrentPreset() return getCurrentPreset()
}) })
handle("config-presets:get-current-id", () => { ipcMain.handle("config-presets:get-current-id", () => {
return getCurrentPresetId() return getCurrentPresetId()
}) })
handle( ipcMain.handle(
"config-presets:save", "config-presets:save",
async ( (
_event, _event,
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & { preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
id?: string id?: string
}, },
) => { ) => {
// Validate preset name // 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") throw new Error("Invalid preset name")
} }
@@ -140,48 +165,42 @@ export function registerIpcHandlers(): void {
if (preset.id) { if (preset.id) {
// Update existing preset // Update existing preset
const updated = updatePreset(preset.id, { return updatePreset(preset.id, {
name: preset.name.trim(), name: preset.name.trim(),
config: sanitizedConfig, 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 // Create new preset
const created = createPreset({ return createPreset({
name: preset.name.trim(), name: preset.name.trim(),
config: sanitizedConfig, config: sanitizedConfig,
}) })
rebuildAppMenu()
return created
}, },
) )
handle("config-presets:delete", async (_event, id: string) => { ipcMain.handle("config-presets:delete", (_event, id: string) => {
const wasCurrent = id === getCurrentPresetId() return deletePreset(id)
// Deleting the active preset also clears its env vars
const deleted = deletePreset(id)
rebuildAppMenu()
// Restart so the server stops using the deleted preset
if (deleted && wasCurrent && app.isPackaged) {
await restartNextServer()
}
return deleted
}) })
handle("config-presets:apply", async (_event, id: string) => { 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 { try {
const env = await switchPreset(id) await restartNextServer()
// In development mode, electron-dev.mjs restarts Next.js return { success: true, env }
return app.isPackaged
? { success: true, env }
: { success: true, env, devMode: true }
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
@@ -193,39 +212,30 @@ export function registerIpcHandlers(): void {
} }
}) })
handle("config-presets:set-current", (_event, id: string | null) => { ipcMain.handle(
return setCurrentPreset(id) "config-presets:set-current",
}) (_event, id: string | null) => {
return setCurrentPreset(id)
},
)
// ==================== Proxy Settings ==================== // ==================== Proxy Settings ====================
handle("get-proxy", () => { ipcMain.handle("get-proxy", () => {
return getProxyConfig() return getProxyConfig()
}) })
handle("set-proxy", async (_event, config: ProxyConfig) => { ipcMain.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" }
}
try { try {
// Save config to file // Save config to file
saveProxyConfig({ saveProxyConfig(config)
httpProxy: config.httpProxy,
httpsProxy: config.httpsProxy,
})
// Apply to current process environment // Apply to current process environment
applyProxyToEnv() applyProxyToEnv()
if (!app.isPackaged) { const isDev = process.env.NODE_ENV === "development"
if (isDev) {
// In development, env vars are already applied // In development, env vars are already applied
// Next.js dev server may need manual restart // Next.js dev server may need manual restart
return { success: true, devMode: true } return { success: true, devMode: true }
@@ -247,11 +257,11 @@ export function registerIpcHandlers(): void {
// ==================== User Locale ==================== // ==================== User Locale ====================
handle("get-user-locale", () => { ipcMain.handle("get-user-locale", () => {
return getUserLocale() 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 // Validate locale is one of the supported values
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) { if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" } return { success: false, error: "Invalid locale" }
+42 -69
View File
@@ -7,20 +7,9 @@ import {
getServerUrl, getServerUrl,
isPortAvailable, isPortAvailable,
} from "./port-manager" } from "./port-manager"
import { setAppUrl } from "./window-manager"
let serverProcess: UtilityProcess | null = null 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 * Get the path to the standalone server resources
* In packaged app: resources/standalone * In packaged app: resources/standalone
@@ -56,11 +45,7 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
* Start the Next.js standalone server using Electron's utilityProcess * Start the Next.js standalone server using Electron's utilityProcess
* This API is designed for running Node.js code in the background * This API is designed for running Node.js code in the background
*/ */
export function startNextServer(): Promise<string> { export async function startNextServer(): Promise<string> {
return runExclusive(startServer)
}
async function startServer(): Promise<string> {
const resourcePath = getResourcePath() const resourcePath = getResourcePath()
const serverPath = path.join(resourcePath, "server.js") const serverPath = path.join(resourcePath, "server.js")
@@ -86,13 +71,6 @@ async function startServer(): Promise<string> {
HOSTNAME: "127.0.0.1", HOSTNAME: "127.0.0.1",
// Enable Node.js built-in proxy support for fetch (Node.js 24+) // Enable Node.js built-in proxy support for fetch (Node.js 24+)
NODE_USE_ENV_PROXY: "1", 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) // Set cache directory to a writable location (user's app data folder)
@@ -118,27 +96,23 @@ async function startServer(): Promise<string> {
// Use Electron's utilityProcess API for running Node.js in background // Use Electron's utilityProcess API for running Node.js in background
// This is the recommended way to run Node.js code in Electron // This is the recommended way to run Node.js code in Electron
const proc = utilityProcess.fork(serverPath, [], { serverProcess = utilityProcess.fork(serverPath, [], {
cwd: resourcePath, cwd: resourcePath,
env, env,
stdio: "pipe", stdio: "pipe",
}) })
serverProcess = proc
proc.stdout?.on("data", (data) => { serverProcess.stdout?.on("data", (data) => {
console.log(`[Next.js] ${data.toString().trim()}`) 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()}`) 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}`) console.log(`Next.js server exited with code ${code}`)
// An old server can exit after a new one started; keep the new one serverProcess = null
if (serverProcess === proc) {
serverProcess = null
}
}) })
const url = getServerUrl() const url = getServerUrl()
@@ -152,36 +126,39 @@ async function startServer(): Promise<string> {
* Stop the Next.js server process and wait for it to exit * Stop the Next.js server process and wait for it to exit
*/ */
export async function stopNextServer(): Promise<void> { export async function stopNextServer(): Promise<void> {
const proc = serverProcess if (serverProcess) {
if (!proc) { console.log("Stopping Next.js server...")
return
}
console.log("Stopping Next.js server...")
serverProcess = null
// Resolves true when the process exits, false after the timeout // Create a promise that resolves when the process exits
const waitForExit = (ms: number) => const exitPromise = new Promise<void>((resolve) => {
new Promise<boolean>((resolve) => { const proc = serverProcess
proc.once("exit", () => resolve(true)) if (!proc) {
setTimeout(() => resolve(false), ms) 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 // Wait for process to exit
// exits, so force kill it if it is still running after 5 seconds await exitPromise
if (!(await waitForExit(5000)) && proc.pid) {
console.warn("Next.js server did not exit in time, force killing it") // Additional wait for OS to release port
try { await new Promise((resolve) => setTimeout(resolve, 500))
process.kill(proc.pid, "SIGKILL")
} catch (error) {
console.error("Failed to force kill Next.js server:", error)
}
await waitForExit(2000)
} }
// Additional wait for OS to release port
await new Promise((resolve) => setTimeout(resolve, 500))
} }
/** /**
@@ -207,19 +184,15 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
/** /**
* Restart the Next.js server with new environment variables * Restart the Next.js server with new environment variables
*/ */
export function restartNextServer(): Promise<string> { export async function restartNextServer(): Promise<string> {
return runExclusive(async () => { console.log("Restarting Next.js server...")
console.log("Restarting Next.js server...")
// Stop the current server and wait for it to exit // Stop the current server and wait for it to exit
await stopNextServer() await stopNextServer()
// Wait for the port to be released // Wait for the port to be released
await waitForServerStop() await waitForServerStop()
// Start the server again, and follow it if it moved to another port // Start the server again
const url = await startServer() return startNextServer()
setAppUrl(url)
return url
})
} }
+13 -84
View File
@@ -1,6 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import net from "node:net" import net from "node:net"
import path from "node:path"
import { app } from "electron" import { app } from "electron"
/** /**
@@ -25,69 +23,6 @@ const PORT_CONFIG = {
*/ */
let allocatedPort: number | null = null let allocatedPort: number | null = null
/**
* Whether chats are saved under http://127.0.0.1:<port>: Electron keeps
* each origin's IndexedDB in its own folder
*/
function hasStoredData(port: number): boolean {
return existsSync(
path.join(
app.getPath("userData"),
"IndexedDB",
`http_127.0.0.1_${port}.indexeddb.leveldb`,
),
)
}
// The two fixed production ports, the only ones whose origin (and so its
// chats and settings) is the same at every launch
const HOME_PORTS = [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
const chatPortFile = () => path.join(app.getPath("userData"), "chat-port.json")
/** The fixed port where a chat was last saved, if known */
function readChatPort(): number | null {
try {
const { port } = JSON.parse(readFileSync(chatPortFile(), "utf-8"))
return HOME_PORTS.includes(port) ? port : null
} catch {
return null
}
}
function writeChatPort(port: number): void {
try {
writeFileSync(chatPortFile(), JSON.stringify({ port }))
} catch (error) {
console.warn("Could not save the chat port:", error)
}
}
/**
* The page saved a chat: open on this port next time. Chats of the two
* ports cannot be shown together (each origin has its own storage), so the
* app opens where the user last worked. A launch that had to use the other
* port and saved nothing does not move it.
*/
export function rememberChatPort(): void {
const port = allocatedPort
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
if (readChatPort() !== port) writeChatPort(port)
}
/**
* The page loaded without any chats. Before any chat was saved under this
* version (no file yet), the user's chats may be on the other fixed port,
* where an older version opened: try it first next time.
*/
export function noteNoChats(): void {
const port = allocatedPort
if (!app.isPackaged || port === null || !HOME_PORTS.includes(port)) return
if (existsSync(chatPortFile())) return
const other = HOME_PORTS.find((p) => p !== port)
if (other !== undefined && hasStoredData(other)) writeChatPort(other)
}
/** /**
* Check if a specific port is available * Check if a specific port is available
*/ */
@@ -109,8 +44,7 @@ export function isPortAvailable(port: number): Promise<boolean> {
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - In development: uses fixed port (6002)
* - In production: uses the legacy port (61337), then 13370, to preserve * - In production: uses fixed port (13370) to preserve localStorage
* localStorage; 13370 first when only it has saved chats
* - Falls back to sequential ports if preferred port is unavailable * - Falls back to sequential ports if preferred port is unavailable
* - Last resort: lets the OS assign a port (port 0) * - Last resort: lets the OS assign a port (port 0)
* *
@@ -135,26 +69,21 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// In production, first the port where a chat was last saved. Without // In production, try legacy port first to preserve existing users' localStorage
// one, the legacy port first to preserve existing users' data, unless if (!isDev) {
// only the new port has data: their app started on 13370 while Windows const legacyPort = PORT_CONFIG.legacyProduction
// reserved 61337, and 61337 being free now would hide it if (await isPortAvailable(legacyPort)) {
const chatPort = isDev ? null : readChatPort() allocatedPort = legacyPort
const candidates = isDev return legacyPort
? [preferredPort]
: chatPort !== null
? [chatPort, ...HOME_PORTS.filter((p) => p !== chatPort)]
: hasStoredData(PORT_CONFIG.production) &&
!hasStoredData(PORT_CONFIG.legacyProduction)
? [PORT_CONFIG.production, PORT_CONFIG.legacyProduction]
: [PORT_CONFIG.legacyProduction, PORT_CONFIG.production]
for (const port of candidates) {
if (await isPortAvailable(port)) {
allocatedPort = port
return port
} }
} }
// Try preferred port
if (await isPortAvailable(preferredPort)) {
allocatedPort = preferredPort
return preferredPort
}
console.warn( console.warn(
`Preferred port ${preferredPort} is in use, finding alternative...`, `Preferred port ${preferredPort} is in use, finding alternative...`,
) )
+5 -18
View File
@@ -13,22 +13,18 @@ function getConfigPath(): string {
/** /**
* Load proxy configuration from JSON file * 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 { try {
const configPath = getConfigPath() const configPath = getConfigPath()
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const data = JSON.parse(fs.readFileSync(configPath, "utf-8")) const data = fs.readFileSync(configPath, "utf-8")
if (data && typeof data === "object" && !Array.isArray(data)) { return JSON.parse(data) as ProxyConfig
return data as ProxyConfig
}
console.error("Ignoring invalid proxy config:", data)
} }
} catch (error) { } catch (error) {
console.error("Failed to load proxy config:", 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 { export function saveProxyConfig(config: ProxyConfig): void {
try { try {
const configPath = getConfigPath() const configPath = getConfigPath()
// Write a temp file and rename it, so a crash mid-write can't leave fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
// a truncated file
const tempPath = `${configPath}.tmp`
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
fs.renameSync(tempPath, configPath)
} catch (error) { } catch (error) {
console.error("Failed to save proxy config:", error) console.error("Failed to save proxy config:", error)
throw error throw error
@@ -55,11 +47,6 @@ export function saveProxyConfig(config: ProxyConfig): void {
export function applyProxyToEnv(): void { export function applyProxyToEnv(): void {
const config = loadProxyConfig() const config = loadProxyConfig()
// No saved settings: keep proxy vars inherited from the system or .env
if (!config) {
return
}
if (config.httpProxy) { if (config.httpProxy) {
process.env.HTTP_PROXY = config.httpProxy process.env.HTTP_PROXY = config.httpProxy
process.env.http_proxy = config.httpProxy process.env.http_proxy = config.httpProxy
+1 -41
View File
@@ -3,9 +3,6 @@ import { app, BrowserWindow, screen } from "electron"
let mainWindow: BrowserWindow | null = null 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 * Get the icon path based on platform
* Note: electron-builder converts icon.png during packaging, * Note: electron-builder converts icon.png during packaging,
@@ -31,7 +28,6 @@ function getIconPath(): string | undefined {
* Create the main application window * Create the main application window
*/ */
export function createWindow(serverUrl: string): BrowserWindow { export function createWindow(serverUrl: string): BrowserWindow {
appUrl = serverUrl
const { width, height } = screen.getPrimaryDisplay().workAreaSize const { width, height } = screen.getPrimaryDisplay().workAreaSize
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
@@ -60,7 +56,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
}) })
// Open DevTools in development // Open DevTools in development
if (!app.isPackaged) { if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools() mainWindow.webContents.openDevTools()
} }
@@ -97,39 +93,3 @@ export function createWindow(serverUrl: string): BrowserWindow {
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
return mainWindow return mainWindow
} }
/**
* Get the app server URL the main window loads
*/
export function getAppUrl(): string | null {
return appUrl
}
/**
* Point the main window at the restarted app server (it can come up on a
* different port). On the same port the page fetches the new preset's
* server models instead of sending the old preset's choice; it is not
* reloaded, which would drop unsent attachments.
*/
export function setAppUrl(url: string): void {
if (url === appUrl) {
mainWindow?.webContents.send("server-restarted")
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 -14
View File
@@ -18,6 +18,10 @@ contextBridge.exposeInMainWorld("electronAPI", {
maximize: () => ipcRenderer.send("window-maximize"), maximize: () => ipcRenderer.send("window-maximize"),
close: () => ipcRenderer.send("window-close"), close: () => ipcRenderer.send("window-close"),
// File operations
openFile: () => ipcRenderer.invoke("dialog-open-file"),
saveFile: (data: string) => ipcRenderer.invoke("dialog-save-file", data),
// Proxy settings // Proxy settings
getProxy: () => ipcRenderer.invoke("get-proxy"), getProxy: () => ipcRenderer.invoke("get-proxy"),
setProxy: (config: { httpProxy?: string; httpsProxy?: string }) => setProxy: (config: { httpProxy?: string; httpsProxy?: string }) =>
@@ -27,18 +31,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
getUserLocale: () => ipcRenderer.invoke("get-user-locale"), getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
setUserLocale: (locale: string) => setUserLocale: (locale: string) =>
ipcRenderer.invoke("set-user-locale", locale), ipcRenderer.invoke("set-user-locale", locale),
// A chat was saved, or the page loaded with this many chats: the next
// launch opens the port where the chats are
chatSaved: () => ipcRenderer.invoke("chat-saved"),
chatsLoaded: (count: number) => ipcRenderer.invoke("chats-loaded", count),
// The server restarted on the same port (another preset)
onServerRestarted: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on("server-restarted", listener)
return () => {
ipcRenderer.removeListener("server-restarted", listener)
}
},
}) })
+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 { try {
saveBtn.disabled = true saveBtn.disabled = true
saveBtn.innerHTML = '<span class="loading"></span>' saveBtn.innerHTML = '<span class="loading"></span>'
@@ -223,7 +220,10 @@ async function savePreset() {
await window.settingsAPI.savePreset(preset) await window.settingsAPI.savePreset(preset)
await loadPresets() await loadPresets()
closeModal() closeModal()
showToast(isEdit ? "Preset updated" : "Preset created", "success") showToast(
editingPresetId ? "Preset updated" : "Preset created",
"success",
)
} catch (error) { } catch (error) {
console.error("Failed to save preset:", error) console.error("Failed to save preset:", error)
showToast("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) const result = await window.settingsAPI.applyPreset(id)
if (result.success) { if (result.success) {
currentPresetId = id
renderPresets()
showToast("Preset applied, server restarting...", "success") showToast("Preset applied, server restarting...", "success")
} else { } else {
showToast(result.error || "Failed to apply preset", "error") showToast(result.error || "Failed to apply preset", "error")
@@ -272,9 +274,6 @@ async function applyPreset(id) {
} catch (error) { } catch (error) {
console.error("Failed to apply preset:", error) console.error("Failed to apply preset:", error)
showToast("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()
} }
} }
+3 -23
View File
@@ -11,12 +11,6 @@ AI_PROVIDER=bedrock
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro # Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Output limit, all providers (default: 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 Bedrock Configuration
# AWS_REGION=us-east-1 # AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=your-access-key-id # AWS_ACCESS_KEY_ID=your-access-key-id
@@ -44,6 +38,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Google Generative AI Configuration # Google Generative AI Configuration
# GOOGLE_GENERATIVE_AI_API_KEY=... # GOOGLE_GENERATIVE_AI_API_KEY=...
# GOOGLE_BASE_URL=https://generativelanguage.googleapis.com/v1beta # Optional: Custom endpoint # 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_K=40 # Optional: Top K sampling parameter
# GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter # GOOGLE_TOP_P=0.95 # Optional: Nucleus sampling parameter
# Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true) # Note: Gemini 2.5/3 models automatically enable reasoning display (includeThoughts: true)
@@ -70,7 +65,7 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# AZURE_REASONING_SUMMARY=detailed # AZURE_REASONING_SUMMARY=detailed
# Ollama Configuration (Local or Cloud) # Ollama Configuration (Local or Cloud)
# OLLAMA_BASE_URL=https://ollama.com/api # Optional: Ollama Cloud; defaults to local Ollama (http://127.0.0.1:11434) # OLLAMA_BASE_URL=https://ollama.com/api # Optional, defaults to Ollama Cloud
# OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances # OLLAMA_API_KEY=your-ollama-cloud-api-key # Optional: For Ollama Cloud or authenticated remote instances
# OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3) # OLLAMA_ENABLE_THINKING=true # Optional: Enable thinking for models that support it (e.g., qwen3)
@@ -123,14 +118,9 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Temperature (Optional) # Temperature (Optional)
# Controls randomness in AI responses. Lower = more deterministic. # Controls randomness in AI responses. Lower = more deterministic.
# Leave unset for models that don't support temperature (e.g., GPT-5.1 reasoning models). # 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.
# TEMPERATURE=0 # 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 Control (Optional)
# ACCESS_CODE_LIST=your-secret-code,another-code # ACCESS_CODE_LIST=your-secret-code,another-code
@@ -199,13 +189,3 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
# Get your API key from: https://novita.ai/dashboard/key # Get your API key from: https://novita.ai/dashboard/key
# NOVITA_API_KEY=your_novita_api_key # NOVITA_API_KEY=your_novita_api_key
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default # NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
# MiMo (Xiaomi) Configuration (Optional)
# Get your API key from: https://platform.xiaomimimo.com/
# MIMO_API_KEY=your_mimo_api_key
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
# Atlas Cloud Configuration (Optional)
# Get your API key from: https://www.atlascloud.ai/console/api-keys
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
+91 -122
View File
@@ -1,4 +1,5 @@
import type { MutableRefObject } from "react" import type { MutableRefObject } from "react"
import { useRef } from "react"
import type { DiagramOperation } from "@/components/chat/types" import type { DiagramOperation } from "@/components/chat/types"
import type { import type {
ValidationState, ValidationState,
@@ -6,15 +7,10 @@ import type {
} from "@/components/chat/ValidationCard" } from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator" import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator" import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete } from "@/lib/utils" import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
import { editDiagram } from "@/packages/mcp-server/src/edit-diagram.ts"
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
const DEBUG = process.env.NODE_ENV === "development" 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 { interface ToolCall {
toolCallId: string toolCallId: string
toolName: string toolName: string
@@ -52,10 +48,6 @@ type ValidateDiagramFn = (
interface UseDiagramToolHandlersParams { interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string> partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>> editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
// Tool calls the streaming preview must leave alone (shared with it)
processedToolCallsRef: MutableRefObject<Set<string>>
// Failed VLM validations in the current user turn (reset on each user message)
validationRetryCountRef: MutableRefObject<number>
chartXMLRef: MutableRefObject<string> chartXMLRef: MutableRefObject<string>
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string> onFetchChart: (saveToHistory?: boolean) => Promise<string>
@@ -64,11 +56,6 @@ interface UseDiagramToolHandlersParams {
validateDiagram?: ValidateDiagramFn validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean enableVlmValidation?: boolean
sessionId?: string sessionId?: string
// Called when a screenshot check begins; the function it returns
// tells whether the user pressed Stop in this turn, also after the next
// message was sent. A check that has not started then is skipped (one
// already running is cancelled by the caller).
watchStop?: () => () => boolean
onValidationStateChange?: ( onValidationStateChange?: (
toolCallId: string, toolCallId: string,
state: ValidationState, state: ValidationState,
@@ -85,8 +72,6 @@ interface UseDiagramToolHandlersParams {
export function useDiagramToolHandlers({ export function useDiagramToolHandlers({
partialXmlRef, partialXmlRef,
editDiagramOriginalXmlRef, editDiagramOriginalXmlRef,
processedToolCallsRef,
validationRetryCountRef,
chartXMLRef, chartXMLRef,
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
@@ -95,9 +80,11 @@ export function useDiagramToolHandlers({
validateDiagram, validateDiagram,
enableVlmValidation = true, enableVlmValidation = true,
sessionId, sessionId,
watchStop,
onValidationStateChange, onValidationStateChange,
}: UseDiagramToolHandlersParams) { }: UseDiagramToolHandlersParams) {
// Track validation retry count per tool call
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
// Helper to update validation state // Helper to update validation state
const updateValidationState = ( const updateValidationState = (
toolCallId: string, toolCallId: string,
@@ -127,39 +114,18 @@ export function useDiagramToolHandlers({
) )
} }
processedToolCallsRef.current.add(toolCall.toolCallId)
// Only display_diagram, edit_diagram and a completing append_diagram
// put their result on the canvas. Other tools (get_shape_library,
// which the server runs, still arrives here) leave the stored
// originals for the preview code to undo.
if (toolCall.toolName === "display_diagram") { if (toolCall.toolName === "display_diagram") {
await handleDisplayDiagram(toolCall, addToolOutput, takeOriginals()) await handleDisplayDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "edit_diagram") { } else if (toolCall.toolName === "edit_diagram") {
await handleEditDiagram(toolCall, addToolOutput, takeOriginals()) await handleEditDiagram(toolCall, addToolOutput)
} else if (toolCall.toolName === "append_diagram") { } else if (toolCall.toolName === "append_diagram") {
handleAppendDiagram(toolCall, addToolOutput) handleAppendDiagram(toolCall, addToolOutput)
} }
} }
// Stored originals belong to previews not handled yet: this call's, and
// those of earlier calls with invalid input, which never get to the
// handler. The first is the diagram before all of them. A call that
// draws its result replaces those previews, so the preview code must
// neither draw them again nor undo them later. Returns that first one.
const takeOriginals = (): string | undefined => {
const [originalXml] = editDiagramOriginalXmlRef.current.values()
for (const id of editDiagramOriginalXmlRef.current.keys()) {
processedToolCallsRef.current.add(id)
}
editDiagramOriginalXmlRef.current.clear()
return originalXml
}
// originalXml: the diagram before the streamed previews, if any were drawn
const handleDisplayDiagram = async ( const handleDisplayDiagram = async (
toolCall: ToolCall, toolCall: ToolCall,
addToolOutput: AddToolOutputFn, addToolOutput: AddToolOutputFn,
originalXml: string | undefined,
) => { ) => {
const { xml } = toolCall.input as { xml: string } const { xml } = toolCall.input as { xml: string }
@@ -208,18 +174,14 @@ NEXT STEP: Call append_diagram with the continuation XML.
const finalXml = xml const finalXml = xml
partialXmlRef.current = "" // Reset any partial from previous truncation partialXmlRef.current = "" // Reset any partial from previous truncation
// Wrap, validate and auto-fix the model's XML like the MCP server's // Wrap raw XML with full mxfile structure for draw.io
// create_new_diagram, then load it const fullXml = wrapWithMxFile(finalXml)
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
const validationError = prepared.ok // loadDiagram validates and returns error if invalid
? onDisplayChart(prepared.xml, true) const validationError = onDisplayChart(fullXml)
: prepared.error
if (validationError) { if (validationError) {
console.warn("[display_diagram] Validation error:", validationError) console.warn("[display_diagram] Validation error:", validationError)
// Undo the streamed preview, as a failed edit does: the canvas
// keeps the diagram from before this failed call
if (originalXml) onDisplayChart(originalXml, true)
// Return error to model - sendAutomaticallyWhen will trigger retry // Return error to model - sendAutomaticallyWhen will trigger retry
if (DEBUG) { if (DEBUG) {
console.log( console.log(
@@ -251,12 +213,9 @@ ${finalXml}
if ( if (
enableVlmValidation && enableVlmValidation &&
captureValidationPng && captureValidationPng &&
validateDiagram && validateDiagram
// At most this many checks per user turn, passed or not
validationRetryCountRef.current < MAX_VALIDATION_RETRIES
) { ) {
let capturedPngData: string | null = null let capturedPngData: string | null = null
const stopped = watchStop?.()
try { try {
// Notify UI that we're starting capture // Notify UI that we're starting capture
updateValidationState(toolCall.toolCallId, "capturing") updateValidationState(toolCall.toolCallId, "capturing")
@@ -266,28 +225,24 @@ ${finalXml}
await new Promise((resolve) => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
capturedPngData = await captureValidationPng() capturedPngData = await captureValidationPng()
// Stopped while the screenshot was taken: no check. The if (capturedPngData) {
// chat waits for this handler, so it must end now.
if (stopped?.()) {
updateValidationState(toolCall.toolCallId, "skipped")
} else if (capturedPngData) {
if (DEBUG) { if (DEBUG) {
console.log( console.log(
"[display_diagram] Captured PNG for validation", "[display_diagram] Captured PNG for validation",
) )
} }
// Each retry is a new tool call, so count attempts const retryCount =
// per user turn (the chat resets it when the user sends) validationRetryCountRef.current.get(
const attempt = validationRetryCountRef.current + 1 toolCall.toolCallId,
validationRetryCountRef.current = attempt ) || 0
// Notify UI that we're validating (include the image) // Notify UI that we're validating (include the image)
updateValidationState( updateValidationState(
toolCall.toolCallId, toolCall.toolCallId,
"validating", "validating",
{ {
attempt, attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES, maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData, imageData: capturedPngData,
}, },
@@ -299,12 +254,17 @@ ${finalXml}
) )
if (!result.valid) { if (!result.valid) {
if (attempt < MAX_VALIDATION_RETRIES) { if (retryCount < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current.set(
toolCall.toolCallId,
retryCount + 1,
)
const feedback = const feedback =
formatValidationFeedback(result) formatValidationFeedback(result)
if (DEBUG) { if (DEBUG) {
console.log( console.log(
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`, `[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
result.issues, result.issues,
) )
} }
@@ -314,7 +274,7 @@ ${finalXml}
toolCall.toolCallId, toolCall.toolCallId,
"failed", "failed",
{ {
attempt, attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES, maxAttempts: MAX_VALIDATION_RETRIES,
result, result,
imageData: capturedPngData, imageData: capturedPngData,
@@ -325,16 +285,20 @@ ${finalXml}
tool: "display_diagram", tool: "display_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
state: "output-error", state: "output-error",
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`, errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
}) })
return return
} else { } else {
// Last attempt - accept the diagram with warning // Max retries reached - accept the diagram with warning
if (DEBUG) { if (DEBUG) {
console.log( console.log(
"[display_diagram] Max validation retries reached, accepting diagram", "[display_diagram] Max validation retries reached, accepting diagram",
) )
} }
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
// Notify UI that we're accepting with issues (include the image) // Notify UI that we're accepting with issues (include the image)
updateValidationState( updateValidationState(
toolCall.toolCallId, toolCall.toolCallId,
@@ -350,6 +314,10 @@ ${finalXml}
return return
} }
} else { } else {
// Validation passed - clean up retry count
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
if (DEBUG) { if (DEBUG) {
console.log( console.log(
"[display_diagram] Validation passed!", "[display_diagram] Validation passed!",
@@ -372,16 +340,6 @@ ${finalXml}
updateValidationState(toolCall.toolCallId, "skipped") updateValidationState(toolCall.toolCallId, "skipped")
} }
} catch (error) { } catch (error) {
// Cancelled by Stop: the diagram stays, unchecked
if ((error as Error)?.name === "AbortError") {
updateValidationState(toolCall.toolCallId, "skipped")
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
})
return
}
// VLM validation error - log but don't block the user // VLM validation error - log but don't block the user
console.warn( console.warn(
"[display_diagram] VLM validation error:", "[display_diagram] VLM validation error:",
@@ -415,24 +373,21 @@ ${finalXml}
} }
} }
// originalXml: the diagram before the streamed previews, if any were drawn.
// Operations apply to it, the same base XML that streaming used.
const handleEditDiagram = async ( const handleEditDiagram = async (
toolCall: ToolCall, toolCall: ToolCall,
addToolOutput: AddToolOutputFn, addToolOutput: AddToolOutputFn,
originalXml: string | undefined,
) => { ) => {
const { operations } = toolCall.input as { const { operations } = toolCall.input as {
operations: DiagramOperation[] operations: DiagramOperation[]
} }
let currentXml = "" let currentXml = ""
// 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 { 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) { if (originalXml) {
currentXml = originalXml currentXml = originalXml
} else { } else {
@@ -446,19 +401,26 @@ ${finalXml}
} }
} }
// All or nothing, checked like the MCP server's edit_diagram. const { applyDiagramOperations } = await import("@/lib/utils")
// The model sees the first page, so edits target it. const { result: editedXml, errors } = applyDiagramOperations(
const outcome = editDiagram(currentXml, operations, {}) currentXml,
if (!outcome.ok) { operations,
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")}` // Check for operation errors
restoreOriginal() if (errors.length > 0) {
const errorMessages = errors
.map(
(e) =>
`- ${e.type} on cell_id="${e.cellId}": ${e.message}`,
)
.join("\n")
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
state: "output-error", state: "output-error",
errorText: `${reason} errorText: `Some operations failed:\n${errorMessages}
Current diagram XML: Current diagram XML:
\`\`\`xml \`\`\`xml
@@ -467,23 +429,49 @@ ${currentXml}
Please check the cell IDs and retry.`, Please check the cell IDs and retry.`,
}) })
// Clean up the shared original XML ref
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
return 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() onExport()
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, 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)
} catch (error) { } catch (error) {
console.error("[edit_diagram] Failed:", error) console.error("[edit_diagram] Failed:", error)
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
restoreOriginal()
addToolOutput({ addToolOutput({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
@@ -497,6 +485,8 @@ ${currentXml || "No XML available"}
Please check cell IDs and retry, or use display_diagram to regenerate.`, Please check cell IDs and retry, or use display_diagram to regenerate.`,
}) })
// Clean up the shared original XML ref even on error
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
} }
} }
@@ -506,19 +496,6 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
) => { ) => {
const { xml } = toolCall.input as { xml: string } 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 // Detect if LLM incorrectly started fresh instead of continuing
// LLM should only output bare mxCells now, so wrapper tags indicate error // LLM should only output bare mxCells now, so wrapper tags indicate error
const trimmed = xml.trim() const trimmed = xml.trim()
@@ -557,18 +534,10 @@ Start your continuation with the NEXT character after where it stopped.`,
const finalXml = partialXmlRef.current const finalXml = partialXmlRef.current
partialXmlRef.current = "" // Reset partialXmlRef.current = "" // Reset
const prepared = prepareNewDiagram(finalXml, NEW_PAGE) const fullXml = wrapWithMxFile(finalXml)
// It draws now: it takes the stored originals, as display_diagram const validationError = onDisplayChart(fullXml)
const originalXml = prepared.ok ? takeOriginals() : undefined
const validationError = prepared.ok
? onDisplayChart(prepared.xml, true)
: prepared.error
if (validationError) { if (validationError) {
// Loading failed: back to the diagram before the previews
if (prepared.ok && originalXml) {
onDisplayChart(originalXml, true)
}
addToolOutput({ addToolOutput({
tool: "append_diagram", tool: "append_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
+34 -122
View File
@@ -13,7 +13,6 @@ import {
flattenModels, flattenModels,
type ModelConfig, type ModelConfig,
type MultiModelConfig, type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig, type ProviderConfig,
type ProviderName, type ProviderName,
} from "@/lib/types/model-config" } from "@/lib/types/model-config"
@@ -64,28 +63,6 @@ function migrateOldConfig(): MultiModelConfig | null {
return config return config
} }
const isKnownProvider = (p: { provider: string }) =>
Object.hasOwn(PROVIDER_INFO, p.provider)
/**
* The stored config without providers this version does not know (saved
* by another version, or edited by hand): they would break every list of
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
*/
function parseStoredConfig(stored: string): MultiModelConfig {
const config = JSON.parse(stored) as MultiModelConfig
const known = config.providers.filter(isKnownProvider)
if (known.length < config.providers.length) {
console.warn(
"Skipped saved providers this version does not know:",
config.providers
.filter((p) => !isKnownProvider(p))
.map((p) => p.provider),
)
}
return { ...config, providers: known }
}
/** /**
* Load config from localStorage * Load config from localStorage
*/ */
@@ -96,7 +73,7 @@ function loadConfig(): MultiModelConfig {
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs) const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) { if (stored) {
try { try {
return parseStoredConfig(stored) return JSON.parse(stored) as MultiModelConfig
} catch { } catch {
console.error("Failed to parse model config") console.error("Failed to parse model config")
} }
@@ -121,35 +98,7 @@ function loadConfig(): MultiModelConfig {
*/ */
function saveConfig(config: MultiModelConfig): void { function saveConfig(config: MultiModelConfig): void {
if (typeof window === "undefined") return if (typeof window === "undefined") return
// Providers this version does not know are not in config: keep them, localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
// with their keys, for the version that saved them
let unknown: MultiModelConfig["providers"] = []
try {
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
if (stored) {
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
(p) => !isKnownProvider(p),
)
}
} catch {
// Unreadable: nothing to keep
}
localStorage.setItem(
STORAGE_KEYS.modelConfigs,
JSON.stringify({
...config,
providers: [...config.providers, ...unknown],
}),
)
}
/**
* 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 { export interface UseModelConfigReturn {
@@ -195,25 +144,10 @@ export function useModelConfig(): UseModelConfigReturn {
setIsLoaded(true) setIsLoaded(true)
}, []) }, [])
// Pick up config changes saved by other tabs, so this tab neither shows a // Load server models on mount (if any)
// 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), and again when the desktop app
// restarted its server for another preset
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
loadServerModels()
return window.electronAPI?.onServerRestarted?.(loadServerModels)
}, [])
function loadServerModels() {
fetch(getApiEndpoint("/api/server-models")) fetch(getApiEndpoint("/api/server-models"))
.then((res) => { .then((res) => {
if (!res.ok) { if (!res.ok) {
@@ -231,37 +165,24 @@ export function useModelConfig(): UseModelConfigReturn {
setServerModels(raw) setServerModels(raw)
setServerLoaded(true) setServerLoaded(true)
// Auto-select the default server model if no model is selected, // Auto-select default server model if no model is currently selected
// or if the saved server model is gone (renamed or removed)
setConfig((prev) => { setConfig((prev) => {
const id = prev.selectedModelId if (!prev.selectedModelId && raw.length > 0) {
const isStale = const defaultModel = raw.find((m) => m.isDefault)
id?.startsWith("server:") && if (defaultModel) {
!raw.some((m) => m.id === id) return { ...prev, selectedModelId: defaultModel.id }
if (id && !isStale) return prev }
// Saved before non-ASCII characters in provider names // If no default marked, use first server model
// got into the id: they were dropped from it return { ...prev, selectedModelId: raw[0].id }
const renamed = raw.filter( }
(m) => return prev
`server:${m.providerLabel
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
)
const fallback =
renamed.length === 1
? renamed[0].id
: defaultServerModelId(raw)
return fallback === id
? prev
: { ...prev, selectedModelId: fallback }
}) })
}) })
.catch((error) => { .catch((error) => {
console.error("Error while loading server models:", error) console.error("Error while loading server models:", error)
setServerLoaded(true) setServerLoaded(true)
}) })
} }, [])
// Save config whenever it changes (after initial load) // Save config whenever it changes (after initial load)
useEffect(() => { useEffect(() => {
@@ -339,31 +260,24 @@ export function useModelConfig(): UseModelConfigReturn {
[], [],
) )
const deleteProvider = useCallback( const deleteProvider = useCallback((providerId: string) => {
(providerId: string) => { setConfig((prev) => {
setConfig((prev) => { const provider = prev.providers.find((p) => p.id === providerId)
const provider = prev.providers.find((p) => p.id === providerId) const modelIds = provider?.models.map((m) => m.id) || []
const modelIds = provider?.models.map((m) => m.id) || []
// Fall back to the default server model if the selected model // Clear selected model if it belongs to deleted provider
// belongs to the deleted provider const newSelectedId =
const newSelectedId = prev.selectedModelId && modelIds.includes(prev.selectedModelId)
prev.selectedModelId && ? undefined
modelIds.includes(prev.selectedModelId) : prev.selectedModelId
? defaultServerModelId(serverModels)
: prev.selectedModelId
return { return {
...prev, ...prev,
providers: prev.providers.filter( providers: prev.providers.filter((p) => p.id !== providerId),
(p) => p.id !== providerId, selectedModelId: newSelectedId,
), }
selectedModelId: newSelectedId, })
} }, [])
})
},
[serverModels],
)
const addModel = useCallback( const addModel = useCallback(
(providerId: string, modelId: string): ModelConfig => { (providerId: string, modelId: string): ModelConfig => {
@@ -420,15 +334,14 @@ export function useModelConfig(): UseModelConfigReturn {
} }
: p, : p,
), ),
// Fall back to the default server model if the selected model // Clear selected model if it was deleted
// was deleted
selectedModelId: selectedModelId:
prev.selectedModelId === modelConfigId prev.selectedModelId === modelConfigId
? defaultServerModelId(serverModels) ? undefined
: prev.selectedModelId, : prev.selectedModelId,
})) }))
}, },
[serverModels], [],
) )
const resetConfig = useCallback(() => { const resetConfig = useCallback(() => {
@@ -515,8 +428,7 @@ export function getSelectedAIConfig(): {
let config: MultiModelConfig let config: MultiModelConfig
try { try {
// Unknown providers would break the model lookup below config = JSON.parse(stored)
config = parseStoredConfig(stored)
} catch { } catch {
return { ...empty, accessCode } return { ...empty, accessCode }
} }
+100 -207
View File
@@ -1,8 +1,6 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useDictionary } from "@/hooks/use-dictionary"
import { import {
type ChatSession, type ChatSession,
createEmptySession, createEmptySession,
@@ -13,12 +11,10 @@ import {
getSession, getSession,
isIndexedDBAvailable, isIndexedDBAvailable,
migrateFromLocalStorage, migrateFromLocalStorage,
readSessionCount,
type SessionMetadata, type SessionMetadata,
type StoredMessage, type StoredMessage,
saveSession, saveSession,
} from "@/lib/session-storage" } from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
export interface SessionData { export interface SessionData {
messages: StoredMessage[] messages: StoredMessage[]
@@ -28,13 +24,6 @@ export interface SessionData {
diagramHistory?: { svg: string; xml: string }[] diagramHistory?: { svg: string; xml: string }[]
} }
// Taken right before a save's data is read: the chat on screen then, and
// the order of the reads
export interface SaveTicket {
generation: number
seq: number
}
export interface UseSessionManagerReturn { export interface UseSessionManagerReturn {
// State // State
sessions: SessionMetadata[] sessions: SessionMetadata[]
@@ -46,27 +35,13 @@ export interface UseSessionManagerReturn {
// Actions // Actions
switchSession: (id: string) => Promise<SessionData | null> switchSession: (id: string) => Promise<SessionData | null>
deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }> deleteSession: (id: string) => Promise<{ wasCurrentSession: boolean }>
// ticket: getSaveTicket() before the data was read (by default, now). // forSessionId: optional session ID to verify save targets correct session (prevents stale debounce writes)
// The save is dropped if another chat is on screen when its turn comes,
// or if a copy of this chat read later was saved already.
// Resolves to false when the save failed (the user was told)
saveCurrentSession: ( saveCurrentSession: (
data: SessionData, data: SessionData,
ticket?: SaveTicket, forSessionId?: string | null,
) => Promise<boolean> ) => Promise<void>
refreshSessions: () => Promise<void> refreshSessions: () => Promise<void>
clearCurrentSession: () => void clearCurrentSession: () => void
getChatGeneration: () => number
getSaveTicket: () => SaveTicket
}
// 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 { interface UseSessionManagerOptions {
@@ -78,7 +53,6 @@ export function useSessionManager(
options: UseSessionManagerOptions = {}, options: UseSessionManagerOptions = {},
): UseSessionManagerReturn { ): UseSessionManagerReturn {
const { initialSessionId } = options const { initialSessionId } = options
const dict = useDictionary()
const [sessions, setSessions] = useState<SessionMetadata[]>([]) const [sessions, setSessions] = useState<SessionMetadata[]>([])
const [currentSessionId, setCurrentSessionId] = useState<string | null>( const [currentSessionId, setCurrentSessionId] = useState<string | null>(
null, null,
@@ -92,23 +66,6 @@ export function useSessionManager(
const isInitializedRef = useRef(false) const isInitializedRef = useRef(false)
// Sequence guard for URL changes - prevents out-of-order async resolution // Sequence guard for URL changes - prevents out-of-order async resolution
const urlChangeSequenceRef = useRef(0) const urlChangeSequenceRef = useRef(0)
// The chat on screen, read by saves that run after a render or a wait
const currentSessionRef = useRef<ChatSession | null>(null)
// Goes up each time another chat is put on screen (creating the
// session of the chat on screen does not count)
const chatGenerationRef = useRef(0)
// Saves run one at a time, so two saves of a new chat create it once
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve())
// The last ticket number, and that of the newest data saved
const saveSeqRef = useRef(0)
const savedSeqRef = useRef(0)
const changeChat = useCallback((session: ChatSession | null) => {
chatGenerationRef.current++
currentSessionRef.current = session
setCurrentSession(session)
setCurrentSessionId(session?.id ?? null)
}, [])
// Load sessions list // Load sessions list
const refreshSessions = useCallback(async () => { const refreshSessions = useCallback(async () => {
@@ -144,36 +101,14 @@ export function useSessionManager(
// Load sessions list // Load sessions list
const metadata = await getAllSessionMetadata() const metadata = await getAllSessionMetadata()
setSessions(metadata) setSessions(metadata)
// The desktop app may try its other port next launch, where
// an older version may have saved the chats: only when this
// origin surely has none (a failed read is not "none") and
// keeps no model settings or keys either
if (window.electronAPI?.chatsLoaded) {
const count = await readSessionCount()
// The app saves an empty config on its first load; the
// providers are what holds the keys, besides an access
// code
let hasSettings = true
try {
const config = JSON.parse(
localStorage.getItem(STORAGE_KEYS.modelConfigs) ??
"{}",
)
hasSettings =
(config.providers?.length ?? 0) > 0 ||
!!localStorage.getItem(STORAGE_KEYS.accessCode)
} catch {
// Unreadable: treat as settings, and stay
}
if (count !== null && !hasSettings) {
window.electronAPI.chatsLoaded(count).catch(() => {})
}
}
// Only load a session if initialSessionId is provided (from URL param) // Only load a session if initialSessionId is provided (from URL param)
if (initialSessionId) { if (initialSessionId) {
const session = await getSession(initialSessionId) const session = await getSession(initialSessionId)
if (session) changeChat(session) if (session) {
setCurrentSession(session)
setCurrentSessionId(session.id)
}
// If session not found, stay in blank state (URL has invalid session ID) // If session not found, stay in blank state (URL has invalid session ID)
} }
// If no initialSessionId, start with blank state (no auto-restore) // If no initialSessionId, start with blank state (no auto-restore)
@@ -185,7 +120,7 @@ export function useSessionManager(
} }
init() init()
}, [initialSessionId, changeChat]) }, [initialSessionId])
// Handle URL session ID changes after initialization // Handle URL session ID changes after initialization
// Note: intentionally NOT including currentSessionId in deps to avoid race conditions // Note: intentionally NOT including currentSessionId in deps to avoid race conditions
@@ -200,7 +135,6 @@ export function useSessionManager(
async function handleSessionIdChange() { async function handleSessionIdChange() {
if (initialSessionId) { if (initialSessionId) {
const generation = chatGenerationRef.current
// URL has session ID - load it // URL has session ID - load it
const session = await getSession(initialSessionId) const session = await getSession(initialSessionId)
@@ -209,13 +143,16 @@ export function useSessionManager(
if (currentSequence !== urlChangeSequenceRef.current) { if (currentSequence !== urlChangeSequenceRef.current) {
return return
} }
// Another chat was put on screen meanwhile (New Chat right
// after this one got its session id in the URL): keep it
if (generation !== chatGenerationRef.current) return
// Only update if the session is different from current if (session) {
if (session && currentSessionRef.current?.id !== session.id) { // Only update if the session is different from current
changeChat(session) setCurrentSessionId((current) => {
if (current !== session.id) {
setCurrentSession(session)
return session.id
}
return current
})
} }
} }
// Removed: else clause that clears session // Removed: else clause that clears session
@@ -224,17 +161,11 @@ export function useSessionManager(
} }
handleSessionIdChange() handleSessionIdChange()
}, [initialSessionId, isAvailable, changeChat]) }, [initialSessionId, isAvailable])
// Refresh sessions on window focus (multi-tab sync), at most once per interval // Refresh sessions on window focus (multi-tab sync)
const lastFocusRefreshRef = useRef(0)
useEffect(() => { useEffect(() => {
const handleFocus = () => { const handleFocus = () => {
const now = Date.now()
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
return
}
lastFocusRefreshRef.current = now
refreshSessions() refreshSessions()
} }
window.addEventListener("focus", handleFocus) window.addEventListener("focus", handleFocus)
@@ -246,11 +177,9 @@ export function useSessionManager(
async (id: string): Promise<SessionData | null> => { async (id: string): Promise<SessionData | null> => {
if (id === currentSessionId) return null if (id === currentSessionId) return null
// Save current session first if it has messages (as saved // Save current session first if it has messages
// last: the caller may have just saved it) if (currentSession && currentSession.messages.length > 0) {
const current = currentSessionRef.current await saveSession(currentSession)
if (current && current.messages.length > 0) {
await saveSession(current)
} }
// Load the target session // Load the target session
@@ -260,7 +189,9 @@ export function useSessionManager(
return null return null
} }
changeChat(session) // Update state
setCurrentSession(session)
setCurrentSessionId(session.id)
return { return {
messages: session.messages, messages: session.messages,
@@ -270,7 +201,7 @@ export function useSessionManager(
diagramHistory: session.diagramHistory, diagramHistory: session.diagramHistory,
} }
}, },
[currentSessionId, changeChat], [currentSessionId, currentSession],
) )
// Delete a session // Delete a session
@@ -280,137 +211,101 @@ export function useSessionManager(
await deleteSessionFromDB(id) await deleteSessionFromDB(id)
// If deleting current session, clear state (caller will show new empty session) // If deleting current session, clear state (caller will show new empty session)
if (wasCurrentSession) changeChat(null) if (wasCurrentSession) {
setCurrentSession(null)
setCurrentSessionId(null)
}
await refreshSessions() await refreshSessions()
return { wasCurrentSession } return { wasCurrentSession }
}, },
[currentSessionId, refreshSessions, changeChat], [currentSessionId, refreshSessions],
) )
// Save current session data (debounced externally by caller) // Save current session data (debounced externally by caller)
// forSessionId: if provided, verify save targets correct session (prevents stale debounce writes)
const saveCurrentSession = useCallback( const saveCurrentSession = useCallback(
(data: SessionData, ticket?: SaveTicket): Promise<boolean> => { async (
// The data is of the chat on screen when it was read data: SessionData,
const { generation, seq } = ticket ?? { forSessionId?: string | null,
generation: chatGenerationRef.current, ): Promise<void> => {
seq: ++saveSeqRef.current, // If forSessionId is provided, verify it matches current session
// This prevents stale debounced saves from overwriting a newly switched session
if (
forSessionId !== undefined &&
forSessionId !== currentSessionId
) {
return
} }
const run = async (): Promise<boolean> => {
// That chat is no longer on screen (leaving it saved it)
if (generation !== chatGenerationRef.current) return true
// A copy read later was saved already (one that waited for
// its thumbnail must not undo it)
if (seq < savedSeqRef.current) return true
// Nothing can be stored without IndexedDB
if (!isIndexedDBAvailable()) return true
// The user may put another chat on screen while this one is
// written; the stored copy is still right, the state is not
const stillOnScreen = () =>
chatGenerationRef.current === generation
const currentSession = currentSessionRef.current
if (!currentSession) { if (!currentSession) {
// Create a new session if none exists // Create a new session if none exists
const newSession: ChatSession = { const newSession: ChatSession = {
...createEmptySession(), ...createEmptySession(),
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl: data.thumbnailDataUrl,
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 false
}
savedSeqRef.current = seq
await enforceSessionLimit()
if (stillOnScreen()) {
currentSessionRef.current = newSession
setCurrentSession(newSession)
setCurrentSessionId(newSession.id)
}
await refreshSessions()
return true
}
// Update existing session
const updatedSession: ChatSession = {
...currentSession,
messages: data.messages, messages: data.messages,
xmlSnapshots: data.xmlSnapshots, xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml, diagramXml: data.diagramXml,
thumbnailDataUrl: thumbnailDataUrl: data.thumbnailDataUrl,
data.thumbnailDataUrl ?? diagramHistory: data.diagramHistory,
currentSession.thumbnailDataUrl, title: extractTitle(data.messages),
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
} }
await saveSession(newSession)
if (!(await saveSession(updatedSession))) { await enforceSessionLimit()
notifySaveFailed(dict.errors.sessionSaveFailed) setCurrentSession(newSession)
return false setCurrentSessionId(newSession.id)
} await refreshSessions()
savedSeqRef.current = seq return
if (stillOnScreen()) {
currentSessionRef.current = updatedSession
setCurrentSession(updatedSession)
}
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length >
0,
thumbnailDataUrl:
updatedSession.thumbnailDataUrl,
}
: s,
),
)
return true
} }
const result = saveQueueRef.current.then(run)
saveQueueRef.current = result.catch(() => {}) // Update existing session
return result const updatedSession: ChatSession = {
...currentSession,
messages: data.messages,
xmlSnapshots: data.xmlSnapshots,
diagramXml: data.diagramXml,
thumbnailDataUrl:
data.thumbnailDataUrl ?? currentSession.thumbnailDataUrl,
diagramHistory:
data.diagramHistory ?? currentSession.diagramHistory,
updatedAt: Date.now(),
// Update title if it's still default and we have messages
title:
currentSession.title === "New Chat" &&
data.messages.length > 0
? extractTitle(data.messages)
: currentSession.title,
}
await saveSession(updatedSession)
setCurrentSession(updatedSession)
// Update sessions list metadata
setSessions((prev) =>
prev.map((s) =>
s.id === updatedSession.id
? {
...s,
title: updatedSession.title,
updatedAt: updatedSession.updatedAt,
messageCount: updatedSession.messages.length,
hasDiagram:
!!updatedSession.diagramXml &&
updatedSession.diagramXml.trim().length > 0,
thumbnailDataUrl: updatedSession.thumbnailDataUrl,
}
: s,
),
)
}, },
[refreshSessions, dict], [currentSession, currentSessionId, refreshSessions],
) )
// Clear current session state (for starting fresh without loading another session) // Clear current session state (for starting fresh without loading another session)
const clearCurrentSession = useCallback(() => { const clearCurrentSession = useCallback(() => {
changeChat(null) setCurrentSession(null)
}, [changeChat]) setCurrentSessionId(null)
}, [])
const getChatGeneration = useCallback(() => chatGenerationRef.current, [])
const getSaveTicket = useCallback(
(): SaveTicket => ({
generation: chatGenerationRef.current,
seq: ++saveSeqRef.current,
}),
[],
)
return { return {
sessions, sessions,
@@ -423,7 +318,5 @@ export function useSessionManager(
saveCurrentSession, saveCurrentSession,
refreshSessions, refreshSessions,
clearCurrentSession, clearCurrentSession,
getChatGeneration,
getSaveTicket,
} }
} }
-18
View File
@@ -6,7 +6,6 @@
import { experimental_useObject as useObject } from "@ai-sdk/react" import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react" import { useCallback, useRef } from "react"
import { getSelectedAIConfig } from "@/hooks/use-model-config"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { import {
type ValidationResult, type ValidationResult,
@@ -40,8 +39,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { object, submit, isLoading, error, stop } = useObject({ const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"), api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema, schema: ValidationResultSchema,
// Resolved per request so a changed access code is picked up
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
onFinish: ({ onFinish: ({
object, object,
error: finishError, error: finishError,
@@ -103,22 +100,9 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
[submit], [submit],
) )
/**
* End a running check (the user pressed Stop): its promise rejects with
* an AbortError, so the tool handler can finish at once.
*/
const cancel = useCallback(() => {
const pending = pendingValidationRef.current
if (!pending) return
pendingValidationRef.current = null
stop()
pending.reject(new DOMException("Validation cancelled", "AbortError"))
}, [stop])
/** /**
* Validate with fallback - returns default valid result on error. * Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures. * Use this to avoid blocking the user on validation failures.
* A cancelled check is passed on as its AbortError.
*/ */
const validateWithFallback = useCallback( const validateWithFallback = useCallback(
async ( async (
@@ -128,7 +112,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
try { try {
return await validate(imageData, sessionId) return await validate(imageData, sessionId)
} catch (error) { } catch (error) {
if ((error as Error)?.name === "AbortError") throw error
console.warn( console.warn(
"[useValidateDiagram] Validation failed, using fallback:", "[useValidateDiagram] Validation failed, using fallback:",
error, error,
@@ -144,7 +127,6 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
validate, validate,
validateWithFallback, validateWithFallback,
stop, stop,
cancel,
// State // State
isValidating: isLoading, isValidating: isLoading,
-50
View File
@@ -1,50 +0,0 @@
/**
* Refuse a POST that a page on another website could have sent. A browser
* sends a cross-site POST without asking first (CORS preflight) only with a
* text or form body, so the routes take JSON only. In the desktop app also
* refuse a foreign Host: a site that points its own domain name at
* 127.0.0.1 (DNS rebinding) is same-origin with the local server, but its
* requests carry that domain. A request the server builds itself has no
* Host. Returns the response to send, or null when the request may go on.
*/
export function rejectCrossSite(req: Request): Response | null {
const contentType = req.headers.get("content-type") ?? ""
if (!/^\s*application\/json\b/i.test(contentType)) {
return Response.json(
{ error: "Content-Type must be application/json" },
{ status: 415 },
)
}
const host = req.headers.get("host")
if (
process.env.NEXT_AI_DRAWIO_DESKTOP === "1" &&
host &&
!/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host)
) {
return Response.json({ error: "Forbidden" }, { status: 403 })
}
return null
}
/**
* 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 },
)
}
+7 -19
View File
@@ -2,7 +2,6 @@ import { z } from "zod"
import { import {
ProviderNameSchema, ProviderNameSchema,
type ServerModelsConfig, type ServerModelsConfig,
slugify,
} from "@/lib/server-model-config" } from "@/lib/server-model-config"
import { import {
FIXED_CRED_PROVIDERS, FIXED_CRED_PROVIDERS,
@@ -183,15 +182,12 @@ export function validateAdminProviders(
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.` 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 names = list.map((p) => displayName(p))
const slugs = names.map(slugify) if (new Set(names).size !== names.length) {
if (new Set(slugs).size !== slugs.length) { return "Provider display names must be unique."
return "Provider display names must be unique (ignoring case and punctuation)."
} }
const envSlugs = new Set(envProviders.map((p) => slugify(p.name))) const envNames = new Set(envProviders.map((p) => p.name))
const clash = names.find((_, i) => envSlugs.has(slugs[i])) const clash = names.find((n) => envNames.has(n))
if (clash) { if (clash) {
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.` return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
} }
@@ -244,14 +240,10 @@ export function deriveEnvUpdates(
indexByProvider.set(p.provider, index + 1) indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") { if (p.provider === "bedrock") {
// ADMIN_ names keep the standard AWS_* vars untouched, so other if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
// 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) if (p.awsSecretAccessKey)
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion if (p.awsRegion) updates.AWS_REGION = p.awsRegion
} else if (p.provider === "vertexai") { } else if (p.provider === "vertexai") {
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
@@ -292,10 +284,6 @@ function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
const index = indexByProvider.get(p.provider) ?? 0 const index = indexByProvider.get(p.provider) ?? 0
indexByProvider.set(p.provider, index + 1) indexByProvider.set(p.provider, index + 1)
if (p.provider === "bedrock") { 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_ACCESS_KEY_ID")
keys.add("AWS_SECRET_ACCESS_KEY") keys.add("AWS_SECRET_ACCESS_KEY")
keys.add("AWS_REGION") keys.add("AWS_REGION")
+19 -34
View File
@@ -10,27 +10,13 @@ interface SettingsFile {
values: Record<string, string> values: Record<string, string>
} }
interface SettingsState { // Original env values snapshotted before the first overlay, so removing a
// Original env values snapshotted before the first overlay, so removing // key from the settings file restores the env default. null = was unset.
// a key from the settings file restores the env default. null = was unset. const originalEnv: Record<string, string | null> = {}
originalEnv: Record<string, string | null> // Keys currently overlaid, so we can restore ones removed from the file.
// Keys currently overlaid, so we can restore ones removed from the file. let overlaidKeys = new Set<string>()
overlaidKeys: Set<string>
cachedSettings: Record<string, string> | null
}
// Kept on globalThis because the build can load this module more than once let cachedSettings: Record<string, string> | null = null
// (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 { export function getSettingsPath(): string {
const custom = process.env.SETTINGS_FILE const custom = process.env.SETTINGS_FILE
@@ -39,7 +25,7 @@ export function getSettingsPath(): string {
} }
export function loadSettings(): Record<string, string> { export function loadSettings(): Record<string, string> {
if (state.cachedSettings) return state.cachedSettings if (cachedSettings) return cachedSettings
try { try {
const raw = fs.readFileSync(getSettingsPath(), "utf8") const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile const parsed = JSON.parse(raw) as SettingsFile
@@ -57,22 +43,21 @@ export function loadSettings(): Record<string, string> {
for (const [key, value] of Object.entries(rawValues)) { for (const [key, value] of Object.entries(rawValues)) {
if (typeof value === "string") values[key] = value if (typeof value === "string") values[key] = value
} }
state.cachedSettings = values cachedSettings = values
} catch (err: any) { } catch (err: any) {
if (err?.code !== "ENOENT") { if (err?.code !== "ENOENT") {
console.error("[admin-settings] Failed to read settings file:", err) console.error("[admin-settings] Failed to read settings file:", err)
} }
state.cachedSettings = {} cachedSettings = {}
} }
return state.cachedSettings return cachedSettings
} }
export function applyToEnv(): void { export function applyToEnv(): void {
const values = loadSettings() const values = loadSettings()
const { originalEnv } = state
// Restore env for keys that were overlaid before but are now gone // Restore env for keys that were overlaid before but are now gone
for (const key of state.overlaidKeys) { for (const key of overlaidKeys) {
if (!(key in values)) { if (!(key in values)) {
const original = originalEnv[key] const original = originalEnv[key]
if (original === null) delete process.env[key] if (original === null) delete process.env[key]
@@ -87,12 +72,12 @@ export function applyToEnv(): void {
process.env[key] = value process.env[key] = value
} }
state.overlaidKeys = new Set(Object.keys(values)) overlaidKeys = new Set(Object.keys(values))
} }
// The effective env value if the file entry were removed (for fallback display) // The effective env value if the file entry were removed (for fallback display)
export function getEnvFallback(key: string): string | null { export function getEnvFallback(key: string): string | null {
if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null if (overlaidKeys.has(key)) return originalEnv[key] ?? null
return process.env[key] ?? null return process.env[key] ?? null
} }
@@ -116,7 +101,7 @@ export function saveSettings(updates: Record<string, string | null>): void {
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 }) fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
fs.renameSync(tmpPath, filePath) fs.renameSync(tmpPath, filePath)
state.cachedSettings = current cachedSettings = current
applyToEnv() applyToEnv()
} }
@@ -137,13 +122,13 @@ export function isSettingsWritable(): boolean {
// Test-only: reset module state // Test-only: reset module state
export function _resetForTests(): void { export function _resetForTests(): void {
state.cachedSettings = null cachedSettings = null
writableCache = null writableCache = null
for (const key of state.overlaidKeys) { for (const key of overlaidKeys) {
const original = state.originalEnv[key] const original = originalEnv[key]
if (original === null) delete process.env[key] if (original === null) delete process.env[key]
else if (original !== undefined) process.env[key] = original else if (original !== undefined) process.env[key] = original
} }
state.overlaidKeys = new Set() overlaidKeys = new Set()
state.originalEnv = {} for (const key of Object.keys(originalEnv)) delete originalEnv[key]
} }
+765 -523
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
export const AIHUBMIX_MODELS_ENDPOINT = "https://aihubmix.com/api/v1/models"
const NON_CHAT_MODEL_TYPES = new Set([
"embedding",
"image_generation",
"rerank",
"transcription",
"tts",
"video",
])
type AihubmixModelListPayload = {
data?: unknown
}
type AihubmixModelRecord = {
model_id?: unknown
types?: unknown
}
function getModelTypes(types: unknown): Set<string> {
if (typeof types !== "string") {
return new Set()
}
return new Set(
types
.split(",")
.map((type) => type.trim())
.filter(Boolean),
)
}
function isChatModel(record: AihubmixModelRecord): record is {
model_id: string
types: string
} {
if (typeof record.model_id !== "string" || !record.model_id.trim()) {
return false
}
const types = getModelTypes(record.types)
if (!types.has("llm")) {
return false
}
return !Array.from(NON_CHAT_MODEL_TYPES).some((type) => types.has(type))
}
export function extractAihubmixModelIds(payload: unknown): string[] {
const data = (payload as AihubmixModelListPayload)?.data
if (!Array.isArray(data)) {
return []
}
const seen = new Set<string>()
const modelIds: string[] = []
for (const item of data) {
if (!item || typeof item !== "object") {
continue
}
const record = item as AihubmixModelRecord
if (!isChatModel(record)) {
continue
}
const modelId = record.model_id.trim()
if (seen.has(modelId)) {
continue
}
seen.add(modelId)
modelIds.push(modelId)
}
return modelIds
}
-10
View File
@@ -1,8 +1,6 @@
export interface CachedResponse { export interface CachedResponse {
promptText: string promptText: string
hasImage: boolean hasImage: boolean
// Name of the bundled example file the prompt is sent with
fileName?: string
xml: string xml: string
} }
@@ -256,7 +254,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{ {
promptText: "Replicate this in aws style", promptText: "Replicate this in aws style",
hasImage: true, 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"> 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"/> <mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
</mxCell> </mxCell>
@@ -321,7 +318,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{ {
promptText: "Replicate this flowchart.", promptText: "Replicate this flowchart.",
hasImage: true, 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"> 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"/> <mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
</mxCell> </mxCell>
@@ -383,7 +379,6 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
{ {
promptText: "Summarize this paper as a diagram", promptText: "Summarize this paper as a diagram",
hasImage: true, hasImage: true,
fileName: "chain-of-thought.txt",
xml: `<mxCell id="title_bg" parent="1" xml: `<mxCell id="title_bg" parent="1"
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
value="" vertex="1"> 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( export function findCachedResponse(
promptText: string, promptText: string,
hasImage: boolean, hasImage: boolean,
fileName?: string,
): CachedResponse | undefined { ): CachedResponse | undefined {
return CACHED_EXAMPLE_RESPONSES.find( return CACHED_EXAMPLE_RESPONSES.find(
(c) => (c) =>
c.promptText === promptText && c.promptText === promptText &&
c.hasImage === hasImage && c.hasImage === hasImage &&
(!c.fileName || c.fileName === fileName) &&
c.xml !== "", c.xml !== "",
) )
} }
+45 -91
View File
@@ -6,37 +6,25 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export const MAX_FILES = 5 export const MAX_FILES = 5
// Helper function to validate file parts in messages // Helper function to validate file parts in messages
// Checks every message, since history is sent to the model too
export function validateFileParts(messages: any[]): { export function validateFileParts(messages: any[]): {
valid: boolean valid: boolean
error?: string error?: string
} { } {
for (const message of messages) { const lastMessage = messages[messages.length - 1]
const fileParts = const fileParts =
message?.parts?.filter((p: any) => p.type === "file") || [] lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) { if (fileParts.length > MAX_FILES) {
return { return {
valid: false, valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`, error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
} }
}
for (const filePart of fileParts) { for (const filePart of fileParts) {
// The client sends files inline. Any other URL would be downloaded // Data URLs format: data:image/png;base64,<data>
// by the server (AI SDK does that for models without URL support). // Base64 increases size by ~33%, so we check the decoded size
if ( if (filePart.url?.startsWith("data:")) {
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
const base64Data = filePart.url.split(",")[1] const base64Data = filePart.url.split(",")[1]
if (base64Data) { if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4) const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
@@ -53,83 +41,49 @@ export function validateFileParts(messages: any[]): {
return { valid: true } return { valid: true }
} }
// A tool-call input providers accept: a non-empty JSON object // Helper function to check if diagram is minimal/empty
function isValidToolInput(input: unknown): boolean { export function isMinimalDiagram(xml: string): boolean {
return !!input && typeof input === "object" && Object.keys(input).length > 0 const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
} }
// Helper function to replace historical tool call XML with placeholders // 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) // 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[] { export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => { return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) { if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg return msg
} }
const replacedContent = msg.content.map((part: any) => { const replacedContent = msg.content
if ( .map((part: any) => {
part.type === "tool-call" && if (part.type === "tool-call") {
isValidToolInput(part.input) && const toolName = part.toolName
(part.toolName === "display_diagram" || // Fix invalid/undefined inputs from interrupted streaming
part.toolName === "edit_diagram") if (
) { !part.input ||
return { typeof part.input !== "object" ||
...part, Object.keys(part.input).length === 0
input: { ) {
placeholder: // Skip tool calls with invalid inputs entirely
"[XML content replaced - see current diagram XML in system context]", 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 } 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)
}
},
},
})
}
+1 -4
View File
@@ -64,13 +64,10 @@ interface QuotaCheckResult {
* Check all quotas and increment request count atomically. * Check all quotas and increment request count atomically.
* Uses composite key (PK=user, SK=date) for per-day tracking. * Uses composite key (PK=user, SK=date) for per-day tracking.
* Each day automatically gets a new item - no explicit reset needed. * Each day automatically gets a new item - no explicit reset needed.
* A request limit of 0 means none; increment 0 checks the limits without
* counting a request (the screenshot check).
*/ */
export async function checkAndIncrementRequest( export async function checkAndIncrementRequest(
ip: string, ip: string,
limits: QuotaLimits, limits: QuotaLimits,
increment = 1,
): Promise<QuotaCheckResult> { ): Promise<QuotaCheckResult> {
// Skip if quota tracking not enabled // Skip if quota tracking not enabled
if (!client || !TABLE) { if (!client || !TABLE) {
@@ -102,7 +99,7 @@ export async function checkAndIncrementRequest(
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit) attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`, `,
ExpressionAttributeValues: { ExpressionAttributeValues: {
":one": { N: String(increment) }, ":one": { N: "1" },
":minute": { S: currentMinute }, ":minute": { S: currentMinute },
":reqLimit": { N: String(limits.requests || 999999) }, ":reqLimit": { N: String(limits.requests || 999999) },
":tokenLimit": { N: String(limits.tokens || 999999) }, ":tokenLimit": { N: String(limits.tokens || 999999) },
+5 -30
View File
@@ -34,8 +34,7 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu", "qiniu": "Qiniu"
"mimo": "MiMo (Xiaomi)"
}, },
"chat": { "chat": {
"placeholder": "Describe your diagram or upload a file...", "placeholder": "Describe your diagram or upload a file...",
@@ -132,8 +131,6 @@
"customSystemMessage": "Custom System Message", "customSystemMessage": "Custom System Message",
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.", "customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...", "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", "panelVisibility": "Lobby Panels",
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.", "panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
"showRecentChats": "Recent Chats", "showRecentChats": "Recent Chats",
@@ -178,6 +175,9 @@
"networkError": "Network error. Please check your connection.", "networkError": "Network error. Please check your connection.",
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.", "retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.", "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.", "sessionCorrupted": "Session data was corrupted. Starting fresh.",
"failedToSave": "Failed to save messages to localStorage", "failedToSave": "Failed to save messages to localStorage",
"failedToRestore": "Failed to restore from localStorage", "failedToRestore": "Failed to restore from localStorage",
@@ -185,25 +185,7 @@
"failedToExport": "Error fetching chart data", "failedToExport": "Error fetching chart data",
"failedToLoadExample": "Error loading example image", "failedToLoadExample": "Error loading example image",
"failedToRecordFeedback": "Failed to record your feedback. Please try again.", "failedToRecordFeedback": "Failed to record your feedback. Please try again.",
"storageUpdateFailed": "Chat cleared but browser storage could not be updated", "storageUpdateFailed": "Chat cleared but browser storage could not be updated"
"sessionSaveFailed": "Could not save this chat. Browser storage may be full: delete old chats from history and try again.",
"sessionSaveFailedLeave": "Could not save this chat. Browser storage may be full. You can go on without saving it, then delete old chats from the list in the new chat.",
"continueWithoutSaving": "Continue without saving",
"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"
}
}, },
"quota": { "quota": {
"dailyLimit": "Daily Quota Reached", "dailyLimit": "Daily Quota Reached",
@@ -250,7 +232,6 @@
"reasoning": { "reasoning": {
"thinking": "Thinking...", "thinking": "Thinking...",
"thoughtFor": "Thought for {duration} seconds", "thoughtFor": "Thought for {duration} seconds",
"thoughtForOne": "Thought for 1 second",
"thoughtBrief": "Thought for a few seconds" "thoughtBrief": "Thought for a few seconds"
}, },
"dev": { "dev": {
@@ -387,15 +368,9 @@
"enterSecretKey": "Enter your secret access key", "enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL", "baseUrl": "Base URL",
"optional": "(optional)", "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})", "baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL", "customEndpoint": "Custom endpoint URL",
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API", "minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
"models": "Models", "models": "Models",
"customModelId": "Custom model ID...", "customModelId": "Custom model ID...",
"allAdded": "All added", "allAdded": "All added",
+5 -30
View File
@@ -34,8 +34,7 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu", "qiniu": "Qiniu"
"mimo": "MiMo (Xiaomi)"
}, },
"chat": { "chat": {
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
@@ -132,8 +131,6 @@
"customSystemMessage": "カスタムシステムメッセージ", "customSystemMessage": "カスタムシステムメッセージ",
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。", "customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...", "customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
"maxOutputTokens": "最大出力トークン数",
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
"panelVisibility": "ロビーパネル", "panelVisibility": "ロビーパネル",
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。", "panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
"showRecentChats": "最近のチャット", "showRecentChats": "最近のチャット",
@@ -178,6 +175,9 @@
"networkError": "ネットワークエラー。接続を確認してください。", "networkError": "ネットワークエラー。接続を確認してください。",
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。", "retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。", "continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。", "sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
"failedToSave": "localStorage へのメッセージの保存に失敗しました", "failedToSave": "localStorage へのメッセージの保存に失敗しました",
"failedToRestore": "localStorage からの復元に失敗しました", "failedToRestore": "localStorage からの復元に失敗しました",
@@ -185,25 +185,7 @@
"failedToExport": "チャートデータの取得エラー", "failedToExport": "チャートデータの取得エラー",
"failedToLoadExample": "例の画像の読み込みエラー", "failedToLoadExample": "例の画像の読み込みエラー",
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。", "failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした", "storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。",
"sessionSaveFailedLeave": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。保存せずに続けて、新しいチャットの一覧から古いチャットを削除できます。",
"continueWithoutSaving": "保存せずに続ける",
"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": "モデル設定を開く"
}
}, },
"quota": { "quota": {
"dailyLimit": "1日の割当量に達しました", "dailyLimit": "1日の割当量に達しました",
@@ -250,7 +232,6 @@
"reasoning": { "reasoning": {
"thinking": "考え中...", "thinking": "考え中...",
"thoughtFor": "{duration} 秒考えました", "thoughtFor": "{duration} 秒考えました",
"thoughtForOne": "1 秒考えました",
"thoughtBrief": "数秒考えました" "thoughtBrief": "数秒考えました"
}, },
"dev": { "dev": {
@@ -341,15 +322,9 @@
"enterSecretKey": "シークレットアクセスキーを入力", "enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL", "baseUrl": "ベース URL",
"optional": "(オプション)", "optional": "(オプション)",
"getApiKey": "API キーを取得",
"fetchModels": "プロバイダーからモデル一覧を取得",
"noTools": "ツール呼び出し非対応",
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
"requestUrl": "リクエスト先: {url}",
"baseUrlWithExample": "ベース URL(オプション、例: {example})", "baseUrlWithExample": "ベース URL(オプション、例: {example})",
"customEndpoint": "カスタムエンドポイント URL", "customEndpoint": "カスタムエンドポイント URL",
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用", "minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
"models": "モデル", "models": "モデル",
"customModelId": "カスタムモデル ID...", "customModelId": "カスタムモデル ID...",
"allAdded": "すべて追加済み", "allAdded": "すべて追加済み",
+5 -30
View File
@@ -34,8 +34,7 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu", "qiniu": "Qiniu"
"mimo": "MiMo (小米)"
}, },
"chat": { "chat": {
"placeholder": "描述您的圖表或上傳檔案...", "placeholder": "描述您的圖表或上傳檔案...",
@@ -132,8 +131,6 @@
"customSystemMessage": "自訂系統訊息", "customSystemMessage": "自訂系統訊息",
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。", "customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...", "customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
"maxOutputTokens": "最大輸出 token 數",
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
"panelVisibility": "大廳面板", "panelVisibility": "大廳面板",
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。", "panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
"showRecentChats": "最近聊天", "showRecentChats": "最近聊天",
@@ -178,6 +175,9 @@
"networkError": "網路錯誤。請檢查您的連線。", "networkError": "網路錯誤。請檢查您的連線。",
"retryLimit": "已達自動重試限制({max})。請手動重試。", "retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。", "continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。", "sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage", "failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原", "failedToRestore": "無法從 localStorage 還原",
@@ -185,25 +185,7 @@
"failedToExport": "取得圖表資料時出錯", "failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯", "failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。", "failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間", "storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。",
"sessionSaveFailedLeave": "無法儲存這個對話,瀏覽器儲存空間可能已滿。可以不儲存它、直接繼續,再在新對話的列表裡刪除舊對話。",
"continueWithoutSaving": "不儲存,繼續",
"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": "開啟模型設定"
}
}, },
"quota": { "quota": {
"dailyLimit": "已達每日配額", "dailyLimit": "已達每日配額",
@@ -250,7 +232,6 @@
"reasoning": { "reasoning": {
"thinking": "思考中...", "thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒", "thoughtFor": "思考了 {duration} 秒",
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了幾秒鐘" "thoughtBrief": "思考了幾秒鐘"
}, },
"dev": { "dev": {
@@ -387,15 +368,9 @@
"enterSecretKey": "輸入您的 Secret Key", "enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL", "baseUrl": "基礎 URL",
"optional": "(可選)", "optional": "(可選)",
"getApiKey": "取得 API Key",
"fetchModels": "從服務商取得模型清單",
"noTools": "不支援工具呼叫",
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
"requestUrl": "請求將傳送至 {url}",
"baseUrlWithExample": "基礎 URL(可選,例如 {example})", "baseUrlWithExample": "基礎 URL(可選,例如 {example})",
"customEndpoint": "自訂端點 URL", "customEndpoint": "自訂端點 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API", "minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型", "models": "模型",
"customModelId": "自訂模型 ID...", "customModelId": "自訂模型 ID...",
"allAdded": "已全部新增", "allAdded": "已全部新增",
+5 -30
View File
@@ -34,8 +34,7 @@
"glm": "GLM", "glm": "GLM",
"qwen": "Qwen", "qwen": "Qwen",
"kimi": "Kimi", "kimi": "Kimi",
"qiniu": "Qiniu", "qiniu": "Qiniu"
"mimo": "MiMo (小米)"
}, },
"chat": { "chat": {
"placeholder": "描述您的图表或上传文件...", "placeholder": "描述您的图表或上传文件...",
@@ -132,8 +131,6 @@
"customSystemMessage": "自定义系统消息", "customSystemMessage": "自定义系统消息",
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。", "customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...", "customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
"maxOutputTokens": "最大输出 token 数",
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
"panelVisibility": "大厅面板", "panelVisibility": "大厅面板",
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。", "panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
"showRecentChats": "最近聊天", "showRecentChats": "最近聊天",
@@ -178,6 +175,9 @@
"networkError": "网络错误。请检查您的连接。", "networkError": "网络错误。请检查您的连接。",
"retryLimit": "已达到自动重试限制({max})。请手动重试。", "retryLimit": "已达到自动重试限制({max})。请手动重试。",
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。", "continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
"validationFailed": "图表验证失败。请尝试重新生成。",
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
"failedToProcess": "无法处理图表。请尝试重新生成。",
"sessionCorrupted": "会话数据已损坏。重新开始。", "sessionCorrupted": "会话数据已损坏。重新开始。",
"failedToSave": "无法保存消息到 localStorage", "failedToSave": "无法保存消息到 localStorage",
"failedToRestore": "无法从 localStorage 恢复", "failedToRestore": "无法从 localStorage 恢复",
@@ -185,25 +185,7 @@
"failedToExport": "获取图表数据时出错", "failedToExport": "获取图表数据时出错",
"failedToLoadExample": "加载示例图片时出错", "failedToLoadExample": "加载示例图片时出错",
"failedToRecordFeedback": "记录您的反馈失败。请重试。", "failedToRecordFeedback": "记录您的反馈失败。请重试。",
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储", "storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。",
"sessionSaveFailedLeave": "无法保存这个对话,浏览器存储空间可能已满。可以不保存它、直接继续,再在新对话的列表里删除旧对话。",
"continueWithoutSaving": "不保存,继续",
"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": "打开模型设置"
}
}, },
"quota": { "quota": {
"dailyLimit": "已达每日配额", "dailyLimit": "已达每日配额",
@@ -250,7 +232,6 @@
"reasoning": { "reasoning": {
"thinking": "思考中...", "thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒", "thoughtFor": "思考了 {duration} 秒",
"thoughtForOne": "思考了 1 秒",
"thoughtBrief": "思考了几秒钟" "thoughtBrief": "思考了几秒钟"
}, },
"dev": { "dev": {
@@ -387,15 +368,9 @@
"enterSecretKey": "输入您的 Secret Key", "enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL", "baseUrl": "基础 URL",
"optional": "(可选)", "optional": "(可选)",
"getApiKey": "获取 API Key",
"fetchModels": "从服务商获取模型列表",
"noTools": "不支持工具调用",
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
"requestUrl": "请求将发往 {url}",
"baseUrlWithExample": "基础 URL(可选,例如 {example})", "baseUrlWithExample": "基础 URL(可选,例如 {example})",
"customEndpoint": "自定义端点 URL", "customEndpoint": "自定义端点 URL",
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API", "minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
"models": "模型", "models": "模型",
"customModelId": "自定义模型 ID...", "customModelId": "自定义模型 ID...",
"allAdded": "已全部添加", "allAdded": "已全部添加",
+1 -8
View File
@@ -51,15 +51,8 @@ export function setTraceOutput(output: string) {
if (!isLangfuseEnabled()) return if (!isLangfuseEnabled()) return
updateActiveTrace({ output }) 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() const activeSpan = api.trace.getActiveSpan()
if (activeSpan) { if (activeSpan) {
activeSpan.end() activeSpan.end()
-187
View File
@@ -1,187 +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",
],
// "too many tokens": Bedrock's throttling
[/rate limit|too many requests|too many tokens/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. Other
// strings come from providers (DeepSeek's SDK sends stream errors so).
if (
typeof error === "string" &&
/^(Invalid input for tool|Model tried to call unavailable tool)/.test(
error,
)
) {
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) // Maximum characters allowed for extracted text (configurable via env)
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
@@ -14,7 +14,6 @@ const TEXT_EXTENSIONS = [
".json", ".json",
".csv", ".csv",
".xml", ".xml",
".svg",
".html", ".html",
".css", ".css",
".js", ".js",
@@ -44,10 +43,8 @@ const TEXT_EXTENSIONS = [
*/ */
export async function extractPdfText(file: File): Promise<string> { export async function extractPdfText(file: File): Promise<string> {
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
// Pass raw bytes so unpdf destroys the PDF document when it is done const pdf = await getDocumentProxy(new Uint8Array(buffer))
const { text } = await extractText(new Uint8Array(buffer), { const { text } = await extractText(pdf, { mergePages: true })
mergePages: true,
})
return text as string return text as string
} }
-255
View File
@@ -1,255 +0,0 @@
import { createGateway } from "ai"
import { getModelInfo } from "@/lib/model-catalog"
import { readLimitedBody } from "@/lib/read-limited-body"
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]
}
/**
* An error this module wrote itself. Only these texts reach the caller:
* the base URL is the caller's and may be an internal address, so anything
* else (a parse error quoting the body, a network error naming a host)
* stays in the server log.
*/
export class ModelListError extends Error {
constructor(
message: string,
readonly statusCode?: number,
) {
super(message)
this.name = "ModelListError"
}
}
const MAX_LIST_BYTES = 2 * 1024 * 1024
/** A fetch that reads at most MAX_LIST_BYTES of each response */
function sizeLimitedFetch(fetchFn: typeof fetch): typeof fetch {
return async (input, init) => {
// Ends a download that is too large (the Gateway SDK passes no
// signal of its own)
const download = new AbortController()
const signal = init?.signal
? AbortSignal.any([init.signal, download.signal])
: download.signal
const response = await fetchFn(input, { ...init, signal })
const body = await readLimitedBody(response, MAX_LIST_BYTES)
if (body === null) {
download.abort()
throw new ModelListError("The model list is too large.")
}
// The body is already decoded and has its own length now
const headers = new Headers(response.headers)
headers.delete("content-encoding")
headers.delete("content-length")
// Some statuses must have no body at all
const noBody = [101, 204, 205, 304].includes(response.status)
return new Response(noBody ? null : body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
}
/** 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 new ModelListError(
`The model list request failed (${response.status})`,
response.status,
)
}
const text = await response.text()
try {
return JSON.parse(text)
} catch {
throw new ModelListError("The model list was not valid JSON.")
}
}
/**
* Where to list from without the user's base URL: where chat goes then. For
* Ollama without a key 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, apiKey?: string): string {
if (provider === "ollama" && !apiKey) {
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 },
unlimitedFetch: typeof fetch = fetch,
): Promise<ListedModel[]> {
const fetchFn = sizeLimitedFetch(unlimitedFetch)
const base = normalizeBaseUrl(baseUrl || listFallbackUrl(provider, apiKey))
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 ModelListError(
`${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,
}))
}
-32
View File
@@ -1,32 +0,0 @@
/**
* Read a response body, giving up once it passes maxBytes, so a huge
* download from a URL the client chose can't exhaust server memory.
* Returns null when it is too large; the caller then aborts the request,
* which ends the download.
*/
export async function readLimitedBody(
response: Response,
maxBytes: number,
): Promise<ArrayBuffer | null> {
if (Number(response.headers.get("content-length")) > maxBytes) {
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 > maxBytes) {
// Not awaited: a copy of the body that Next.js keeps (its fetch
// dedupe) can hold the cancel back until it is read
reader.cancel().catch(() => {})
return null
}
chunks.push(value)
}
return new Blob(chunks as BlobPart[]).arrayBuffer()
}
+2 -16
View File
@@ -47,14 +47,11 @@ export interface FlattenedServerModel {
/** /**
* Convert provider name to URL-safe slug for use in model ID * Convert provider name to URL-safe slug for use in model ID
* e.g., "OpenAI Production" → "openai-production", "主力" → "4e3b-529b" * e.g., "OpenAI Production" → "openai-production"
* Non-ASCII characters become their hex code point so CJK names stay
* distinct; the id is sent in HTTP headers, which must be ASCII.
*/ */
export function slugify(name: string): string { function slugify(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
.replace(/[^\p{ASCII}]/gu, (c) => `-${c.codePointAt(0)?.toString(16)}-`)
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") .replace(/^-|-$/g, "")
} }
@@ -192,7 +189,6 @@ export async function loadFlattenedServerModels(): Promise<
const defaultModelId = process.env.AI_MODEL const defaultModelId = process.env.AI_MODEL
const flattened: FlattenedServerModel[] = [] const flattened: FlattenedServerModel[] = []
const seenIds = new Set<string>()
for (const p of cfg.providers) { for (const p of cfg.providers) {
const providerLabel = const providerLabel =
@@ -203,16 +199,6 @@ export async function loadFlattenedServerModels(): Promise<
for (const modelId of p.models) { for (const modelId of p.models) {
const id = `server:${nameSlug}:${modelId}` 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: // Default model priority:
// 1. From ai-models.json: first model of provider with default: true // 1. From ai-models.json: first model of provider with default: true
+25 -40
View File
@@ -1,6 +1,5 @@
import { type DBSchema, type IDBPDatabase, openDB } from "idb" import { type DBSchema, type IDBPDatabase, openDB } from "idb"
import { nanoid } from "nanoid" import { nanoid } from "nanoid"
import { toast } from "sonner"
import type { Template } from "./template-storage" import type { Template } from "./template-storage"
// Constants // Constants
@@ -62,7 +61,6 @@ let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> { async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
if (!dbPromise) { if (!dbPromise) {
// A failed or lost connection is not cached: the next call reopens it
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, { dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion) { upgrade(db, oldVersion) {
if (oldVersion < 1) { if (oldVersion < 1) {
@@ -90,28 +88,6 @@ 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
},
}).catch((error) => {
dbPromise = null
throw error
}) })
} }
return dbPromise return dbPromise
@@ -169,22 +145,36 @@ export async function getSession(id: string): Promise<ChatSession | 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> { export async function saveSession(session: ChatSession): Promise<boolean> {
if (!isIndexedDBAvailable()) return false if (!isIndexedDBAvailable()) return false
try { try {
const db = await getDB() const db = await getDB()
await db.put(STORE_NAME, session) await db.put(STORE_NAME, session)
// The desktop app opens this port (this origin's chats) next launch
window.electronAPI?.chatSaved?.().catch(() => {})
return true return true
} catch (error) { } catch (error) {
console.error("Failed to save session:", error) // Handle quota exceeded
// Reopen the connection next time in case it was lost (Safari reports if (
// "Connection to Indexed Database server lost" without closing it) error instanceof DOMException &&
dbPromise = null error.name === "QuotaExceededError"
return false ) {
console.warn("Storage quota exceeded, deleting oldest session...")
await deleteOldestSession()
// Retry once
try {
const db = await getDB()
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
}
} }
} }
@@ -199,18 +189,13 @@ export async function deleteSession(id: string): Promise<void> {
} }
export async function getSessionCount(): Promise<number> { export async function getSessionCount(): Promise<number> {
return (await readSessionCount()) ?? 0 if (!isIndexedDBAvailable()) return 0
}
/** The number of saved chats, or null when it could not be read */
export async function readSessionCount(): Promise<number | null> {
if (!isIndexedDBAvailable()) return null
try { try {
const db = await getDB() const db = await getDB()
return await db.count(STORE_NAME) return await db.count(STORE_NAME)
} catch (error) { } catch (error) {
console.error("Failed to get session count:", error) console.error("Failed to get session count:", error)
return null return 0
} }
} }
+64 -176
View File
@@ -2,108 +2,80 @@
* SSRF (Server-Side Request Forgery) protection utilities * 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. * Check if URL points to private/internal network
* Works for both user-supplied literal IPs and DNS-resolved addresses. * Blocks: localhost, private IPs, link-local, AWS metadata service
*/ */
function isPrivateIp(ip: string): boolean { export function isPrivateUrl(urlString: string): boolean {
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "") try {
const url = new URL(urlString)
// Strip a trailing dot so FQDN forms like "localhost." (which still
// resolve to 127.0.0.1) cannot bypass the equality checks below.
const hostname = url.hostname
.toLowerCase()
.replace(/^\[|\]$/g, "")
.replace(/\.$/, "")
// IPv6 // Block localhost
if (addr.includes(":")) {
if (addr === "::1" || addr === "::") return true
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
if ( if (
addr.startsWith("fc") || hostname === "localhost" ||
addr.startsWith("fd") || hostname === "127.0.0.1" ||
addr.startsWith("::ffff:") hostname === "::1" ||
hostname === "::"
) { ) {
return true return true
} }
// link-local (fe80::/10)
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/) // Block IPv6 unique-local (fc00::/7), link-local (fe80::/10),
if (linkLocal) { // and IPv4-mapped (::ffff:0:0/96) hosts.
const high = parseInt(linkLocal[1], 16) if (hostname.includes(":")) {
if (high >= 0x80 && high <= 0xbf) return true if (
hostname.startsWith("fc") ||
hostname.startsWith("fd") ||
hostname.startsWith("::ffff:")
) {
return true
}
const linkLocal = hostname.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 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 { } catch {
return true // Invalid URL or DNS failure - block it return true // Invalid URL - block it
} }
} }
@@ -115,87 +87,3 @@ export async function isPrivateUrl(urlString: string): Promise<boolean> {
export function allowPrivateUrls(): boolean { export function allowPrivateUrls(): boolean {
return process.env.ALLOW_PRIVATE_URLS !== "false" return process.env.ALLOW_PRIVATE_URLS !== "false"
} }
/** A redirect the guard below refused; its text is safe to show */
export class RedirectRefusedError extends Error {
constructor(message = "Redirects are not allowed for custom base URLs") {
super(message)
this.name = "RedirectRefusedError"
}
}
const MAX_REDIRECTS = 5
// Dropped when a redirect goes to another origin: those fetch drops, and
// the key headers of providers that do not use Authorization (Anthropic,
// Google, Azure)
const CREDENTIAL_HEADERS = [
"authorization",
"proxy-authorization",
"cookie",
"x-api-key",
"x-goog-api-key",
"api-key",
]
/**
* 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. With private URLs allowed but the quota
* on (DYNAMODB_QUOTA_TABLE), a request to a private address counts as the
* server's: a public URL's redirects are followed only to public addresses,
* or it could reach the server's own network uncounted. Undefined otherwise.
*/
export function redirectGuardedFetch(): typeof fetch | undefined {
const blockAll = !allowPrivateUrls()
if (!blockAll && !process.env.DYNAMODB_QUOTA_TABLE) return undefined
return async (input, init) => {
let url = input instanceof Request ? input.url : String(input)
let next = init
// A request to a private address already counts as the server's
let startsPrivate: boolean | undefined
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await fetch(url, { ...next, redirect: "manual" })
const location = response.headers.get("location")
if (response.status < 300 || response.status >= 400 || !location) {
return response
}
if (blockAll) throw new RedirectRefusedError()
startsPrivate ??= await isPrivateUrl(url)
const from = new URL(url)
url = new URL(location, url).toString()
if (!startsPrivate && (await isPrivateUrl(url))) {
throw new RedirectRefusedError(
"Redirects to private addresses are not allowed",
)
}
// The rest as fetch itself does it. Another origin gets no
// credentials (the user's key, EdgeOne's cookies)
const headers = new Headers(next?.headers)
if (new URL(url).origin !== from.origin) {
for (const name of CREDENTIAL_HEADERS) headers.delete(name)
}
next = { ...next, headers }
// 303, and 301 or 302 after a POST, go on as a GET without the
// body
const method = (next.method ?? "GET").toUpperCase()
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) &&
method === "POST")
) {
for (const name of [
"content-type",
"content-length",
"content-encoding",
"content-language",
"content-location",
]) {
headers.delete(name)
}
next = { ...next, method: "GET", body: undefined }
}
}
throw new RedirectRefusedError("Too many redirects")
}
}
-3
View File
@@ -31,9 +31,6 @@ export const STORAGE_KEYS = {
// Custom system message // Custom system message
customSystemMessage: "next-ai-draw-io-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 // Panel visibility
showRecentChats: "next-ai-draw-io-show-recent-chats", showRecentChats: "next-ai-draw-io-show-recent-chats",
showMyTemplates: "next-ai-draw-io-show-my-templates", showMyTemplates: "next-ai-draw-io-show-my-templates",
+33 -10
View File
@@ -1,14 +1,11 @@
/** /**
* System prompts for different AI models * System prompts for different AI models
* Extended prompt is used for models with higher cache token minimums (Opus 4.5, Haiku 4.5) * 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 // Default system prompt (~1900 tokens) - works with all models
export const DEFAULT_SYSTEM_PROMPT = ` export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation. You are an expert diagram creation assistant specializing in draw.io XML generation.
@@ -44,7 +41,7 @@ parameters: {
tool name: edit_diagram 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. 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: { parameters: {
operations: Array<{operation: "update" | "add" | "delete", cell_id: string, new_xml?: string}> edits: Array<{search: string, replace: string}>
} }
---Tool3--- ---Tool3---
tool name: append_diagram tool name: append_diagram
@@ -244,7 +241,21 @@ const EXTENDED_ADDITIONS = `
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags): **Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
\`\`\`xml \`\`\`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 ### 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): ### Two edges between same nodes (CORRECT - no overlap):
\`\`\`xml \`\`\`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): ### Edge with single waypoint (simple detour):
@@ -326,7 +342,14 @@ ${TWO_EDGES_EXAMPLE}
**WRONG:** Direct diagonal line crosses over Develop **WRONG:** Direct diagonal line crosses over Develop
**CORRECT:** Route around the OUTSIDE (go right first, then up) **CORRECT:** Route around the OUTSIDE (go right first, then up)
\`\`\`xml \`\`\`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. This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
+21 -150
View File
@@ -23,8 +23,6 @@ export type ProviderName =
| "kimi" | "kimi"
| "minimax" | "minimax"
| "novita" | "novita"
| "mimo"
| "atlascloud"
// Individual model configuration // Individual model configuration
export interface ModelConfig { export interface ModelConfig {
@@ -32,8 +30,6 @@ export interface ModelConfig {
modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5" modelId: string // e.g., "gpt-4o", "claude-sonnet-4-5"
validated?: boolean // Has this model been validated validated?: boolean // Has this model been validated
validationError?: string // Error message if validation failed 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 // Provider configuration
@@ -118,49 +114,24 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
modelscope: "modelscope", modelscope: "modelscope",
minimax: "minimax", minimax: "minimax",
novita: "novita", novita: "novita",
mimo: "xiaomi",
atlascloud: "openai",
} }
/** How a provider lists its models (see lib/provider-models.ts) */ // Provider metadata
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).
export const PROVIDER_INFO: Record< export const PROVIDER_INFO: Record<
ProviderName, ProviderName,
{ { label: string; defaultBaseUrl?: string }
label: string
defaultBaseUrl?: string
apiKeyUrl?: string
modelList?: ModelListStyle
}
> = { > = {
openai: { openai: {
label: "OpenAI", label: "OpenAI",
defaultBaseUrl: "https://api.openai.com/v1", defaultBaseUrl: "https://api.openai.com/v1",
apiKeyUrl: "https://platform.openai.com/api-keys",
modelList: "openai",
}, },
anthropic: { anthropic: {
label: "Anthropic", label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1", defaultBaseUrl: "https://api.anthropic.com/v1",
apiKeyUrl: "https://platform.claude.com/settings/keys",
modelList: "anthropic",
}, },
google: { google: {
label: "Google", label: "Google",
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta", defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyUrl: "https://aistudio.google.com/apikey",
modelList: "google",
}, },
vertexai: { label: "Google Vertex AI" }, vertexai: { label: "Google Vertex AI" },
azure: { azure: {
@@ -171,114 +142,69 @@ export const PROVIDER_INFO: Record<
ollama: { ollama: {
label: "Ollama", label: "Ollama",
defaultBaseUrl: "https://ollama.com/api", defaultBaseUrl: "https://ollama.com/api",
apiKeyUrl: "https://ollama.com/settings/keys",
modelList: "ollama",
}, },
openrouter: { openrouter: {
label: "OpenRouter", label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1", defaultBaseUrl: "https://openrouter.ai/api/v1",
apiKeyUrl: "https://openrouter.ai/keys",
modelList: "openrouter",
}, },
aihubmix: { aihubmix: {
label: "AIHubMix", label: "AIHubMix",
defaultBaseUrl: "https://aihubmix.com/v1", defaultBaseUrl: "https://aihubmix.com/v1",
apiKeyUrl: "https://aihubmix.com/token",
modelList: "aihubmix",
}, },
deepseek: { deepseek: {
label: "DeepSeek", label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1", defaultBaseUrl: "https://api.deepseek.com/v1",
apiKeyUrl: "https://platform.deepseek.com/api_keys",
modelList: "openai",
}, },
siliconflow: { siliconflow: {
label: "SiliconFlow", label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1", defaultBaseUrl: "https://api.siliconflow.cn/v1",
apiKeyUrl: "https://cloud.siliconflow.cn/account/ak",
modelList: "openai",
}, },
sglang: { sglang: {
label: "SGLang", label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1", defaultBaseUrl: "http://127.0.0.1:8000/v1",
modelList: "openai",
}, },
gateway: { gateway: {
label: "AI Gateway", label: "AI Gateway",
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai", defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
apiKeyUrl: "https://vercel.com/ai-gateway",
modelList: "gateway",
}, },
edgeone: { label: "EdgeOne Pages" }, edgeone: { label: "EdgeOne Pages" },
doubao: { doubao: {
label: "Doubao (ByteDance)", label: "Doubao (ByteDance)",
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3", defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
apiKeyUrl:
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
}, },
modelscope: { modelscope: {
label: "ModelScope", label: "ModelScope",
defaultBaseUrl: "https://api-inference.modelscope.cn/v1", defaultBaseUrl: "https://api-inference.modelscope.cn/v1",
apiKeyUrl: "https://modelscope.cn/my/myaccesstoken",
modelList: "openai",
}, },
glm: { glm: {
label: "GLM (Zhipu)", label: "GLM (Zhipu)",
defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4", defaultBaseUrl: "https://open.bigmodel.cn/api/paas/v4",
apiKeyUrl: "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
modelList: "openai",
}, },
qwen: { qwen: {
label: "Qwen (Alibaba)", label: "Qwen (Alibaba)",
defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", defaultBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKeyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
modelList: "openai",
}, },
qiniu: { qiniu: {
label: "Qiniu", label: "Qiniu",
defaultBaseUrl: "https://api.qnaigc.com/v1", defaultBaseUrl: "https://api.qnaigc.com/v1",
apiKeyUrl: "https://www.qiniu.com/ai/models",
modelList: "openai",
}, },
kimi: { kimi: {
label: "Kimi (Moonshot)", label: "Kimi (Moonshot)",
defaultBaseUrl: "https://api.moonshot.cn/v1", defaultBaseUrl: "https://api.moonshot.cn/v1",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
modelList: "openai",
}, },
minimax: { minimax: {
label: "MiniMax", label: "MiniMax",
defaultBaseUrl: "https://api.minimaxi.com/anthropic", defaultBaseUrl: "https://api.minimaxi.com/anthropic",
apiKeyUrl:
"https://platform.minimaxi.com/user-center/basic-information/interface-key",
}, },
novita: { novita: {
label: "Novita AI", label: "Novita AI",
defaultBaseUrl: "https://api.novita.ai/openai", 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 // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = { export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
openai: [ openai: [
"gpt-6.1-sol",
"gpt-6-sol",
"gpt-6-luna",
"gpt-6-astra",
"gpt-5.5-pro", "gpt-5.5-pro",
"gpt-5.5", "gpt-5.5",
"gpt-5.4-pro", "gpt-5.4-pro",
@@ -292,13 +218,7 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"gpt-4o-mini", "gpt-4o-mini",
], ],
anthropic: [ anthropic: [
// Claude 5 series (latest) // Claude 4.8 / 4.7 / 4.6 series (latest, dateless pinned IDs)
"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-opus-4-8",
"claude-sonnet-4-6", "claude-sonnet-4-6",
"claude-haiku-4-5", "claude-haiku-4-5",
@@ -347,47 +267,39 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"o3", "o3",
"o4-mini", "o4-mini",
], ],
// Newer models only answer through an inference profile id (the region
// prefix). Each id here was called once on 2026-10-04.
bedrock: [ bedrock: [
// Anthropic Claude ("global." works from any region) // Anthropic Claude
"global.anthropic.claude-opus-5-5", "anthropic.claude-opus-4-8",
"global.anthropic.claude-sonnet-5-5", "anthropic.claude-opus-4-7",
"global.anthropic.claude-fable-5-1", "anthropic.claude-sonnet-4-6",
"global.anthropic.claude-opus-5", "anthropic.claude-opus-4-6-v1",
"global.anthropic.claude-sonnet-5", "anthropic.claude-opus-4-5-20251101-v1:0",
"global.anthropic.claude-opus-4-8", "anthropic.claude-sonnet-4-5-20250929-v1:0",
"global.anthropic.claude-opus-4-7", "anthropic.claude-haiku-4-5-20251001-v1:0",
"global.anthropic.claude-sonnet-4-6", "anthropic.claude-opus-4-1-20250805-v1:0",
"global.anthropic.claude-opus-4-6-v1", "anthropic.claude-opus-4-20250514-v1:0",
"global.anthropic.claude-opus-4-5-20251101-v1:0", "anthropic.claude-sonnet-4-20250514-v1:0",
"global.anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-3-5-haiku-20241022-v1:0",
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
"global.anthropic.claude-sonnet-4-20250514-v1:0",
// Amazon Nova // Amazon Nova
"us.amazon.nova-2-lite-v1:0", "amazon.nova-2-lite-v1:0",
"amazon.nova-premier-v1:0",
"amazon.nova-pro-v1:0", "amazon.nova-pro-v1:0",
"amazon.nova-lite-v1:0", "amazon.nova-lite-v1:0",
"amazon.nova-micro-v1:0", "amazon.nova-micro-v1:0",
// Meta Llama // Meta Llama
"us.meta.llama4-maverick-17b-instruct-v1:0", "meta.llama4-maverick-17b-instruct-v1:0",
"us.meta.llama4-scout-17b-instruct-v1:0", "meta.llama4-scout-17b-instruct-v1:0",
"us.meta.llama3-3-70b-instruct-v1:0", "meta.llama3-3-70b-instruct-v1:0",
// Mistral // Mistral
"mistral.mistral-large-3-675b-instruct", "mistral.mistral-large-3-675b-instruct",
"us.mistral.pixtral-large-2502-v1:0", "mistral.pixtral-large-2502-v1:0",
], ],
openrouter: [ openrouter: [
// Anthropic // Anthropic
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"anthropic/claude-fable-5.1",
"anthropic/claude-opus-4.8", "anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6", "anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5",
// OpenAI // OpenAI
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"openai/gpt-5.5", "openai/gpt-5.5",
"openai/gpt-5.4", "openai/gpt-5.4",
"openai/gpt-5.4-mini", "openai/gpt-5.4-mini",
@@ -476,10 +388,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"default", "default",
], ],
gateway: [ gateway: [
"anthropic/claude-opus-5.5",
"anthropic/claude-sonnet-5.5",
"openai/gpt-6.1-sol",
"openai/gpt-6-luna",
"openai/gpt-5.5", "openai/gpt-5.5",
"anthropic/claude-opus-4.7", "anthropic/claude-opus-4.7",
"google/gemini-3.1-pro-preview", "google/gemini-3.1-pro-preview",
@@ -529,8 +437,6 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
"moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6",
"deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash",
], ],
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
} }
// Helper to generate UUID // Helper to generate UUID
@@ -609,38 +515,3 @@ export function findModelById(
): FlattenedModel | undefined { ): FlattenedModel | undefined {
return flattenModels(config).find((m) => m.id === modelId) 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 { z } from "zod"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { STORAGE_KEYS } from "@/lib/storage"
export interface UrlData { export interface UrlData {
url: string url: string
@@ -19,11 +18,7 @@ const UrlResponseSchema = z.object({
export async function extractUrlContent(url: string): Promise<UrlData> { export async function extractUrlContent(url: string): Promise<UrlData> {
const response = await fetch(getApiEndpoint("/api/parse-url"), { const response = await fetch(getApiEndpoint("/api/parse-url"), {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json",
"x-access-code":
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
},
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}) })
+61 -55
View File
@@ -27,72 +27,78 @@ export function useFileProcessor() {
const handleFileChange = async (newFiles: File[]) => { const handleFileChange = async (newFiles: File[]) => {
setFiles(newFiles) setFiles(newFiles)
const pending = newFiles.filter( // Extract text immediately for new PDF/text files
(file) => for (const file of newFiles) {
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file), 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 // Extract text asynchronously
// file as extracting, so queued files also block sending try {
setPdfData((prev) => { let text: string
const next = new Map<File, FileData>() if (isPdfFile(file)) {
for (const file of newFiles) { text = await extractPdfText(file)
const existing = prev.get(file) } else {
if (existing) next.set(file, existing) text = await extractTextFileContent(file)
} }
for (const file of pending) {
next.set(file, { text: "", charCount: 0, isExtracting: true })
}
return next
})
// Extract one file at a time // Check character limit
for (const file of pending) { if (text.length > MAX_EXTRACTED_CHARS) {
try { const limitK = MAX_EXTRACTED_CHARS / 1000
let text: string toast.error(
if (isPdfFile(file)) { `${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
text = await extractPdfText(file) )
} else { setPdfData((prev) => {
text = await extractTextFileContent(file) 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 setPdfData((prev) => {
if (text.length > MAX_EXTRACTED_CHARS) { const next = new Map(prev)
const limitK = MAX_EXTRACTED_CHARS / 1000 next.set(file, {
toast.error( text,
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`, 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) => { setPdfData((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.delete(file) next.delete(file)
return next 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 { return {
+1377 -27
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -10,11 +10,9 @@ const nextConfig: NextConfig = {
env: { env: {
APP_VERSION: packageJson.version, APP_VERSION: packageJson.version,
}, },
// Include instrumentation.ts in standalone build for Langfuse telemetry, // Include instrumentation.ts in standalone build for Langfuse telemetry
// and the shape library docs get_shape_library reads at run time
outputFileTracingIncludes: { outputFileTracingIncludes: {
"*": ["./instrumentation.ts"], "*": ["./instrumentation.ts"],
"/api/chat": ["./docs/shape-libraries/*.md"],
}, },
} }
+1824 -1968
View File
File diff suppressed because it is too large Load Diff
+17 -18
View File
@@ -31,28 +31,28 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.191", "@ai-sdk/amazon-bedrock": "^4.0.1",
"@ai-sdk/anthropic": "^3.0.127", "@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/azure": "^3.0.133", "@ai-sdk/azure": "^3.0.0",
"@ai-sdk/deepseek": "^2.0.71", "@ai-sdk/deepseek": "^2.0.0",
"@ai-sdk/google": "^3.0.130", "@ai-sdk/gateway": "^3.0.0",
"@ai-sdk/google-vertex": "^4.0.210", "@ai-sdk/google": "^3.0.0",
"@ai-sdk/openai": "^3.0.124", "@ai-sdk/google-vertex": "^4.0.16",
"@ai-sdk/openai-compatible": "^2.0.81", "@ai-sdk/openai": "^3.0.0",
"@ai-sdk/react": "^3.0.303", "@ai-sdk/react": "^3.0.1",
"@aihubmix/ai-sdk-provider": "^2.2.1", "@aihubmix/ai-sdk-provider": "^2.1.0",
"@aws-sdk/client-dynamodb": "^3.957.0", "@aws-sdk/client-dynamodb": "^3.957.0",
"@aws-sdk/credential-providers": "^3.943.0", "@aws-sdk/credential-providers": "^3.943.0",
"@extractus/article-extractor": "^8.0.18", "@extractus/article-extractor": "^8.0.18",
"@formatjs/intl-localematcher": "^0.9.0", "@formatjs/intl-localematcher": "^0.8.0",
"@langfuse/client": "^4.4.9", "@langfuse/client": "^4.4.9",
"@langfuse/otel": "^4.4.4", "@langfuse/otel": "^4.4.4",
"@langfuse/tracing": "^4.4.9", "@langfuse/tracing": "^4.4.9",
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "^1.17.1", "@opennextjs/cloudflare": "^1.17.1",
"@openrouter/ai-sdk-provider": "^2.10.0", "@openrouter/ai-sdk-provider": "^2.0.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0", "@opentelemetry/exporter-trace-otlp-http": "^0.216.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
@@ -66,7 +66,7 @@
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-use-controllable-state": "^1.2.2",
"@xmldom/xmldom": "^0.9.8", "@xmldom/xmldom": "^0.9.8",
"ai": "^6.0.300", "ai": "^6.0.1",
"base-64": "^1.0.0", "base-64": "^1.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -78,7 +78,7 @@
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^3.6.0", "ollama-ai-provider-v2": "^3.0.0",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.1.2", "react": "^19.1.2",
@@ -88,7 +88,6 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"saxes": "^6.0.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
@@ -110,7 +109,7 @@
}, },
"devDependencies": { "devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4", "@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7", "@biomejs/biome": "2.4.13",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -130,7 +129,7 @@
"electron": "^39.2.7", "electron": "^39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"eslint": "9.39.5", "eslint": "9.39.4",
"eslint-config-next": "16.1.6", "eslint-config-next": "16.1.6",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
+6 -16
View File
@@ -25,13 +25,10 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
## Features ## Features
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them - **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more - **Version History**: Restore previous diagram versions with visual thumbnails
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser - **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files - **Export**: Save diagrams as `.drawio` files
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
- **Self-contained**: Embedded server, no external dependencies required - **Self-contained**: Embedded server, no external dependencies required
## Use Case Examples ## Use Case Examples
@@ -75,16 +72,11 @@ between user, client app, auth server, and resource server
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules | | `start_session` | Opens browser with real-time diagram preview |
| `get_drawing_guide` | Return the drawing rules again |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
| `create_new_diagram` | Create a new diagram from XML | | `create_new_diagram` | Create a new diagram from XML |
| `load_diagram` | Load a `.drawio` file from disk | | `edit_diagram` | Edit diagram by ID-based operations |
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
| `get_diagram` | Get the current diagram XML | | `get_diagram` | Get the current diagram XML |
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result | | `export_diagram` | Save diagram to a `.drawio` file |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
## How It Works ## How It Works
@@ -103,8 +95,6 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server | | `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams; `off` turns auto-save off |
| `DEBUG` | unset | Set to `true` to log debug messages |
## Links ## Links
+10 -35
View File
@@ -104,40 +104,21 @@ Use the standard MCP configuration with:
## Features ## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them - **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing - **Version History**: Restore previous diagram versions with visual thumbnails - click the clock button (bottom-right) to browse and restore earlier states
- **Self-check**: The AI can take a screenshot of the rendered diagram and fix overlapping shapes or edges that cross shapes - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML - **Edit Support**: Modify existing diagrams with natural language instructions
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History - **Export**: Save diagrams as `.drawio` files
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram`
- **Multi-page**: List, add, rename, and delete pages, and edit any page
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, so it survives a restart of the MCP client
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
## Available Tools ## Available Tools
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules | | `start_session` | Opens browser with real-time diagram preview |
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted | | `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` | | `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough | | `get_diagram` | Get the current diagram XML |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) | | `export_diagram` | Save diagram to a `.drawio` file |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
| `list_pages` | List every page (tab) with id, name, index, and cell count |
| `add_page` | Append a new page without touching existing ones |
| `rename_page` | Rename a page |
| `delete_page` | Delete a page (refuses to delete the last one) |
## Continue a Diagram Later
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself.
The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
## How It Works ## How It Works
@@ -168,8 +149,6 @@ The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server | | `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files. Set to `off` to turn auto-save off. |
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
### Private Deployment (Self-hosted draw.io) ### Private Deployment (Self-hosted draw.io)
@@ -195,7 +174,7 @@ You can deploy your own draw.io instance using the official Docker image:
docker run -d -p 8080:8080 jgraph/drawio docker run -d -p 8080:8080 jgraph/drawio
``` ```
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL). The preview page loads nothing else from the internet, so with a local draw.io it works offline. One exception: shapes from the Material Design library show icons from `fonts.gstatic.com`. Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
## Troubleshooting ## Troubleshooting
@@ -224,10 +203,6 @@ Call `start_session` first to open the browser window.
Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server. Check that the browser URL has the `?mcp=` query parameter. The MCP session ID connects the browser to the server.
### Screenshot or PNG/SVG export times out
PNG and SVG files are rendered by draw.io in the preview tab. Browsers slow down tabs that stay in the background, so the tab may not answer in time. Bring the preview tab to the front and try again.
## License ## License
Apache-2.0 Apache-2.0
+194 -1556
View File
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.3.0", "version": "0.2.0",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -8,12 +8,9 @@
"next-ai-drawio-mcp": "./dist/index.js" "next-ai-drawio-mcp": "./dist/index.js"
}, },
"scripts": { "scripts": {
"build": "tsc && node scripts/copy-assets.mjs", "build": "tsc",
"check-package": "node scripts/check-package.mjs",
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"keywords": [ "keywords": [
@@ -39,19 +36,15 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.31.0", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"pako": "^2.2.0",
"saxes": "^6.0.0",
"zod": "^4.0.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pako": "^2.0.4",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5", "typescript": "^5"
"vitest": "^4.1.8"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -1,25 +0,0 @@
// Fail if the npm package would miss files the server reads at runtime.
// Tests run from src/ (tsx) and cannot notice a broken dist/ copy step.
// Run after `npm run build`.
import { execSync } from "node:child_process"
const REQUIRED = [
"dist/index.js",
"dist/shape-libraries/aws4.md",
"dist/preview/index.html",
"dist/preview/preview.css",
"dist/preview/preview.js",
]
const output = JSON.parse(
execSync("npm pack --dry-run --json", { encoding: "utf8" }),
)
// npm 11 prints a list, npm 12 an object keyed by package name
const pack = Array.isArray(output) ? output[0] : Object.values(output)[0]
const files = new Set(pack.files.map((f) => f.path))
const missing = REQUIRED.filter((f) => !files.has(f))
if (missing.length > 0) {
console.error(`npm package is missing: ${missing.join(", ")}`)
process.exit(1)
}
console.log(`npm package OK (${files.size} files)`)
@@ -1,22 +0,0 @@
// Copy non-TypeScript assets into dist/ after tsc, so they ship in the npm
// package ("files": ["dist"]).
import { cpSync, mkdirSync, readdirSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
const pkg = join(dirname(fileURLToPath(import.meta.url)), "..")
// Shape library docs live at the repository root, shared with the web app
const libSrc = join(pkg, "../../docs/shape-libraries")
const libDest = join(pkg, "dist/shape-libraries")
mkdirSync(libDest, { recursive: true })
for (const file of readdirSync(libSrc)) {
if (file.endsWith(".md") && file !== "README.md") {
cpSync(join(libSrc, file), join(libDest, file))
}
}
// Browser preview page (HTML, CSS and script)
cpSync(join(pkg, "src/preview"), join(pkg, "dist/preview"), {
recursive: true,
})
+48 -177
View File
@@ -1,17 +1,8 @@
/** /**
* ID-based diagram operations * ID-based diagram operations
* * Copied from lib/utils.ts to avoid cross-package imports
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
* optional pageSelector identifies which page to edit; when omitted, the
* first page is targeted (the "active page by convention" — see pages.ts).
*/ */
import { decompressPageContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
export interface DiagramOperation { export interface DiagramOperation {
operation: "update" | "add" | "delete" operation: "update" | "add" | "delete"
cell_id: string cell_id: string
@@ -29,75 +20,26 @@ export interface ApplyOperationsResult {
errors: OperationError[] errors: OperationError[]
} }
// Cells with links, tooltips or custom data are stored as
// <UserObject id="..."><mxCell .../></UserObject> (or <object>): the id sits
// on the wrapper, so the wrapper is treated as the cell.
const CELL_SELECTOR = "mxCell, UserObject, object"
/**
* Return the <root> of a <diagram> page, creating it when missing. An empty
* page gets a blank model with the "0" and "1" root cells; a page whose text
* is draw.io's compressed format is decompressed in place. Returns null if
* the text is neither empty nor decompressible.
*/
function ensurePageRoot(doc: Document, page: Element): Element | null {
const existing = page.querySelector("root")
if (existing) return existing
let model = page.querySelector("mxGraphModel")
if (!model) {
const text = page.textContent?.trim() ?? ""
if (text) {
const xml = decompressPageContent(text)
if (!xml || getXmlSyntaxError(xml)) return null
const parsed = new DOMParser().parseFromString(xml, "text/xml")
if (parsed.documentElement?.tagName !== "mxGraphModel") return null
page.textContent = ""
model = page.appendChild(
doc.importNode(parsed.documentElement, true),
) as Element
const decompressedRoot = model.querySelector("root")
if (decompressedRoot) return decompressedRoot
} else {
model = page.appendChild(doc.createElement("mxGraphModel"))
}
}
const blank = new DOMParser().parseFromString(
`<root><mxCell id="0"/><mxCell id="1" parent="0"/></root>`,
"text/xml",
)
return model.appendChild(
doc.importNode(blank.documentElement, true),
) as Element
}
/** Read parent/source/target, which a wrapped cell keeps on its inner mxCell. */
function cellAttr(cell: Element, name: string): string | null {
const inner =
cell.tagName === "mxCell" ? cell : cell.querySelector("mxCell")
return inner?.getAttribute(name) ?? null
}
/** /**
* Apply diagram operations (update/add/delete) using ID-based lookup. * Apply diagram operations (update/add/delete) using ID-based lookup.
* This replaces the text-matching approach with direct DOM manipulation.
* *
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or * @param xmlContent - The full mxfile XML content
* a full <mxfile> with one or more <diagram> children. * @param operations - Array of operations to apply
* @param operations - Array of operations to apply. * @returns Object with result XML and any errors
* @param pageSelector - Optional page selector for multi-page docs. Defaults
* to the first page.
* @returns Object with result XML (same shape as input) and any per-op errors.
*/ */
export function applyDiagramOperations( export function applyDiagramOperations(
xmlContent: string, xmlContent: string,
operations: DiagramOperation[], operations: DiagramOperation[],
pageSelector?: PageSelector,
): ApplyOperationsResult { ): ApplyOperationsResult {
const errors: OperationError[] = [] const errors: OperationError[] = []
// Check for syntax errors, then parse the XML // Parse the XML
const parseError = getXmlSyntaxError(xmlContent) const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
// Check for parse errors
const parseError = doc.querySelector("parsererror")
if (parseError) { if (parseError) {
return { return {
result: xmlContent, result: xmlContent,
@@ -105,94 +47,33 @@ export function applyDiagramOperations(
{ {
type: "update", type: "update",
cellId: "", cellId: "",
message: `XML parse error: ${parseError}`, message: `XML parse error: ${parseError.textContent}`,
}, },
], ],
} }
} }
const parser = new DOMParser()
const doc = parser.parseFromString(xmlContent, "text/xml")
// Locate the <root> element to operate on. // Find the root element (inside mxGraphModel)
// const root = doc.querySelector("root")
// - For <mxfile> input: resolve the page via pageSelector, then dive into if (!root) {
// its <root>. This scopes querySelectorAll calls below to one page so return {
// cells on other pages aren't accidentally matched. result: xmlContent,
// - For bare <mxGraphModel> input: use the document's only <root>. errors: [
let root: Element | null {
if (doc.documentElement?.tagName === "mxfile") { type: "update",
const found = findPageElement(doc as unknown as Document, pageSelector) cellId: "",
if (!found) { message: "Could not find <root> element in XML",
const selDesc = hasPageSelector(pageSelector) },
? ` matching selector ${JSON.stringify(pageSelector)}` ],
: ""
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page${selDesc} not found in <mxfile>`,
},
],
}
}
root = ensurePageRoot(doc as unknown as Document, found.element)
if (!root) {
const pageId =
found.element.getAttribute("id") || `(index ${found.index})`
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: `Page "${pageId}" has no <root> element and its content could not be decompressed`,
},
],
}
}
} else {
if (hasPageSelector(pageSelector)) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message:
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
},
],
}
}
root = doc.querySelector("root")
if (!root) {
return {
result: xmlContent,
errors: [
{
type: "update",
cellId: "",
message: "Could not find <root> element in XML",
},
],
}
} }
} }
// Build a map of cell IDs to elements (scoped to the resolved page). // Build a map of cell IDs to elements
const cellMap = new Map<string, Element>() const cellMap = new Map<string, Element>()
root.querySelectorAll(CELL_SELECTOR).forEach((cell) => { root.querySelectorAll("mxCell").forEach((cell) => {
const id = cell.getAttribute("id") const id = cell.getAttribute("id")
// A wrapped mxCell may repeat its wrapper's id; the wrapper is the cell if (id) cellMap.set(id, cell)
const wrapped =
cell.tagName === "mxCell" &&
/^(UserObject|object)$/.test(cell.parentElement?.tagName ?? "")
if (id && !wrapped) cellMap.set(id, cell)
}) })
// Ids deleted so far in this batch; deleting one again is a no-op
const deletedIds = new Set<string>()
// Process each operation // Process each operation
for (const op of operations) { for (const op of operations) {
@@ -221,7 +102,7 @@ export function applyDiagramOperations(
`<wrapper>${op.new_xml}</wrapper>`, `<wrapper>${op.new_xml}</wrapper>`,
"text/xml", "text/xml",
) )
const newCell = newDoc.querySelector(CELL_SELECTOR) const newCell = newDoc.querySelector("mxCell")
if (!newCell) { if (!newCell) {
errors.push({ errors.push({
type: "update", type: "update",
@@ -273,7 +154,7 @@ export function applyDiagramOperations(
`<wrapper>${op.new_xml}</wrapper>`, `<wrapper>${op.new_xml}</wrapper>`,
"text/xml", "text/xml",
) )
const newCell = newDoc.querySelector(CELL_SELECTOR) const newCell = newDoc.querySelector("mxCell")
if (!newCell) { if (!newCell) {
errors.push({ errors.push({
type: "add", type: "add",
@@ -313,15 +194,8 @@ export function applyDiagramOperations(
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
// Skip cells already cascade-deleted by a previous operation // Cell not found - might have been cascade-deleted by a previous operation
// (AI may redundantly list children/edges); warn otherwise // Skip silently instead of erroring (AI may redundantly list children/edges)
if (!deletedIds.has(op.cell_id)) {
errors.push({
type: "delete",
cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`,
})
}
continue continue
} }
@@ -334,17 +208,15 @@ export function applyDiagramOperations(
cellsToDelete.add(cellId) cellsToDelete.add(cellId)
// Find children (cells where parent === cellId) // Find children (cells where parent === cellId)
// cellMap only holds this page's cells, so other pages' cells const children = root.querySelectorAll(
// with the same parent id (notably "1") are never touched. `mxCell[parent="${cellId}"]`,
for (const [childId, child] of cellMap) { )
if ( children.forEach((child) => {
childId !== "0" && const childId = child.getAttribute("id")
childId !== "1" && if (childId && childId !== "0" && childId !== "1") {
cellAttr(child, "parent") === cellId
) {
collectDescendants(childId) collectDescendants(childId)
} }
} })
} }
// Collect the target cell and all its descendants // Collect the target cell and all its descendants
@@ -353,23 +225,23 @@ export function applyDiagramOperations(
// Find edges referencing any of the cells to be deleted // Find edges referencing any of the cells to be deleted
// Also recursively collect children of those edges (e.g., edge labels) // Also recursively collect children of those edges (e.g., edge labels)
for (const cellId of cellsToDelete) { for (const cellId of cellsToDelete) {
for (const [edgeId, edge] of cellMap) { const referencingEdges = root.querySelectorAll(
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
)
referencingEdges.forEach((edge) => {
const edgeId = edge.getAttribute("id")
// Protect root cells from being added via edge references // Protect root cells from being added via edge references
if (edgeId === "0" || edgeId === "1") continue if (edgeId && edgeId !== "0" && edgeId !== "1") {
if (
cellAttr(edge, "source") === cellId ||
cellAttr(edge, "target") === cellId
) {
// Recurse to collect edge's children (like labels) // Recurse to collect edge's children (like labels)
collectDescendants(edgeId) collectDescendants(edgeId)
} }
} })
} }
// Log what will be deleted (stderr: stdout carries JSON-RPC) // Log what will be deleted
if (cellsToDelete.size > 1) { if (cellsToDelete.size > 1) {
log.debug( console.log(
`Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`, `[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
) )
} }
@@ -380,7 +252,6 @@ export function applyDiagramOperations(
cell.parentNode?.removeChild(cell) cell.parentNode?.removeChild(cell)
cellMap.delete(cellId) cellMap.delete(cellId)
} }
deletedIds.add(cellId)
} }
} }
} }

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