mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-04 00:37:48 +08:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46787c1b8 | ||
|
|
95f4b4b92b | ||
|
|
3193d20e00 | ||
|
|
87edf2e19d | ||
|
|
5c7613ea09 | ||
|
|
79b4c52741 | ||
|
|
528b6e54c8 | ||
|
|
366480426d | ||
|
|
a45e5b6796 | ||
|
|
027cd88c90 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b | ||
|
|
fd758b9e87 | ||
|
|
81da9fad83 | ||
|
|
6493652ff0 | ||
|
|
be8f26d6b1 | ||
|
|
cd02b2de92 | ||
|
|
6e653942b0 | ||
|
|
c8463aefa7 | ||
|
|
4b07228320 | ||
|
|
f3a85558d8 | ||
|
|
4f09d9461a | ||
|
|
4984be82a1 | ||
|
|
5bfd7b2468 | ||
|
|
80baf43827 | ||
|
|
5c884766a8 | ||
|
|
8e42dd9da8 |
@@ -33,6 +33,11 @@
|
||||
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
|
||||
"groupName": "Core framework packages",
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@biomejs/biome"],
|
||||
"groupName": "Biome",
|
||||
"automerge": false
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
|
||||
@@ -58,6 +58,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -89,4 +91,3 @@ jobs:
|
||||
docker pull ghcr.io/${REPO_LOWER}:latest
|
||||
docker tag ghcr.io/${REPO_LOWER}:latest ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-1.amazonaws.com/next-ai-draw-io:latest
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Publish MCP Server
|
||||
|
||||
# Publishes @next-ai-drawio/mcp-server to npm via OIDC trusted publishing
|
||||
# (no token, no OTP). Triggers when packages/mcp-server changes on main;
|
||||
# skips silently if the package.json version is already on npm — so a
|
||||
# release is just "bump the version in a PR and merge".
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/mcp-server/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token for npm trusted publishing
|
||||
|
||||
concurrency:
|
||||
group: publish-mcp
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/mcp-server
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
cache-dependency-path: packages/mcp-server/package-lock.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Trusted publishing requires npm >= 11.5.1
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Check if version is already published
|
||||
id: version
|
||||
run: |
|
||||
LOCAL=$(node -p "require('./package.json').version")
|
||||
if npm view "@next-ai-drawio/mcp-server@${LOCAL}" version >/dev/null 2>&1; then
|
||||
echo "Version ${LOCAL} already on npm - nothing to publish"
|
||||
echo "publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version ${LOCAL} not on npm - publishing"
|
||||
echo "publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Test
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm test
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.version.outputs.publish == 'true'
|
||||
run: npm publish
|
||||
@@ -28,6 +28,16 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: npm run test -- --run
|
||||
|
||||
# The MCP server package ships its own vitest because its DOM polyfill
|
||||
# (linkedom) needs `environment: node`, while the root vitest uses jsdom
|
||||
# for the Next.js app. Install + run its tests separately so CI catches
|
||||
# multi-page mxfile regressions.
|
||||
- name: Install MCP server dependencies
|
||||
run: npm --prefix packages/mcp-server ci
|
||||
|
||||
- name: Run MCP server unit tests
|
||||
run: npm --prefix packages/mcp-server test
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -21,6 +21,17 @@ A Next.js web application that integrates AI capabilities with draw.io diagrams.
|
||||
|
||||
> Note: Thanks to <img src="https://raw.githubusercontent.com/DayuanJiang/next-ai-draw-io/main/public/doubao-color.png" alt="" height="20" /> [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) sponsorship, the demo site now uses the powerful glm-4.7 model!
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./public/atlas-cloud-logo-white.svg">
|
||||
<img src="./public/atlas-cloud-logo.svg" alt="Atlas Cloud" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> 🎁 Thanks to **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)** for sponsoring next-ai-draw-io. Its OpenAI-compatible API gives diagram workflows one provider connection for DeepSeek, Qwen, GLM, Kimi, MiniMax, and more. Budget-friendly access is available through the [Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1
|
||||
|
||||
@@ -217,6 +228,7 @@ See the [Next.js deployment documentation](https://nextjs.org/docs/app/building-
|
||||
- ModelScope
|
||||
- SGLang
|
||||
- Vercel AI Gateway
|
||||
- [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io)
|
||||
|
||||
|
||||
All providers except AWS Bedrock and OpenRouter support custom endpoints.
|
||||
@@ -253,6 +265,8 @@ Diagrams are represented as XML that can be rendered in draw.io. The AI processe
|
||||
|
||||
**Special thanks to [ByteDance Doubao](https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=Z9Z3LDTJ&utm_campaign=drawio&utm_content=drawio&utm_medium=devrel&utm_source=OWO&utm_term=drawio) for sponsoring the API token usage of the demo site!** Register on the ARK platform to get 500K free tokens for all models!
|
||||
|
||||
**Special thanks to [Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=next-ai-draw-io) for sponsoring next-ai-draw-io and supporting its multi-provider ecosystem!** Try its OpenAI-compatible LLM API through the [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan).
|
||||
|
||||
If you find this project useful, please consider [sponsoring](https://github.com/sponsors/DayuanJiang) to help me host the live demo site!
|
||||
|
||||
For support or inquiries, please open an issue on the GitHub repository or contact the maintainer at:
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
generateId,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
SUGGESTED_MODELS,
|
||||
@@ -225,6 +226,7 @@ function ProviderDetail({
|
||||
</Button>
|
||||
{suggestions.length > 0 && (
|
||||
<Select
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addModel(v)}
|
||||
>
|
||||
@@ -390,12 +392,14 @@ function ProviderDetail({
|
||||
export function ModelsSection({
|
||||
providers,
|
||||
envProviders,
|
||||
envHasDefaultModel,
|
||||
disabled,
|
||||
password,
|
||||
onChange,
|
||||
}: {
|
||||
providers: AdminProvider[]
|
||||
envProviders: EnvProvider[]
|
||||
envHasDefaultModel: boolean
|
||||
disabled: boolean
|
||||
password: string
|
||||
onChange: (providers: AdminProvider[]) => void
|
||||
@@ -409,10 +413,16 @@ export function ModelsSection({
|
||||
|
||||
const addProvider = (provider: ProviderName) => {
|
||||
const newProvider: AdminProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
||||
id: generateId(),
|
||||
provider,
|
||||
models: [],
|
||||
isDefault: providers.length === 0,
|
||||
// Only the very first provider becomes the default, and only when
|
||||
// the env config has no default that it would replace on save
|
||||
isDefault:
|
||||
providers.length === 0 &&
|
||||
!envProviders.some((p) => p.isDefault) &&
|
||||
!envHasDefaultModel,
|
||||
}
|
||||
onChange([...providers, newProvider])
|
||||
setSelectedId(newProvider.id)
|
||||
@@ -496,7 +506,9 @@ export function ModelsSection({
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
{/* Always empty so picking the same type again still fires */}
|
||||
<Select
|
||||
value=""
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||
>
|
||||
|
||||
+49
-14
@@ -37,6 +37,19 @@ import { SettingField } from "./setting-field"
|
||||
|
||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
||||
|
||||
// For each toggleable group, whether any of its settings has a value (from
|
||||
// the settings file or the environment)
|
||||
function groupsWithValues(map: SettingsMap): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable) continue
|
||||
result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const dict = useDictionary()
|
||||
// Localized group title/description, keyed by group id
|
||||
@@ -62,6 +75,8 @@ export default function AdminPage() {
|
||||
// Models section state
|
||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||
// Whether .env itself sets AI_MODEL (a default the panel would override)
|
||||
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
|
||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||
|
||||
@@ -88,15 +103,13 @@ export default function AdminPage() {
|
||||
const map: SettingsMap = {}
|
||||
for (const s of data.settings) map[s.key] = s
|
||||
setSettings(map)
|
||||
// Seed each toggle once from whether the group has configured
|
||||
// values; don't stomp a user's explicit toggle on later saves
|
||||
// A group stays on while it still has values (e.g. from env vars
|
||||
// that saving can't remove); a user's explicit "on" for a group
|
||||
// with no values yet is kept across saves
|
||||
setEnabledGroups((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const group of SETTING_GROUPS) {
|
||||
if (!group.toggleable || group.id in next) continue
|
||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
||||
(d) => map[d.key]?.source !== "default",
|
||||
)
|
||||
const next = groupsWithValues(map)
|
||||
for (const id of Object.keys(next)) {
|
||||
next[id] = next[id] || !!prev[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -108,10 +121,12 @@ export default function AdminPage() {
|
||||
(data: {
|
||||
providers: AdminProvider[]
|
||||
envProviders?: EnvProvider[]
|
||||
envHasDefaultModel?: boolean
|
||||
}) => {
|
||||
setProviders(data.providers)
|
||||
setSavedProviders(JSON.stringify(data.providers))
|
||||
setEnvProviders(data.envProviders ?? [])
|
||||
setEnvHasDefaultModel(!!data.envHasDefaultModel)
|
||||
},
|
||||
[],
|
||||
)
|
||||
@@ -181,8 +196,9 @@ export default function AdminPage() {
|
||||
return () => observer.disconnect()
|
||||
}, [authedPassword])
|
||||
|
||||
// value undefined drops the pending change (back to the saved value)
|
||||
const handleChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
(key: string, value: string | null | undefined) => {
|
||||
setSaveMessage(null)
|
||||
setErrors((prev) => {
|
||||
if (!(key in prev)) return prev
|
||||
@@ -201,7 +217,7 @@ export default function AdminPage() {
|
||||
value === "" &&
|
||||
(!state || state.source !== "file") &&
|
||||
!isSecretValue(state?.value)
|
||||
if (isRevert || isNoop) {
|
||||
if (value === undefined || isRevert || isNoop) {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
@@ -225,9 +241,10 @@ export default function AdminPage() {
|
||||
const next = { ...prev }
|
||||
for (const key of keys) {
|
||||
if (!enabled) {
|
||||
// Stage deletion only for values currently set
|
||||
if (settings[key]?.source !== "default")
|
||||
next[key] = null
|
||||
// Stage deletion of saved values; drop unsaved input
|
||||
if (settings[key]?.source === "default")
|
||||
delete next[key]
|
||||
else next[key] = null
|
||||
} else if (next[key] === null) {
|
||||
delete next[key]
|
||||
}
|
||||
@@ -447,6 +464,7 @@ export default function AdminPage() {
|
||||
<ModelsSection
|
||||
providers={providers}
|
||||
envProviders={envProviders}
|
||||
envHasDefaultModel={envHasDefaultModel}
|
||||
disabled={!writable || saving}
|
||||
password={authedPassword}
|
||||
onChange={(next) => {
|
||||
@@ -462,6 +480,11 @@ export default function AdminPage() {
|
||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||
const groupOff =
|
||||
group.toggleable && !enabledGroups[group.id]
|
||||
// Values from env vars can't be removed here, so the
|
||||
// group can't be turned off from the panel
|
||||
const envLocked = defs.some(
|
||||
(d) => settings[d.key]?.source === "env",
|
||||
)
|
||||
const fieldsDisabled = !writable || saving || !!groupOff
|
||||
const gt = groupText(group.id)
|
||||
const title = gt?.title ?? group.title
|
||||
@@ -480,6 +503,11 @@ export default function AdminPage() {
|
||||
</h2>
|
||||
{group.toggleable && (
|
||||
<label
|
||||
title={
|
||||
envLocked
|
||||
? dict.admin.sourceEnvTitle
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||
enabledGroups[group.id]
|
||||
@@ -494,7 +522,11 @@ export default function AdminPage() {
|
||||
checked={
|
||||
!!enabledGroups[group.id]
|
||||
}
|
||||
disabled={!writable || saving}
|
||||
disabled={
|
||||
!writable ||
|
||||
saving ||
|
||||
envLocked
|
||||
}
|
||||
aria-label={formatMessage(
|
||||
dict.admin.enableGroup,
|
||||
{ group: title },
|
||||
@@ -579,6 +611,9 @@ export default function AdminPage() {
|
||||
setPending({})
|
||||
setErrors({})
|
||||
setProviders(JSON.parse(savedProviders))
|
||||
setEnabledGroups(
|
||||
groupsWithValues(settings),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{dict.admin.discard}
|
||||
|
||||
@@ -73,8 +73,10 @@ export function SecretInput({
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const [show, setShow] = useState(false)
|
||||
// The stored marker as it was at mount, to revert to on empty
|
||||
const [original] = useState(value)
|
||||
// The stored marker to revert to on empty. Refreshed whenever the parent
|
||||
// passes server state (a marker or nothing), e.g. after a save.
|
||||
const [original, setOriginal] = useState(value)
|
||||
if (typeof value !== "string" && value !== original) setOriginal(value)
|
||||
const hadStored = isSecretValue(original)
|
||||
const text = typeof value === "string" ? value : ""
|
||||
const placeholder = isSecretValue(value)
|
||||
@@ -146,7 +148,8 @@ export function SettingField({
|
||||
pendingValue: string | null | undefined
|
||||
error?: string
|
||||
disabled: boolean
|
||||
onChange: (value: string | null) => void
|
||||
// undefined drops the pending change (back to the saved value)
|
||||
onChange: (value: string | null | undefined) => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const isDirty = pendingValue !== undefined
|
||||
@@ -226,16 +229,18 @@ export function SettingField({
|
||||
case "secret":
|
||||
control = (
|
||||
<div className="w-full max-w-md">
|
||||
{/* Clearing a saved secret reverts to it; the X button deletes */}
|
||||
<SecretInput
|
||||
id={inputId}
|
||||
keepOnEmpty={source === "file"}
|
||||
value={
|
||||
isDirty
|
||||
? (pendingValue ?? "")
|
||||
: (secretState ?? currentValue)
|
||||
: (secretState ?? undefined)
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(v) =>
|
||||
onChange(typeof v === "string" ? v : "")
|
||||
onChange(typeof v === "string" ? v : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+12
-17
@@ -37,7 +37,6 @@ export default function Home() {
|
||||
)
|
||||
|
||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||
const isMobileRef = useRef(false)
|
||||
|
||||
// Load preferences from localStorage after mount
|
||||
useEffect(() => {
|
||||
@@ -48,7 +47,9 @@ export default function Home() {
|
||||
const currentLocale = pathParts[0]
|
||||
if (currentLocale !== savedLocale) {
|
||||
pathParts[0] = savedLocale
|
||||
router.replace(`/${pathParts.join("/")}`)
|
||||
// Keep the query (e.g. ?session=) and hash
|
||||
const { search, hash } = window.location
|
||||
router.replace(`/${pathParts.join("/")}${search}${hash}`)
|
||||
return // Wait for redirect
|
||||
}
|
||||
}
|
||||
@@ -106,27 +107,17 @@ export default function Home() {
|
||||
resetDrawioReady()
|
||||
}
|
||||
|
||||
// Check mobile - reset draw.io before crossing breakpoint
|
||||
const isInitialRenderRef = useRef(true)
|
||||
// Check mobile. The draw.io iframe is not remounted when crossing the
|
||||
// breakpoint (only the chat panel is), so its ready state stays as is.
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const newIsMobile = window.innerWidth < 768
|
||||
if (
|
||||
!isInitialRenderRef.current &&
|
||||
newIsMobile !== isMobileRef.current
|
||||
) {
|
||||
setIsDrawioReady(false)
|
||||
resetDrawioReady()
|
||||
}
|
||||
isMobileRef.current = newIsMobile
|
||||
isInitialRenderRef.current = false
|
||||
setIsMobile(newIsMobile)
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [resetDrawioReady])
|
||||
}, [])
|
||||
|
||||
const toggleChatPanel = () => {
|
||||
const panel = chatPanelRef.current
|
||||
@@ -193,7 +184,11 @@ export default function Home() {
|
||||
noExitBtn: true,
|
||||
dark:
|
||||
darkMode || drawioUi === "dark",
|
||||
lang: currentLang,
|
||||
// draw.io names Traditional Chinese "zh-tw"
|
||||
lang:
|
||||
currentLang === "zh-Hant"
|
||||
? "zh-tw"
|
||||
: currentLang,
|
||||
// Enable offline mode in Electron to disable external service calls
|
||||
...(isElectron && {
|
||||
offline: true,
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
mergeSecrets,
|
||||
validateAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
|
||||
import {
|
||||
getEnvFallback,
|
||||
isSettingsWritable,
|
||||
saveSettings,
|
||||
} from "@/lib/admin/settings"
|
||||
import { loadEnvServerModelsConfig } from "@/lib/server-model-config"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
@@ -33,6 +37,9 @@ async function payload() {
|
||||
models: p.models,
|
||||
isDefault: !!p.default && !adminHasDefault,
|
||||
})) ?? [],
|
||||
// Whether .env sets a default model. getEnvFallback skips the value
|
||||
// the panel overlays onto process.env, so a panel default doesn't count.
|
||||
envHasDefaultModel: !!getEnvFallback("AI_MODEL"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+110
-111
@@ -12,14 +12,17 @@ import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import {
|
||||
getAIModel,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsImageInput,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
isMinimalDiagram,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
@@ -30,16 +33,27 @@ import {
|
||||
recordTokenUsage,
|
||||
} from "@/lib/dynamo-quota-manager"
|
||||
import {
|
||||
endTrace,
|
||||
getTelemetryConfig,
|
||||
setTraceInput,
|
||||
setTraceOutput,
|
||||
wrapWithObserve,
|
||||
} from "@/lib/langfuse"
|
||||
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 { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const maxDuration = 120
|
||||
// No explicit cap: a reasoning model can spend minutes planning before it emits
|
||||
// the tool call, so take whatever the host allows. Vercel's own default is 300s,
|
||||
// which is also where Node's response-body timeout on the upstream stream lands.
|
||||
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
@@ -71,24 +85,14 @@ function createCachedStreamResponse(xml: string): Response {
|
||||
return createUIMessageStreamResponse({ stream })
|
||||
}
|
||||
|
||||
// Responses streamed from the model, whose trace streamText's callbacks end
|
||||
const modelStreamResponses = new WeakSet<Response>()
|
||||
|
||||
// Inner handler function
|
||||
async function handleChatRequest(req: Request): Promise<Response> {
|
||||
// Check for access code
|
||||
const accessCodes =
|
||||
process.env.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length > 0) {
|
||||
const accessCodeHeader = req.headers.get("x-access-code")
|
||||
if (!accessCodeHeader || !accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
}
|
||||
const accessDenied = checkAccessCode(req)
|
||||
if (accessDenied) return accessDenied
|
||||
|
||||
const body = await req.json()
|
||||
const { messages, xml, previousXml, sessionId } = body
|
||||
@@ -187,6 +191,15 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
baseUrl = `${origin}/api/edgeai`
|
||||
}
|
||||
|
||||
// Same rule as validate-model: with ALLOW_PRIVATE_URLS=false a request may
|
||||
// not point the server at a private or internal address
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return Response.json(
|
||||
{ error: "Private or internal base URLs are not allowed." },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get cookie header for EdgeOne authentication (eo_token, eo_time)
|
||||
const cookieHeader = req.headers.get("cookie")
|
||||
|
||||
@@ -196,8 +209,9 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
baseUrlEnv?: string
|
||||
provider?: string
|
||||
} = {}
|
||||
let serverModel: FlattenedServerModel | null = null
|
||||
if (selectedModelId?.startsWith("server:")) {
|
||||
const serverModel = await findServerModelById(selectedModelId)
|
||||
serverModel = await findServerModelById(selectedModelId)
|
||||
console.log(
|
||||
`[Server Model Lookup] ID: ${selectedModelId}, Found: ${!!serverModel}, Provider: ${serverModel?.provider}`,
|
||||
)
|
||||
@@ -216,7 +230,8 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
provider: serverModelConfig.provider || provider,
|
||||
baseUrl,
|
||||
apiKey: req.headers.get("x-ai-api-key"),
|
||||
modelId: req.headers.get("x-ai-model"),
|
||||
// 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"),
|
||||
@@ -226,11 +241,14 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
...serverModelConfig,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: req.headers.get("x-vertex-api-key"),
|
||||
// Pass cookies for EdgeOne Pages authentication
|
||||
...(provider === "edgeone" &&
|
||||
cookieHeader && {
|
||||
headers: { cookie: cookieHeader },
|
||||
}),
|
||||
// Pass cookies for EdgeOne Pages authentication, and the access code,
|
||||
// which the EdgeOne function checks too
|
||||
...(provider === "edgeone" && {
|
||||
headers: {
|
||||
...(cookieHeader && { cookie: cookieHeader }),
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Read minimal style preference from header
|
||||
@@ -242,13 +260,42 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
|
||||
// Get AI model with optional client overrides
|
||||
const {
|
||||
model,
|
||||
model: baseModel,
|
||||
providerOptions,
|
||||
headers,
|
||||
modelId,
|
||||
provider: resolvedProvider,
|
||||
} = getAIModel(clientOverrides)
|
||||
|
||||
// On the server's own keys, only run models the server offers: a server
|
||||
// model picked by id (its model name is fixed above) or one in AI_MODEL.
|
||||
// With their own key, users can run any model.
|
||||
const onServerCredentials = usesServerCredentials(
|
||||
resolvedProvider,
|
||||
clientOverrides,
|
||||
)
|
||||
const envModels =
|
||||
process.env.AI_MODEL?.split(",").map((m) => m.trim()) || []
|
||||
if (onServerCredentials && !serverModel && !envModels.includes(modelId)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Model "${modelId}" is not available on this server. Add your own API key in Settings to use it.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Retry with a smaller budget if the provider rejects the requested one
|
||||
const model = withOutputTokenLimitFallback(baseModel)
|
||||
|
||||
// The user setting can raise the budget only on their own key (desktop users
|
||||
// can still raise it themselves); on the server's keys it can only lower it
|
||||
const maxOutputTokens = resolveMaxOutputTokens(
|
||||
req.headers.get("x-max-output-tokens"),
|
||||
onServerCredentials,
|
||||
)
|
||||
console.log(`[maxOutputTokens] ${maxOutputTokens}`)
|
||||
|
||||
// Check if model supports prompt caching
|
||||
const shouldCache = supportsPromptCaching(modelId)
|
||||
console.log(
|
||||
@@ -266,16 +313,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
|
||||
lastUserMessage?.parts?.filter((part: any) => part.type === "file") ||
|
||||
[]
|
||||
|
||||
// Check if user is sending images to a model that doesn't support them
|
||||
// AI SDK silently drops unsupported parts, so we need to catch this early
|
||||
if (fileParts.length > 0 && !supportsImageInput(modelId)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// Note: we used to pre-emptively reject images for models we guessed were
|
||||
// text-only (by name matching). That heuristic misfired on newer models
|
||||
// (see issue #874), so we now let the request through and surface the real
|
||||
// provider error if the model genuinely can't accept images.
|
||||
|
||||
// User input only - XML is now in a separate cached system message
|
||||
const formattedUserInput = `User input:
|
||||
@@ -332,32 +373,9 @@ ${userInputText}
|
||||
)
|
||||
|
||||
// Filter out tool-calls with invalid inputs (from failed repair or interrupted streaming)
|
||||
// Bedrock API rejects messages where toolUse.input is not a valid JSON object
|
||||
enhancedMessages = enhancedMessages
|
||||
.map((msg: any) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||
return msg
|
||||
}
|
||||
const filteredContent = msg.content.filter((part: any) => {
|
||||
if (part.type === "tool-call") {
|
||||
// Check if input is a valid object (not null, undefined, or empty)
|
||||
if (
|
||||
!part.input ||
|
||||
typeof part.input !== "object" ||
|
||||
Object.keys(part.input).length === 0
|
||||
) {
|
||||
console.warn(
|
||||
`[route.ts] Filtering out tool-call with invalid input:`,
|
||||
{ toolName: part.toolName, input: part.input },
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return { ...msg, content: filteredContent }
|
||||
})
|
||||
.filter((msg: any) => msg.content && msg.content.length > 0)
|
||||
// and their results. Bedrock API rejects messages where toolUse.input is not a valid
|
||||
// JSON object, and every provider rejects a tool result whose call is gone.
|
||||
enhancedMessages = dropInvalidToolCalls(enhancedMessages)
|
||||
|
||||
// DEBUG: Log modelMessages structure (what's being sent to AI)
|
||||
console.log("[route.ts] Model messages count:", enhancedMessages.length)
|
||||
@@ -402,7 +420,7 @@ ${userInputText}
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType,
|
||||
mediaType: filePart.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -463,7 +481,7 @@ ${previousXml}
|
||||
${xml || ""}
|
||||
"""
|
||||
|
||||
IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on the canvas right now. The user can manually add, delete, or modify shapes directly in draw.io. Always count and describe elements based on the CURRENT XML, not on what you previously generated. If both previous and current XML are shown, compare them to understand what the user changed. When using edit_diagram, COPY search patterns exactly from the CURRENT XML - attribute order matters!`
|
||||
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.`
|
||||
|
||||
const systemMessages = isSingleSystemProvider
|
||||
? [
|
||||
@@ -500,9 +518,9 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
const result = streamText({
|
||||
model,
|
||||
abortSignal: req.signal,
|
||||
...(process.env.MAX_OUTPUT_TOKENS && {
|
||||
maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10),
|
||||
}),
|
||||
// Must be sent: unset means the provider's own default, and Bedrock's is
|
||||
// 4096, enough for a small diagram, so larger ones were cut off mid-attribute.
|
||||
maxOutputTokens,
|
||||
stopWhen: stepCountIs(5),
|
||||
// Repair truncated tool calls when maxOutputTokens is reached mid-JSON
|
||||
experimental_repairToolCall: async ({ toolCall, error }) => {
|
||||
@@ -520,23 +538,11 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
error.name === "AI_InvalidToolInputError"
|
||||
) {
|
||||
try {
|
||||
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle
|
||||
let inputToRepair = toolCall.input
|
||||
if (typeof inputToRepair === "string") {
|
||||
// Fix `:=` instead of `: ` (LLM sometimes generates this)
|
||||
inputToRepair = inputToRepair.replace(/:=/g, ": ")
|
||||
// Fix `= "` instead of `: "`
|
||||
inputToRepair = inputToRepair.replace(/=\s*"/g, ': "')
|
||||
// Fix inconsistent quote escaping in XML attributes within JSON strings
|
||||
// Pattern: attribute="value\" where opening quote is unescaped but closing is escaped
|
||||
// Example: y="-20\" should be y=\"-20\"
|
||||
inputToRepair = inputToRepair.replace(
|
||||
/(\w+)="([^"]*?)\\"/g,
|
||||
'$1=\\"$2\\"',
|
||||
)
|
||||
}
|
||||
// Use jsonrepair to fix truncated JSON
|
||||
const repairedInput = jsonrepair(inputToRepair)
|
||||
// Pre-process to fix common LLM JSON errors that jsonrepair can't handle,
|
||||
// then use jsonrepair to fix truncated JSON
|
||||
const repairedInput = jsonrepair(
|
||||
fixToolInputJson(toolCall.input),
|
||||
)
|
||||
console.log(
|
||||
`[repairToolCall] Repaired truncated JSON for tool: ${toolCall.toolName}`,
|
||||
)
|
||||
@@ -546,26 +552,8 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
`[repairToolCall] Failed to repair JSON for tool: ${toolCall.toolName}`,
|
||||
repairError,
|
||||
)
|
||||
// Return a placeholder input to avoid API errors in multi-step
|
||||
// The tool will fail gracefully on client side
|
||||
if (toolCall.toolName === "edit_diagram") {
|
||||
return {
|
||||
...toolCall,
|
||||
input: {
|
||||
operations: [],
|
||||
_error: "JSON repair failed - no operations to apply",
|
||||
},
|
||||
}
|
||||
}
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
return {
|
||||
...toolCall,
|
||||
input: {
|
||||
xml: "",
|
||||
_error: "JSON repair failed - empty diagram",
|
||||
},
|
||||
}
|
||||
}
|
||||
// Keep the original error, so the model and the client see why
|
||||
// the input was rejected and the model can retry the call
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -588,7 +576,7 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
|
||||
// Record token usage for server-side quota tracking (if enabled)
|
||||
// Use totalUsage (cumulative across all steps) instead of usage (final step only)
|
||||
// Include all 4 token types: input, output, cache read, cache write
|
||||
// inputTokens already includes cache reads and writes in AI SDK 6
|
||||
if (
|
||||
isQuotaEnabled() &&
|
||||
!hasOwnApiKey &&
|
||||
@@ -597,12 +585,16 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
) {
|
||||
const totalTokens =
|
||||
(totalUsage.inputTokens || 0) +
|
||||
(totalUsage.outputTokens || 0) +
|
||||
(totalUsage.cachedInputTokens || 0) +
|
||||
(totalUsage.inputTokenDetails?.cacheWriteTokens || 0)
|
||||
(totalUsage.outputTokens || 0)
|
||||
recordTokenUsage(userId, totalTokens)
|
||||
}
|
||||
},
|
||||
// onFinish is skipped when the stream fails or is aborted, so end the trace here
|
||||
onError: ({ error }) => {
|
||||
console.error(error) // what AI SDK does without an onError
|
||||
endTrace()
|
||||
},
|
||||
onAbort: () => endTrace(),
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
@@ -774,7 +766,7 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
}),
|
||||
})
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
const response = result.toUIMessageStreamResponse({
|
||||
sendReasoning: true,
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
@@ -788,6 +780,8 @@ Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
modelStreamResponses.add(response)
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper to categorize errors and return appropriate response
|
||||
@@ -854,11 +848,16 @@ function handleError(error: unknown): Response {
|
||||
|
||||
// Wrap handler with error handling
|
||||
async function safeHandler(req: Request): Promise<Response> {
|
||||
let response: Response
|
||||
try {
|
||||
return await handleChatRequest(req)
|
||||
response = await handleChatRequest(req)
|
||||
} catch (error) {
|
||||
return handleError(error)
|
||||
response = 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)
|
||||
|
||||
+106
-28
@@ -1,13 +1,69 @@
|
||||
import { extract } from "@extractus/article-extractor"
|
||||
import { extractFromHtml } from "@extractus/article-extractor"
|
||||
import { NextResponse } from "next/server"
|
||||
import TurndownService from "turndown"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { isPrivateUrl } from "@/lib/ssrf-protection"
|
||||
|
||||
const MAX_CONTENT_LENGTH = 150000 // Match PDF limit
|
||||
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
const EXTRACT_TIMEOUT_MS = 15000
|
||||
const USER_AGENT = "Mozilla/5.0 (compatible; NextAIDrawio/1.0)"
|
||||
|
||||
// Detect the page's charset so non-UTF-8 pages (Shift_JIS/GBK/EUC/Big5, common
|
||||
// on CJK sites) are decoded correctly. Response.text() always assumes UTF-8 and
|
||||
// would produce mojibake; the article-extractor library does the same detection
|
||||
// when it fetches the page itself, which we no longer rely on.
|
||||
function detectCharset(
|
||||
contentType: string | null,
|
||||
buffer: ArrayBuffer,
|
||||
): string {
|
||||
// 1. HTTP Content-Type header charset (most authoritative).
|
||||
const headerCharset = contentType?.match(/charset=([^;]+)/i)?.[1]?.trim()
|
||||
// 2. <meta charset> / <meta http-equiv> in the first bytes of the document.
|
||||
const head = new TextDecoder("utf-8").decode(buffer.slice(0, 4096))
|
||||
const metaCharset =
|
||||
head.match(/<meta[^>]+charset=["']?\s*([\w-]+)/i)?.[1] ||
|
||||
head.match(/<meta[^>]+content=["'][^"']*charset=([\w-]+)/i)?.[1]
|
||||
const charset = (headerCharset || metaCharset || "utf-8").toLowerCase()
|
||||
// TextDecoder throws on unknown encoding labels; fall back to UTF-8.
|
||||
try {
|
||||
new TextDecoder(charset)
|
||||
return charset
|
||||
} catch {
|
||||
return "utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
// Read the response body, giving up once it passes MAX_RESPONSE_BYTES so a
|
||||
// huge download can't exhaust server memory. Returns null when too large.
|
||||
async function readLimitedBody(
|
||||
response: Response,
|
||||
): Promise<ArrayBuffer | null> {
|
||||
if (Number(response.headers.get("content-length")) > MAX_RESPONSE_BYTES) {
|
||||
return null
|
||||
}
|
||||
if (!response.body) return new ArrayBuffer(0)
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let total = 0
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel()
|
||||
return null
|
||||
}
|
||||
chunks.push(value)
|
||||
}
|
||||
return new Blob(chunks as BlobPart[]).arrayBuffer()
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
const { url } = await req.json()
|
||||
|
||||
@@ -31,21 +87,31 @@ export async function POST(req: Request) {
|
||||
// SSRF protection: parse-url has no use case for fetching internal
|
||||
// hosts, so private URLs are always rejected. ALLOW_PRIVATE_URLS only
|
||||
// governs LLM provider baseUrl overrides (validate-model, chat).
|
||||
if (isPrivateUrl(url)) {
|
||||
if (await isPrivateUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot access private/internal URLs" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
const headController = new AbortController()
|
||||
const headTimeout = setTimeout(() => headController.abort(), 3000)
|
||||
// Fetch the page ourselves so we control redirect handling. The
|
||||
// article-extractor library follows redirects internally and ignores a
|
||||
// `redirect` option, which would let a public URL 302 to an internal
|
||||
// host and bypass the SSRF check above. `redirect: "error"` rejects any
|
||||
// redirect outright.
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
|
||||
let html: string
|
||||
try {
|
||||
const headResponse = await fetch(url, {
|
||||
method: "HEAD",
|
||||
const response = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
signal: headController.signal,
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
})
|
||||
const contentType = headResponse.headers.get("content-type")
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (contentType?.includes("application/pdf")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -54,27 +120,25 @@ export async function POST(req: Request) {
|
||||
{ status: 422 },
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"HEAD pre-check failed, proceeding with extraction:",
|
||||
err,
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(headTimeout)
|
||||
}
|
||||
|
||||
// Extract article content with timeout to avoid tying up server resources
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, EXTRACT_TIMEOUT_MS)
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
let article
|
||||
try {
|
||||
article = await extract(url, undefined, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
signal: controller.signal,
|
||||
})
|
||||
const buffer = await readLimitedBody(response)
|
||||
if (!buffer) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Page exceeds the ${MAX_RESPONSE_BYTES / 1024 / 1024} MB download limit`,
|
||||
},
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
const charset = detectCharset(contentType, buffer)
|
||||
html = new TextDecoder(charset).decode(buffer)
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
@@ -82,11 +146,25 @@ export async function POST(req: Request) {
|
||||
{ status: 504 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
// Redirects are rejected with a TypeError ("failed to fetch" /
|
||||
// "unexpected redirect") when redirect: "error" is set.
|
||||
return NextResponse.json(
|
||||
{ error: "Could not fetch URL content" },
|
||||
{ status: 400 },
|
||||
)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
// extractFromHtml throws (not returns null) on empty/non-HTML bodies,
|
||||
// so map any parse error to the same 400 as the no-content case.
|
||||
let article: Awaited<ReturnType<typeof extractFromHtml>>
|
||||
try {
|
||||
article = await extractFromHtml(html, url)
|
||||
} catch {
|
||||
article = null
|
||||
}
|
||||
|
||||
if (!article || !article.content) {
|
||||
return NextResponse.json(
|
||||
{ error: "Could not extract content from URL" },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { streamObject } from "ai"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import { getValidationModel } from "@/lib/ai-providers"
|
||||
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
|
||||
import {
|
||||
@@ -13,6 +14,9 @@ import {
|
||||
|
||||
export const maxDuration = 30
|
||||
|
||||
// Data URL length cap (~3.75 MB of PNG), well above a normal diagram capture
|
||||
const MAX_IMAGE_DATA_LENGTH = 5 * 1024 * 1024
|
||||
|
||||
interface ValidateDiagramRequest {
|
||||
imageData: string // Base64 PNG data URL
|
||||
sessionId?: string
|
||||
@@ -44,6 +48,10 @@ function createStreamingResponse(result: ValidationResult): Response {
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
// Uses the server's model credentials, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
// Check if VLM validation is enabled (default: true)
|
||||
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
|
||||
@@ -72,6 +80,13 @@ export async function POST(req: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
if (imageData.length > MAX_IMAGE_DATA_LENGTH) {
|
||||
return Response.json(
|
||||
{ error: "Image data too large" },
|
||||
{ status: 413 },
|
||||
)
|
||||
}
|
||||
|
||||
// Get the validation model
|
||||
let model
|
||||
try {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { generateText } from "ai"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createOllama } from "ollama-ai-provider-v2"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import {
|
||||
AIHUBMIX_APP_CODE,
|
||||
isAihubmixStandardBaseURL,
|
||||
@@ -33,7 +34,24 @@ interface ValidateRequest {
|
||||
vertexApiKey?: string // Express Mode API key
|
||||
}
|
||||
|
||||
// With private URLs blocked, a public baseUrl could still redirect the
|
||||
// request to an internal host, so redirects are refused in that case.
|
||||
function redirectGuardedFetch(): typeof fetch | undefined {
|
||||
if (allowPrivateUrls()) return undefined
|
||||
return async (input, init) => {
|
||||
const response = await fetch(input, { ...init, redirect: "manual" })
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new Error("Redirects are not allowed for custom base URLs")
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// Lets the server send requests to arbitrary URLs, so require the access code
|
||||
const accessError = checkAccessCode(req)
|
||||
if (accessError) return accessError
|
||||
|
||||
try {
|
||||
const body: ValidateRequest = await req.json()
|
||||
const {
|
||||
@@ -56,7 +74,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// SECURITY: Block SSRF attacks via custom baseUrl
|
||||
if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) {
|
||||
if (baseUrl && !allowPrivateUrls() && (await isPrivateUrl(baseUrl))) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: "Invalid base URL" },
|
||||
{ status: 400 },
|
||||
@@ -91,6 +109,7 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const guardedFetch = redirectGuardedFetch()
|
||||
let model: any
|
||||
|
||||
switch (provider) {
|
||||
@@ -98,6 +117,7 @@ export async function POST(req: Request) {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
@@ -107,6 +127,7 @@ export async function POST(req: Request) {
|
||||
const anthropic = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.anthropic.com/v1",
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = anthropic(modelId)
|
||||
break
|
||||
@@ -116,6 +137,7 @@ export async function POST(req: Request) {
|
||||
const google = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = google(modelId)
|
||||
break
|
||||
@@ -125,6 +147,7 @@ export async function POST(req: Request) {
|
||||
const vertex = createVertex({
|
||||
apiKey: vertexApiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = vertex(modelId)
|
||||
break
|
||||
@@ -134,6 +157,7 @@ export async function POST(req: Request) {
|
||||
const azure = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = azure.chat(modelId)
|
||||
break
|
||||
@@ -153,6 +177,7 @@ export async function POST(req: Request) {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = openrouter(modelId)
|
||||
break
|
||||
@@ -174,6 +199,7 @@ export async function POST(req: Request) {
|
||||
const aihubmixCompatible = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = aihubmixCompatible.chat(modelId)
|
||||
}
|
||||
@@ -185,6 +211,7 @@ export async function POST(req: Request) {
|
||||
const ds = createDeepSeek({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = ds(modelId)
|
||||
} else {
|
||||
@@ -197,6 +224,7 @@ export async function POST(req: Request) {
|
||||
const sf = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl || "https://api.siliconflow.cn/v1",
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = sf.chat(modelId)
|
||||
break
|
||||
@@ -213,6 +241,7 @@ export async function POST(req: Request) {
|
||||
baseUrl ||
|
||||
process.env.OLLAMA_BASE_URL ||
|
||||
"https://ollama.com/api",
|
||||
fetch: guardedFetch,
|
||||
...(ollamaApiKey && {
|
||||
headers: { Authorization: `Bearer ${ollamaApiKey}` },
|
||||
}),
|
||||
@@ -225,6 +254,7 @@ export async function POST(req: Request) {
|
||||
const gw = createGateway({
|
||||
apiKey,
|
||||
...(baseUrl && { baseURL: baseUrl }),
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = gw(modelId)
|
||||
break
|
||||
@@ -232,13 +262,16 @@ export async function POST(req: Request) {
|
||||
|
||||
case "edgeone": {
|
||||
// EdgeOne uses OpenAI-compatible API via Edge Functions
|
||||
// Need to pass cookies for EdgeOne Pages authentication
|
||||
// Need to pass cookies for EdgeOne Pages authentication,
|
||||
// and the access code, which the edge function also checks
|
||||
const cookieHeader = req.headers.get("cookie") || ""
|
||||
const edgeone = createOpenAI({
|
||||
apiKey: "edgeone", // EdgeOne doesn't require API key
|
||||
baseURL: baseUrl || "/api/edgeai",
|
||||
fetch: guardedFetch,
|
||||
headers: {
|
||||
cookie: cookieHeader,
|
||||
"x-access-code": req.headers.get("x-access-code") || "",
|
||||
},
|
||||
})
|
||||
model = edgeone.chat(modelId)
|
||||
@@ -250,6 +283,7 @@ export async function POST(req: Request) {
|
||||
const sglang = createOpenAI({
|
||||
apiKey: apiKey || "not-needed",
|
||||
baseURL: baseUrl || "http://127.0.0.1:8000/v1",
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = sglang.chat(modelId)
|
||||
break
|
||||
@@ -267,12 +301,14 @@ export async function POST(req: Request) {
|
||||
const doubao = createDeepSeek({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = doubao(modelId)
|
||||
} else {
|
||||
const doubao = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: doubaoBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = doubao.chat(modelId)
|
||||
}
|
||||
@@ -286,7 +322,7 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
// Initiate a streaming request (required for QwQ-32B and certain Qwen3 models)
|
||||
const response = await fetch(
|
||||
const response = await (guardedFetch ?? fetch)(
|
||||
`${baseURL}/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -307,9 +343,15 @@ export async function POST(req: Request) {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
// Log the body but return only the status: the
|
||||
// caller chooses baseUrl, so the body may come from
|
||||
// any host the server can reach
|
||||
console.error(
|
||||
"[validate-model] ModelScope error body:",
|
||||
await response.text(),
|
||||
)
|
||||
throw new Error(
|
||||
`ModelScope API error (${response.status}): ${errorText}`,
|
||||
`ModelScope API error (${response.status})`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -360,24 +402,28 @@ export async function POST(req: Request) {
|
||||
const minimax = createAnthropic({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
} else {
|
||||
const minimax = createOpenAI({
|
||||
apiKey,
|
||||
baseURL: minimaxBaseUrl,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = minimax.chat(modelId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita - OpenAI compatible
|
||||
// GLM, Qwen, Kimi, Qiniu, Novita, MiMo, Atlas Cloud - OpenAI compatible
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
const baseURL =
|
||||
baseUrl ||
|
||||
PROVIDER_INFO[provider as ProviderName]?.defaultBaseUrl ||
|
||||
@@ -396,6 +442,7 @@ export async function POST(req: Request) {
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
fetch: guardedFetch,
|
||||
})
|
||||
model = openai.chat(modelId)
|
||||
break
|
||||
|
||||
@@ -1,29 +1,9 @@
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
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) {
|
||||
if (checkAccessCode(req)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Access code is required" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!accessCodes.includes(accessCodeHeader)) {
|
||||
return Response.json(
|
||||
{ valid: false, message: "Invalid access code" },
|
||||
{ valid: false, message: "Invalid or missing access code" },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!public"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
+68
-36
@@ -11,7 +11,9 @@ import {
|
||||
} from "lucide-react"
|
||||
import type React from "react"
|
||||
import {
|
||||
type Dispatch,
|
||||
forwardRef,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -41,9 +43,20 @@ import { FilePreviewList } from "./file-preview-list"
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const MAX_FILES = 5
|
||||
// Image formats every supported model provider accepts (SVG is read as text)
|
||||
const SUPPORTED_IMAGE_TYPES = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]
|
||||
|
||||
function isValidFileType(file: File): boolean {
|
||||
return file.type.startsWith("image/") || isPdfFile(file) || isTextFile(file)
|
||||
return (
|
||||
SUPPORTED_IMAGE_TYPES.includes(file.type) ||
|
||||
isPdfFile(file) ||
|
||||
isTextFile(file)
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -164,7 +177,7 @@ interface ChatInputProps {
|
||||
{ text: string; charCount: number; isExtracting: boolean }
|
||||
>
|
||||
urlData?: Map<string, UrlData>
|
||||
onUrlChange?: (data: Map<string, UrlData>) => void
|
||||
onUrlChange?: Dispatch<SetStateAction<Map<string, UrlData>>>
|
||||
|
||||
sessionId?: string
|
||||
error?: Error | null
|
||||
@@ -244,6 +257,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
|
||||
const isDisabled =
|
||||
(status === "streaming" || status === "submitted") && !error
|
||||
// Block sending until attached files and URLs have their text, otherwise
|
||||
// their content would be silently dropped
|
||||
const isExtractingAttachments =
|
||||
files.some((file) => pdfData.get(file)?.isExtracting) ||
|
||||
Array.from(urlData?.values() ?? []).some((d) => d.isExtracting)
|
||||
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
@@ -281,6 +299,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Enter that confirms an IME candidate must not send the message
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return
|
||||
|
||||
const shouldSend =
|
||||
sendShortcut === "enter"
|
||||
? e.key === "Enter" &&
|
||||
@@ -292,7 +313,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
if (shouldSend) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget.closest("form")
|
||||
if (form && input.trim() && !isDisabled) {
|
||||
if (
|
||||
form &&
|
||||
input.trim() &&
|
||||
!isDisabled &&
|
||||
!isExtractingAttachments
|
||||
) {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
@@ -380,13 +406,9 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
if (isDisabled) return
|
||||
|
||||
const droppedFiles = e.dataTransfer.files
|
||||
const supportedFiles = Array.from(droppedFiles).filter((file) =>
|
||||
isValidFileType(file),
|
||||
)
|
||||
|
||||
// Let validateFiles show a toast for unsupported types
|
||||
const { validFiles, errors } = validateFiles(
|
||||
supportedFiles,
|
||||
Array.from(e.dataTransfer.files),
|
||||
files.length,
|
||||
dict,
|
||||
)
|
||||
@@ -401,33 +423,34 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
|
||||
setIsExtractingUrl(true)
|
||||
|
||||
// Use functional updates so a removal or send made while extracting
|
||||
// is not overwritten when the request finishes
|
||||
try {
|
||||
const existing = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
existing.set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
})
|
||||
onUrlChange(existing)
|
||||
onUrlChange((prev) =>
|
||||
new Map(prev).set(url, {
|
||||
url,
|
||||
title: url,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const data = await extractUrlContent(url)
|
||||
|
||||
const newUrlData = new Map(existing)
|
||||
newUrlData.set(url, data)
|
||||
onUrlChange(newUrlData)
|
||||
// Skip if the URL was removed while extracting
|
||||
onUrlChange((prev) =>
|
||||
prev.has(url) ? new Map(prev).set(url, data) : prev,
|
||||
)
|
||||
|
||||
setShowUrlDialog(false)
|
||||
} catch (error) {
|
||||
// Remove the URL from the data map on error
|
||||
const newUrlData = urlData
|
||||
? new Map(urlData)
|
||||
: new Map<string, UrlData>()
|
||||
newUrlData.delete(url)
|
||||
onUrlChange(newUrlData)
|
||||
onUrlChange((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
showErrorToast(
|
||||
<span className="text-muted-foreground">
|
||||
{error instanceof Error
|
||||
@@ -463,11 +486,12 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
urlData={urlData}
|
||||
onRemoveUrl={
|
||||
onUrlChange
|
||||
? (url) => {
|
||||
const next = new Map(urlData)
|
||||
next.delete(url)
|
||||
onUrlChange(next)
|
||||
}
|
||||
? (url) =>
|
||||
onUrlChange((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(url)
|
||||
return next
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -559,7 +583,7 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,.svg,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
|
||||
multiple
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
@@ -588,7 +612,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isDisabled || !input.trim()}
|
||||
disabled={
|
||||
isDisabled ||
|
||||
isExtractingAttachments ||
|
||||
!input.trim()
|
||||
}
|
||||
size="sm"
|
||||
className="h-8 px-4 rounded-xl font-medium shadow-sm"
|
||||
aria-label={dict.chat.send}
|
||||
@@ -629,7 +657,11 @@ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
||||
<TemplateCreateDialog
|
||||
open={showSaveAsTemplate}
|
||||
onOpenChange={setShowSaveAsTemplate}
|
||||
onSuccess={() => setShowSaveAsTemplate(false)}
|
||||
onSuccess={() => {
|
||||
setShowSaveAsTemplate(false)
|
||||
// Let the template list in the lobby reload
|
||||
window.dispatchEvent(new Event("templatesChanged"))
|
||||
}}
|
||||
initialPrompt={input.trim()}
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -129,12 +129,14 @@ const getMessageTextContent = (message: UIMessage): string => {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Matches the [PDF: ...], [File: ...] and [URL: ...] sections appended to the user's text
|
||||
export const APPENDED_FILE_SECTIONS_PATTERN =
|
||||
/\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
|
||||
|
||||
// Get only the user's original text, excluding appended file content
|
||||
const getUserOriginalText = (message: UIMessage): string => {
|
||||
const fullText = getMessageTextContent(message)
|
||||
// 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()
|
||||
return fullText.replace(APPENDED_FILE_SECTIONS_PATTERN, "").trim()
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
@@ -458,6 +460,11 @@ export function ChatMessageDisplay({
|
||||
messages.length > 0 ? [messages[messages.length - 1]] : []
|
||||
|
||||
messagesToProcess.forEach((message) => {
|
||||
// Messages restored from a saved session were applied before it was
|
||||
// saved; the saved diagram is authoritative, so don't replay them
|
||||
const isRestoredMessage =
|
||||
loadedMessageIdsRef?.current.has(message.id) ?? false
|
||||
|
||||
if (message.parts) {
|
||||
message.parts.forEach((part) => {
|
||||
if (part.type?.startsWith("tool-")) {
|
||||
@@ -475,6 +482,8 @@ export function ChatMessageDisplay({
|
||||
})
|
||||
}
|
||||
|
||||
if (isRestoredMessage) return
|
||||
|
||||
if (
|
||||
part.type === "tool-display_diagram" &&
|
||||
input?.xml
|
||||
@@ -541,6 +550,32 @@ export function ChatMessageDisplay({
|
||||
part.type === "tool-edit_diagram" &&
|
||||
input?.operations
|
||||
) {
|
||||
// Failed or stopped: drop the queued preview. If the original
|
||||
// XML is still stored, the tool handler never ran (user pressed
|
||||
// stop), so undo the streamed preview here.
|
||||
if (state === "output-error") {
|
||||
if (
|
||||
pendingEditRef.current?.toolCallId ===
|
||||
toolCallId &&
|
||||
editDebounceTimeoutRef.current
|
||||
) {
|
||||
clearTimeout(editDebounceTimeoutRef.current)
|
||||
editDebounceTimeoutRef.current = null
|
||||
pendingEditRef.current = null
|
||||
}
|
||||
const originalXml =
|
||||
editDiagramOriginalXmlRef.current.get(
|
||||
toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
editDiagramOriginalXmlRef.current.delete(
|
||||
toolCallId,
|
||||
)
|
||||
onDisplayChart(originalXml, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const completeOps = getCompleteOperations(
|
||||
input.operations as DiagramOperation[],
|
||||
)
|
||||
@@ -610,9 +645,10 @@ export function ChatMessageDisplay({
|
||||
origXml,
|
||||
pending.operations,
|
||||
)
|
||||
handleDisplayChart(
|
||||
// Load the full document so other pages stay intact
|
||||
onDisplayChart(
|
||||
editedXml,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
lastProcessedXmlRef.current.set(
|
||||
pending.toolCallId +
|
||||
|
||||
+123
-53
@@ -32,6 +32,7 @@ import { useSessionManager } from "@/hooks/use-session-manager"
|
||||
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import { isMinimalDiagram } from "@/lib/chat-helpers"
|
||||
import type { DrawioTheme } from "@/lib/drawio-themes"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
|
||||
@@ -40,9 +41,12 @@ import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { UrlData } from "@/lib/url-utils"
|
||||
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
|
||||
import { useQuotaManager } from "@/lib/use-quota-manager"
|
||||
import { cn, formatXML, isRealDiagram } from "@/lib/utils"
|
||||
import { cn, formatXML, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
|
||||
import type { ValidationState } from "./chat/ValidationCard"
|
||||
import { ChatMessageDisplay } from "./chat-message-display"
|
||||
import {
|
||||
APPENDED_FILE_SECTIONS_PATTERN,
|
||||
ChatMessageDisplay,
|
||||
} from "./chat-message-display"
|
||||
import { DevXmlSimulator } from "./dev-xml-simulator"
|
||||
|
||||
// localStorage keys for persistence
|
||||
@@ -107,6 +111,18 @@ function hasToolErrors(messages: ChatMessage[]): boolean {
|
||||
return lastToolPart?.state === TOOL_ERROR_STATE
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots keep the full multi-page document, but the model only sees and
|
||||
* edits the first page, so give it the first page's mxGraphModel.
|
||||
* Older snapshots already hold a single mxGraphModel and are returned as is.
|
||||
*/
|
||||
function getFirstPageXml(xml: string): string {
|
||||
if (!xml.includes("<mxfile")) return xml
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
const model = doc.querySelector("diagram")?.querySelector("mxGraphModel")
|
||||
return model ? formatXML(new XMLSerializer().serializeToString(model)) : xml
|
||||
}
|
||||
|
||||
export default function ChatPanel({
|
||||
isVisible,
|
||||
onToggleVisibility,
|
||||
@@ -178,6 +194,7 @@ export default function ChatPanel({
|
||||
const [minimalStyle, setMinimalStyle] = useState(false)
|
||||
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
|
||||
const [customSystemMessage, setCustomSystemMessage] = useState("")
|
||||
const [maxOutputTokens, setMaxOutputTokens] = useState("")
|
||||
const [shouldFocusInput, setShouldFocusInput] = useState(false)
|
||||
|
||||
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
|
||||
@@ -204,6 +221,14 @@ export default function ChatPanel({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load output token budget from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.maxOutputTokens)
|
||||
if (stored !== null) {
|
||||
setMaxOutputTokens(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check config on mount
|
||||
useEffect(() => {
|
||||
fetch(getApiEndpoint("/api/config"))
|
||||
@@ -320,20 +345,16 @@ export default function ChatPanel({
|
||||
localStorage.setItem(STORAGE_KEYS.customSystemMessage, value)
|
||||
}, [])
|
||||
|
||||
// Ref to store the sendMessage function for use in callbacks
|
||||
const sendMessageRef = useRef<typeof sendMessage | null>(null)
|
||||
|
||||
// Callback to improve diagram with validation suggestions
|
||||
const handleImproveWithSuggestions = useCallback((feedback: string) => {
|
||||
if (sendMessageRef.current) {
|
||||
// Send the feedback as a new user message to trigger regeneration
|
||||
sendMessageRef.current({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: feedback }],
|
||||
})
|
||||
}
|
||||
// Handler for output token budget change (empty string = use server default)
|
||||
const handleMaxOutputTokensChange = useCallback((value: string) => {
|
||||
const digitsOnly = value.replace(/\D/g, "")
|
||||
setMaxOutputTokens(digitsOnly)
|
||||
localStorage.setItem(STORAGE_KEYS.maxOutputTokens, digitsOnly)
|
||||
}, [])
|
||||
|
||||
// Failed VLM validations in the current user turn (reset on user action)
|
||||
const validationRetryCountRef = useRef(0)
|
||||
|
||||
// VLM validation hook using AI SDK's useObject
|
||||
const { validateWithFallback } = useValidateDiagram()
|
||||
|
||||
@@ -341,6 +362,7 @@ export default function ChatPanel({
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
@@ -502,11 +524,6 @@ export default function ChatPanel({
|
||||
},
|
||||
})
|
||||
|
||||
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
|
||||
useEffect(() => {
|
||||
sendMessageRef.current = sendMessage
|
||||
}, [sendMessage])
|
||||
|
||||
// Ref to track latest messages for unload persistence
|
||||
const messagesRef = useRef(messages)
|
||||
useEffect(() => {
|
||||
@@ -515,6 +532,9 @@ export default function ChatPanel({
|
||||
|
||||
// Track last synced session ID to detect external changes (e.g., URL back/forward)
|
||||
const lastSyncedSessionIdRef = useRef<string | null>(null)
|
||||
// Messages array from our latest save. A session holding this exact array was
|
||||
// created by our own save, so it must not be treated as an external switch.
|
||||
const lastSavedMessagesRef = useRef<unknown[] | null>(null)
|
||||
|
||||
// Helper: Sync UI state with session data (eliminates duplication)
|
||||
// Track message IDs that are being loaded from session (to skip animations/scroll)
|
||||
@@ -581,8 +601,10 @@ export default function ChatPanel({
|
||||
thumbnailDataUrl = latestSvgRef.current
|
||||
}
|
||||
}
|
||||
const messages = sanitizeMessages(messagesRef.current)
|
||||
lastSavedMessagesRef.current = messages
|
||||
return {
|
||||
messages: sanitizeMessages(messagesRef.current),
|
||||
messages,
|
||||
xmlSnapshots: Array.from(xmlSnapshotsRef.current.entries()),
|
||||
diagramXml: currentDiagramXml,
|
||||
thumbnailDataUrl,
|
||||
@@ -635,8 +657,13 @@ export default function ChatPanel({
|
||||
// Skip if session ID hasn't changed (our own saves don't change the ID)
|
||||
if (newSessionId === lastSyncedSessionIdRef.current) return
|
||||
|
||||
// Our own save created this session; the UI already shows its content
|
||||
const isOwnNewSession =
|
||||
newSession?.messages === lastSavedMessagesRef.current
|
||||
|
||||
// Update last synced ID
|
||||
lastSyncedSessionIdRef.current = newSessionId
|
||||
if (isOwnNewSession) return
|
||||
|
||||
// Sync UI with new session
|
||||
if (newSession) {
|
||||
@@ -777,12 +804,23 @@ export default function ChatPanel({
|
||||
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const isProcessing = status === "streaming" || status === "submitted"
|
||||
if (input.trim() && !isProcessing) {
|
||||
// Check if input matches a cached example (only when no messages yet)
|
||||
if (messages.length === 0) {
|
||||
// Attachments still extracting have no text yet. Template sends call
|
||||
// requestSubmit() and skip the disabled send button, so check here too.
|
||||
const isExtracting =
|
||||
files.some((f) => pdfData.get(f)?.isExtracting) ||
|
||||
Array.from(urlData.values()).some((d) => d.isExtracting)
|
||||
if (input.trim() && !isProcessing && !isExtracting) {
|
||||
// Check if input matches a cached example (only when no messages
|
||||
// yet and the canvas is empty, same rule as the server)
|
||||
if (
|
||||
messages.length === 0 &&
|
||||
isMinimalDiagram(chartXMLRef.current || "")
|
||||
) {
|
||||
// Pass the file name so a user's own file never matches an example
|
||||
const cached = findCachedResponse(
|
||||
input.trim(),
|
||||
files.length > 0,
|
||||
files.length === 1 ? files[0].name : undefined,
|
||||
)
|
||||
if (cached) {
|
||||
// Add user message and fake assistant response to messages
|
||||
@@ -818,6 +856,11 @@ export default function ChatPanel({
|
||||
],
|
||||
},
|
||||
] as any)
|
||||
// Snapshot the canvas before the example so editing this message works
|
||||
xmlSnapshotsRef.current.set(
|
||||
0,
|
||||
chartXMLRef.current || wrapWithMxFile(""),
|
||||
)
|
||||
setInput("")
|
||||
sessionStorage.removeItem(SESSION_STORAGE_INPUT_KEY)
|
||||
setFiles([])
|
||||
@@ -827,13 +870,6 @@ export default function ChatPanel({
|
||||
}
|
||||
|
||||
try {
|
||||
let chartXml = await onFetchChart()
|
||||
chartXml = formatXML(chartXml)
|
||||
|
||||
// Update ref directly to avoid race condition with React's async state update
|
||||
// This ensures edit_diagram has the correct XML before AI responds
|
||||
chartXMLRef.current = chartXml
|
||||
|
||||
// Build user text by concatenating input with pre-extracted text
|
||||
// (Backend only reads first text part, so we must combine them)
|
||||
const parts: any[] = []
|
||||
@@ -848,20 +884,7 @@ export default function ChatPanel({
|
||||
// Add the combined text as the first part
|
||||
parts.unshift({ type: "text", text: userText })
|
||||
|
||||
// Get previous XML from the last snapshot (before this message)
|
||||
const snapshotKeys = Array.from(
|
||||
xmlSnapshotsRef.current.keys(),
|
||||
).sort((a, b) => b - a)
|
||||
const previousXml =
|
||||
snapshotKeys.length > 0
|
||||
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
|
||||
: ""
|
||||
|
||||
// Save XML snapshot for this message (will be at index = current messages.length)
|
||||
const messageIndex = messages.length
|
||||
xmlSnapshotsRef.current.set(messageIndex, chartXml)
|
||||
|
||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||
await sendWithCurrentDiagram(parts)
|
||||
|
||||
// Token count is tracked in onFinish with actual server usage
|
||||
setInput("")
|
||||
@@ -870,10 +893,37 @@ export default function ChatPanel({
|
||||
setUrlData(new Map())
|
||||
} catch (error) {
|
||||
console.error("Error fetching chart data:", error)
|
||||
toast.error(dict.errors.failedToExport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export the current diagram, snapshot it for this message, and send
|
||||
const sendWithCurrentDiagram = async (parts: any[]) => {
|
||||
const chartXml = formatXML(await onFetchChart())
|
||||
const previousXml = getPreviousXml(messages.length)
|
||||
|
||||
// Snapshot the full multi-page document (kept fresh by autosave) so
|
||||
// regenerate/edit can restore every page; the model gets page 1 only
|
||||
xmlSnapshotsRef.current.set(
|
||||
messages.length,
|
||||
chartXMLRef.current || chartXml,
|
||||
)
|
||||
|
||||
sendChatMessage(parts, chartXml, previousXml, sessionId)
|
||||
}
|
||||
|
||||
// Send VLM validation feedback as a new user message through the normal send path
|
||||
const handleImproveWithSuggestions = async (feedback: string) => {
|
||||
if (status === "streaming" || status === "submitted") return
|
||||
try {
|
||||
await sendWithCurrentDiagram([{ type: "text", text: feedback }])
|
||||
} catch (error) {
|
||||
console.error("Error fetching chart data:", error)
|
||||
toast.error(dict.errors.failedToExport)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle session switching from history dropdown
|
||||
const handleSelectSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
@@ -977,10 +1027,9 @@ export default function ChatPanel({
|
||||
// Handle sending a template directly (called from TemplatePanel)
|
||||
const handleSendTemplate = useCallback(
|
||||
async (template: { prompt: string }) => {
|
||||
// Keep attachments: they are sent along with the template prompt
|
||||
flushSync(() => {
|
||||
setInput(template.prompt)
|
||||
setFiles([])
|
||||
setUrlData(new Map())
|
||||
})
|
||||
|
||||
const formElement = document.getElementById(
|
||||
@@ -990,7 +1039,7 @@ export default function ChatPanel({
|
||||
formElement.requestSubmit()
|
||||
}
|
||||
},
|
||||
[setInput, setFiles, setUrlData],
|
||||
[setInput],
|
||||
)
|
||||
|
||||
const handleInputChange = (
|
||||
@@ -1005,13 +1054,15 @@ export default function ChatPanel({
|
||||
}
|
||||
|
||||
// Helper functions for message actions (regenerate/edit)
|
||||
// Extract previous XML snapshot before a given message index
|
||||
// Extract previous XML snapshot (first page, as sent to the model) before a given message index
|
||||
const getPreviousXml = (beforeIndex: number): string => {
|
||||
const snapshotKeys = Array.from(xmlSnapshotsRef.current.keys())
|
||||
.filter((k) => k < beforeIndex)
|
||||
.sort((a, b) => b - a)
|
||||
return snapshotKeys.length > 0
|
||||
? xmlSnapshotsRef.current.get(snapshotKeys[0]) || ""
|
||||
? getFirstPageXml(
|
||||
xmlSnapshotsRef.current.get(snapshotKeys[0]) || "",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
||||
@@ -1063,6 +1114,7 @@ export default function ChatPanel({
|
||||
// Reset all retry/continuation state on user-initiated message
|
||||
autoRetryCountRef.current = 0
|
||||
continuationRetryCountRef.current = 0
|
||||
validationRetryCountRef.current = 0
|
||||
partialXmlRef.current = ""
|
||||
|
||||
const config = getSelectedAIConfig()
|
||||
@@ -1108,6 +1160,9 @@ export default function ChatPanel({
|
||||
...(minimalStyle && {
|
||||
"x-minimal-style": "true",
|
||||
}),
|
||||
...(maxOutputTokens && {
|
||||
"x-max-output-tokens": maxOutputTokens,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1208,7 +1263,12 @@ export default function ChatPanel({
|
||||
})
|
||||
|
||||
// Now send the message after state is guaranteed to be updated
|
||||
sendChatMessage(userParts, savedXml, previousXml, sessionId)
|
||||
sendChatMessage(
|
||||
userParts,
|
||||
getFirstPageXml(savedXml),
|
||||
previousXml,
|
||||
sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditMessage = async (messageIndex: number, newText: string) => {
|
||||
@@ -1235,10 +1295,13 @@ export default function ChatPanel({
|
||||
// Clean up snapshots for messages after the user message (they will be removed)
|
||||
cleanupSnapshotsAfter(messageIndex)
|
||||
|
||||
// Create new parts with updated text
|
||||
// Create new parts with updated text. The edit box only shows the typed
|
||||
// text, so keep the appended PDF/file/URL content
|
||||
const newParts = message.parts?.map((part: any) => {
|
||||
if (part.type === "text") {
|
||||
return { ...part, text: newText }
|
||||
const appended =
|
||||
part.text.match(APPENDED_FILE_SECTIONS_PATTERN)?.[0] ?? ""
|
||||
return { ...part, text: newText + appended }
|
||||
}
|
||||
return part
|
||||
}) || [{ type: "text", text: newText }]
|
||||
@@ -1251,7 +1314,12 @@ export default function ChatPanel({
|
||||
})
|
||||
|
||||
// Now send the edited message after state is guaranteed to be updated
|
||||
sendChatMessage(newParts, savedXml, previousXml, sessionId)
|
||||
sendChatMessage(
|
||||
newParts,
|
||||
getFirstPageXml(savedXml),
|
||||
previousXml,
|
||||
sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed view (desktop only)
|
||||
@@ -1452,6 +1520,8 @@ export default function ChatPanel({
|
||||
onVlmValidationChange={handleVlmValidationChange}
|
||||
customSystemMessage={customSystemMessage}
|
||||
onCustomSystemMessageChange={handleCustomSystemMessageChange}
|
||||
maxOutputTokens={maxOutputTokens}
|
||||
onMaxOutputTokensChange={handleMaxOutputTokensChange}
|
||||
onOpenModelConfig={() => setShowModelConfigDialog(true)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -194,6 +194,8 @@ export function ChatLobby({
|
||||
className="group w-full flex items-center gap-3 p-3 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 cursor-pointer text-left"
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Ignore keys bubbling up from the delete button
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
|
||||
@@ -55,6 +55,9 @@ export function TemplateCreateDialog({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// the chat form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
const trimmedPrompt = prompt.trim()
|
||||
if (!trimmedPrompt) {
|
||||
|
||||
@@ -39,16 +39,16 @@ export function TemplateEditDialog({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Populate form when template changes
|
||||
// Populate form each time the dialog opens, dropping any cancelled edits
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
if (open && template) {
|
||||
setTitle(template.title || "")
|
||||
setDescription(template.description || "")
|
||||
setPrompt(template.prompt || "")
|
||||
setPinned(template.pinned || false)
|
||||
setError(null)
|
||||
}
|
||||
}, [template])
|
||||
}, [open, template])
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
@@ -59,6 +59,9 @@ export function TemplateEditDialog({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// React submit events bubble through the portal; keep them away from
|
||||
// any form this dialog may be rendered in
|
||||
e.stopPropagation()
|
||||
|
||||
if (!template) return
|
||||
|
||||
|
||||
@@ -110,6 +110,10 @@ export function TemplatePanel({
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
// Reload when a template is saved elsewhere, e.g. from the chat input
|
||||
window.addEventListener("templatesChanged", loadTemplates)
|
||||
return () =>
|
||||
window.removeEventListener("templatesChanged", loadTemplates)
|
||||
}, [loadTemplates])
|
||||
|
||||
const handleCreateSuccess = () => {
|
||||
@@ -302,6 +306,28 @@ export function TemplatePanel({
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by the empty state and the list, so import works in both
|
||||
const importInput = (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
)
|
||||
const importMessageBox = importMessage && (
|
||||
<div
|
||||
className={`text-xs px-3 py-2 rounded-lg ${
|
||||
importMessage.type === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{importMessage.text}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Empty state: no templates at all
|
||||
if (!loading && templates.length === 0) {
|
||||
return (
|
||||
@@ -332,6 +358,18 @@ export function TemplatePanel({
|
||||
<Plus className="w-4 h-4" />
|
||||
{dict.templates.createFirst}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
{importInput}
|
||||
{importMessageBox && (
|
||||
<div className="mt-3">{importMessageBox}</div>
|
||||
)}
|
||||
|
||||
<TemplateCreateDialog
|
||||
open={createDialogOpen}
|
||||
@@ -389,27 +427,11 @@ export function TemplatePanel({
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{dict.templates.importTemplates}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleImport}
|
||||
className="hidden"
|
||||
/>
|
||||
{importInput}
|
||||
</div>
|
||||
|
||||
{/* Import message */}
|
||||
{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>
|
||||
)}
|
||||
{importMessageBox}
|
||||
|
||||
<div className="space-y-2">
|
||||
{loading
|
||||
@@ -447,6 +469,8 @@ export function TemplatePanel({
|
||||
handleTemplateClick(template)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
// Ignore keys bubbling up from the action buttons
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (
|
||||
e.key === "Enter" ||
|
||||
e.key === " "
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ToolCallCard({
|
||||
dict,
|
||||
}: ToolCallCardProps) {
|
||||
const callId = part.toolCallId
|
||||
const { state, input, output } = part
|
||||
const { state, input, output, errorText } = part
|
||||
// Default to expanded for all states (user can manually collapse if needed)
|
||||
const isExpanded = expandedTools[callId] ?? true
|
||||
const toolName = part.type?.replace("tool-", "")
|
||||
@@ -92,6 +92,14 @@ export function ToolCallCard({
|
||||
}
|
||||
}
|
||||
|
||||
// Incomplete XML means the output hit the length limit, unless the user
|
||||
// stopped the generation themselves
|
||||
const isTruncated =
|
||||
state === "output-error" &&
|
||||
errorText !== "Stopped by user" &&
|
||||
(toolName === "display_diagram" || toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
|
||||
const handleCopy = () => {
|
||||
let textToCopy = ""
|
||||
|
||||
@@ -161,22 +169,15 @@ export function ToolCallCard({
|
||||
</>
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Check if this is a truncation (incomplete XML) vs real error
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
(isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
Truncated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
Error
|
||||
</span>
|
||||
))}
|
||||
{input && Object.keys(input).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -224,23 +225,16 @@ export function ToolCallCard({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{output &&
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase the maxOutputLength."
|
||||
: output}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* AI SDK stores tool errors in errorText */}
|
||||
{state === "output-error" && (errorText || output) && (
|
||||
<div
|
||||
className={`px-4 py-3 border-t border-border/40 text-sm whitespace-pre-wrap break-words ${isTruncated ? "text-yellow-600" : "text-red-600"}`}
|
||||
>
|
||||
{isTruncated
|
||||
? "Output truncated due to length limits. Try a simpler request or increase Max Output Tokens in Settings."
|
||||
: (errorText ?? output)}
|
||||
</div>
|
||||
)}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface ToolPartLike {
|
||||
operations?: DiagramOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -133,6 +134,14 @@ export function ModelConfigDialog({
|
||||
modelId: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
// Model ID being typed; written to the config only when valid on blur
|
||||
const [modelIdDraft, setModelIdDraft] = useState<{
|
||||
id: string
|
||||
value: string
|
||||
} | null>(null)
|
||||
// Bumped on every credential edit so a running test can tell that its
|
||||
// results belong to the old credentials
|
||||
const credentialsVersionRef = useRef(0)
|
||||
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
|
||||
Partial<Record<ProviderName, string[]>>
|
||||
>({})
|
||||
@@ -157,6 +166,11 @@ export function ModelConfigDialog({
|
||||
(p) => p.id === selectedProviderId,
|
||||
)
|
||||
|
||||
// Discard an unfinished model ID edit when the dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) setModelIdDraft(null)
|
||||
}, [open])
|
||||
|
||||
// Cleanup validation reset timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -253,9 +267,9 @@ export function ModelConfigDialog({
|
||||
field: keyof ProviderConfig,
|
||||
value: string | boolean,
|
||||
) => {
|
||||
if (!selectedProviderId) return
|
||||
updateProvider(selectedProviderId, { [field]: value })
|
||||
// Reset validation when credentials change
|
||||
if (!selectedProviderId || !selectedProvider) return
|
||||
const updates: Partial<ProviderConfig> = { [field]: value }
|
||||
// Reset validation of the provider and its models when credentials change
|
||||
const credentialFields = [
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
@@ -265,9 +279,17 @@ export function ModelConfigDialog({
|
||||
"vertexApiKey",
|
||||
]
|
||||
if (credentialFields.includes(field)) {
|
||||
credentialsVersionRef.current++
|
||||
setValidationStatus("idle")
|
||||
updateProvider(selectedProviderId, { validated: false })
|
||||
setValidatingModelIndex(null)
|
||||
updates.validated = false
|
||||
updates.models = selectedProvider.models.map((m) => ({
|
||||
...m,
|
||||
validated: undefined,
|
||||
validationError: undefined,
|
||||
}))
|
||||
}
|
||||
updateProvider(selectedProviderId, updates)
|
||||
}
|
||||
|
||||
// Handle adding a model to current provider
|
||||
@@ -337,6 +359,7 @@ export function ModelConfigDialog({
|
||||
|
||||
let allValid = true
|
||||
let errorCount = 0
|
||||
const credentialsVersion = credentialsVersionRef.current
|
||||
|
||||
// Validate each model
|
||||
for (let i = 0; i < selectedProvider.models.length; i++) {
|
||||
@@ -346,26 +369,37 @@ export function ModelConfigDialog({
|
||||
try {
|
||||
// For EdgeOne, construct baseUrl from current origin
|
||||
const baseUrl = isEdgeOne
|
||||
? `${window.location.origin}/api/edgeai`
|
||||
? `${window.location.origin}${getApiEndpoint("/api/edgeai")}`
|
||||
: selectedProvider.baseUrl
|
||||
|
||||
const response = await fetch("/api/validate-model", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: selectedProvider.provider,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
baseUrl,
|
||||
modelId: model.modelId,
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
||||
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
|
||||
awsRegion: selectedProvider.awsRegion,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: selectedProvider.vertexApiKey,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
const response = await fetch(
|
||||
getApiEndpoint("/api/validate-model"),
|
||||
{
|
||||
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,
|
||||
modelId: model.modelId,
|
||||
// AWS Bedrock credentials
|
||||
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
||||
awsSecretAccessKey:
|
||||
selectedProvider.awsSecretAccessKey,
|
||||
awsRegion: selectedProvider.awsRegion,
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: selectedProvider.vertexApiKey,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = await response.json().catch(() => ({}))
|
||||
// Credentials changed during the test: drop the results
|
||||
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||
|
||||
if (data.valid) {
|
||||
updateModel(selectedProviderId, model.id, {
|
||||
@@ -377,10 +411,15 @@ export function ModelConfigDialog({
|
||||
errorCount++
|
||||
updateModel(selectedProviderId, model.id, {
|
||||
validated: false,
|
||||
validationError: data.error || "Validation failed",
|
||||
validationError:
|
||||
data.error ||
|
||||
(response.ok
|
||||
? "Validation failed"
|
||||
: `Request failed (${response.status})`),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||
allValid = false
|
||||
errorCount++
|
||||
updateModel(selectedProviderId, model.id, {
|
||||
@@ -615,7 +654,9 @@ export function ModelConfigDialog({
|
||||
|
||||
{/* Add Provider */}
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
{/* Always empty so picking the same type again still fires */}
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(v) =>
|
||||
handleAddProvider(v as ProviderName)
|
||||
}
|
||||
@@ -837,6 +878,7 @@ export function ModelConfigDialog({
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
handleAddModel(
|
||||
@@ -989,7 +1031,10 @@ export function ModelConfigDialog({
|
||||
</div>
|
||||
<Input
|
||||
value={
|
||||
model.modelId
|
||||
modelIdDraft?.id ===
|
||||
model.id
|
||||
? modelIdDraft.value
|
||||
: model.modelId
|
||||
}
|
||||
title={
|
||||
model.modelId
|
||||
@@ -1007,24 +1052,14 @@ export function ModelConfigDialog({
|
||||
null,
|
||||
)
|
||||
}
|
||||
if (
|
||||
selectedProviderId
|
||||
) {
|
||||
updateModel(
|
||||
selectedProviderId,
|
||||
model.id,
|
||||
{
|
||||
modelId:
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
validated:
|
||||
undefined,
|
||||
validationError:
|
||||
undefined,
|
||||
},
|
||||
)
|
||||
}
|
||||
setModelIdDraft(
|
||||
{
|
||||
id: model.id,
|
||||
value: e
|
||||
.target
|
||||
.value,
|
||||
},
|
||||
)
|
||||
}}
|
||||
onKeyDown={(
|
||||
e,
|
||||
@@ -1041,6 +1076,10 @@ export function ModelConfigDialog({
|
||||
) => {
|
||||
const newModelId =
|
||||
e.target.value.trim()
|
||||
// Drop the draft; an invalid ID falls back to the saved one
|
||||
setModelIdDraft(
|
||||
null,
|
||||
)
|
||||
|
||||
// Helper to show error with shake
|
||||
const showError =
|
||||
@@ -1135,6 +1174,24 @@ export function ModelConfigDialog({
|
||||
setEditError(
|
||||
null,
|
||||
)
|
||||
if (
|
||||
selectedProviderId &&
|
||||
newModelId !==
|
||||
model.modelId
|
||||
) {
|
||||
updateModel(
|
||||
selectedProviderId,
|
||||
model.id,
|
||||
{
|
||||
modelId:
|
||||
newModelId,
|
||||
validated:
|
||||
undefined,
|
||||
validationError:
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -158,7 +158,7 @@ export function ModelSelector({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="inline-block">
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-48">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
@@ -167,7 +167,7 @@ export function ModelSelector({
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"hover:bg-accent gap-1.5 h-8 px-2 transition-[padding,background-color] duration-150 ease-in-out",
|
||||
"h-8 min-w-0 max-w-full shrink overflow-hidden gap-1.5 px-2 transition-[padding,background-color] duration-150 ease-in-out hover:bg-accent",
|
||||
!showLabel && "px-1.5 justify-center",
|
||||
)}
|
||||
// accessibility: expose label to screen readers
|
||||
@@ -176,7 +176,7 @@ export function ModelSelector({
|
||||
<Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
{/* show/hide visible label based on measured width */}
|
||||
{showLabel ? (
|
||||
<span className="text-xs truncate">
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{selectedModel
|
||||
? selectedModel.modelId
|
||||
: dict.modelConfig.default}
|
||||
@@ -264,9 +264,13 @@ export function ModelSelector({
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
// Unique value so same-named models highlight
|
||||
// separately; keywords keep search by name
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
@@ -351,9 +355,11 @@ export function ModelSelector({
|
||||
(model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={
|
||||
model.modelId
|
||||
}
|
||||
value={model.id}
|
||||
keywords={[
|
||||
model.modelId,
|
||||
providerLabel,
|
||||
]}
|
||||
onSelect={() =>
|
||||
handleSelect(
|
||||
model.id,
|
||||
|
||||
@@ -249,6 +249,11 @@ export function ProviderCredentialsFields({
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -75,6 +75,8 @@ interface SettingsDialogProps {
|
||||
onOpenModelConfig?: () => void
|
||||
customSystemMessage?: string
|
||||
onCustomSystemMessageChange?: (value: string) => void
|
||||
maxOutputTokens?: string
|
||||
onMaxOutputTokensChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
@@ -101,6 +103,8 @@ function SettingsContent({
|
||||
onOpenModelConfig,
|
||||
customSystemMessage = "",
|
||||
onCustomSystemMessageChange = () => {},
|
||||
maxOutputTokens = "",
|
||||
onMaxOutputTokensChange = () => {},
|
||||
}: SettingsDialogProps) {
|
||||
const dict = useDictionary()
|
||||
const router = useRouter()
|
||||
@@ -591,6 +595,24 @@ function SettingsContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max Output Tokens */}
|
||||
<SettingItem
|
||||
label={dict.settings.maxOutputTokens}
|
||||
description={dict.settings.maxOutputTokensDescription}
|
||||
>
|
||||
<Input
|
||||
id="max-output-tokens"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={maxOutputTokens}
|
||||
onChange={(e) =>
|
||||
onMaxOutputTokensChange(e.target.value)
|
||||
}
|
||||
placeholder="64000"
|
||||
className="h-9 w-28 text-sm"
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{/* Send Shortcut */}
|
||||
<SettingItem
|
||||
label={dict.settings.sendShortcut}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef } from "react-drawio"
|
||||
import { createContext, useContext, useRef, useState } from "react"
|
||||
import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
@@ -22,7 +22,7 @@ interface DiagramContextType {
|
||||
handleExportWithoutHistory: () => void
|
||||
resolverRef: React.MutableRefObject<((value: string) => void) | null>
|
||||
drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
|
||||
handleDiagramExport: (data: any) => void
|
||||
handleDiagramExport: (data: EventExport) => void
|
||||
handleDiagramAutoSave: (data: { xml?: string }) => void
|
||||
clearDiagram: () => void
|
||||
saveDiagramToFile: (
|
||||
@@ -42,6 +42,12 @@ interface DiagramContextType {
|
||||
|
||||
const DiagramContext = createContext<DiagramContextType | undefined>(undefined)
|
||||
|
||||
// Exports for thumbnails, validation PNGs and file saves carry a tag in the
|
||||
// request's `message` field. draw.io echoes the request back in the export
|
||||
// event, so each result reaches its own caller; untagged exports (chat-panel's
|
||||
// onFetchChart) resolve resolverRef.
|
||||
type ExportTag = "thumbnail" | "validation"
|
||||
|
||||
export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const [chartXML, setChartXML] = useState<string>("")
|
||||
const [latestSvg, setLatestSvg] = useState<string>("")
|
||||
@@ -53,8 +59,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
const hasCalledOnLoadRef = useRef(false)
|
||||
const drawioRef = useRef<DrawIoEmbedRef | null>(null)
|
||||
const resolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Resolver for PNG export (used for VLM validation)
|
||||
const pngResolverRef = useRef<((value: string) => void) | null>(null)
|
||||
// Pending thumbnail and validation PNG exports, keyed by their export tag
|
||||
const taggedResolversRef = useRef<
|
||||
Partial<Record<ExportTag, (value: string) => void>>
|
||||
>({})
|
||||
// Track if we're expecting an export for history (user-initiated)
|
||||
const expectHistoryExportRef = useRef<boolean>(false)
|
||||
// Track latest chartXML for restoration after remount
|
||||
@@ -76,14 +84,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
setIsDrawioReady(false)
|
||||
}
|
||||
|
||||
// Keep chartXMLRef in sync with state for restoration after remount
|
||||
useEffect(() => {
|
||||
chartXMLRef.current = chartXML
|
||||
}, [chartXML])
|
||||
// Update chartXML and its ref together, so callbacks that read the ref
|
||||
// (export handler, autosave) see the new value right away
|
||||
const updateChartXML = (xml: string) => {
|
||||
chartXMLRef.current = xml
|
||||
setChartXML(xml)
|
||||
}
|
||||
|
||||
// Track if we're expecting an export for file save (stores raw export data)
|
||||
const saveResolverRef = useRef<{
|
||||
resolver: ((data: string) => void) | null
|
||||
resolver: ((data: string, fullDiagramXML?: string) => void) | null
|
||||
format: ExportFormat | null
|
||||
}>({ resolver: null, format: null })
|
||||
|
||||
@@ -106,64 +116,52 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Export with a tag in `message` (draw.io echoes it back in the export
|
||||
// event) and wait for that result. Resolves to null on timeout, which is
|
||||
// expected occasionally.
|
||||
const requestTaggedExport = (
|
||||
tag: ExportTag,
|
||||
format: "xmlsvg" | "png",
|
||||
timeoutMs: number,
|
||||
) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
const finish = (value: string | null) => {
|
||||
clearTimeout(timer)
|
||||
if (taggedResolversRef.current[tag] === finish) {
|
||||
delete taggedResolversRef.current[tag]
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
taggedResolversRef.current[tag] = finish
|
||||
drawioRef.current?.exportDiagram({ format, message: tag })
|
||||
})
|
||||
|
||||
// Get current diagram as SVG for thumbnail (used by session storage)
|
||||
const getThumbnailSvg = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(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),
|
||||
),
|
||||
])
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
// 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
|
||||
if (svgData?.includes("<svg")) {
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Timeout is expected occasionally - don't log as error
|
||||
return null
|
||||
setLatestSvg(svgData)
|
||||
return svgData
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Capture current diagram as PNG for VLM validation
|
||||
const captureValidationPng = async (): Promise<string | null> => {
|
||||
if (!drawioRef.current) return null
|
||||
// Don't export if diagram is empty
|
||||
if (!isRealDiagram(chartXML)) return null
|
||||
if (!isRealDiagram(chartXMLRef.current)) return null
|
||||
|
||||
try {
|
||||
const pngData = await Promise.race([
|
||||
new Promise<string>((resolve) => {
|
||||
pngResolverRef.current = resolve
|
||||
drawioRef.current?.exportDiagram({ format: "png" })
|
||||
}),
|
||||
new Promise<string>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("PNG export timeout")),
|
||||
5000,
|
||||
),
|
||||
),
|
||||
])
|
||||
|
||||
// PNG data should be a base64 data URL
|
||||
if (pngData?.startsWith("data:image/png")) {
|
||||
return pngData
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
// Timeout is expected occasionally - don't log as error
|
||||
return null
|
||||
}
|
||||
const pngData = await requestTaggedExport("validation", "png", 5000)
|
||||
// PNG data should be a base64 data URL
|
||||
return pngData?.startsWith("data:image/png") ? pngData : null
|
||||
}
|
||||
|
||||
const loadDiagram = (
|
||||
@@ -193,7 +191,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Keep chartXML in sync even when diagrams are injected (e.g., display_diagram tool)
|
||||
setChartXML(xmlToLoad)
|
||||
updateChartXML(xmlToLoad)
|
||||
|
||||
if (drawioRef.current) {
|
||||
drawioRef.current.load({
|
||||
@@ -204,41 +202,40 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: any) => {
|
||||
// Handle PNG export for VLM validation
|
||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||
pngResolverRef.current(data.data)
|
||||
pngResolverRef.current = null
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Tagged exports (thumbnail, validation PNG, file save) go only to
|
||||
// their own caller, so they never take the result meant for resolverRef
|
||||
const tag = data.message?.message
|
||||
if (tag === "thumbnail" || tag === "validation") {
|
||||
taggedResolversRef.current[tag]?.(data.data)
|
||||
return
|
||||
}
|
||||
if (tag === "save") {
|
||||
saveResolverRef.current.resolver?.(data.data, data.xml)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
return
|
||||
}
|
||||
|
||||
// Handle save to file if requested (process raw data before extraction)
|
||||
if (saveResolverRef.current.resolver) {
|
||||
const format = saveResolverRef.current.format
|
||||
saveResolverRef.current.resolver(data.data)
|
||||
saveResolverRef.current = { resolver: null, format: null }
|
||||
// For non-xmlsvg formats, skip XML extraction as it will fail
|
||||
// Only drawio (which uses xmlsvg internally) has the content attribute
|
||||
// xmlsvg is saved directly as SVG file, no need for extraction
|
||||
if (format === "png" || format === "svg" || format === "xmlsvg") {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
// data.xml from xmlsvg exports has compressed <diagram> payloads that
|
||||
// would break edit_diagram/display_diagram. Autosave keeps chartXML
|
||||
// up to date with the full uncompressed multi-page document (#879).
|
||||
const extractedXML = extractDiagramXML(data.data)
|
||||
setChartXML(extractedXML)
|
||||
setLatestSvg(data.data)
|
||||
|
||||
// Only add to history if this was a user-initiated export
|
||||
// Limit to 20 entries to prevent memory leaks during long sessions
|
||||
const MAX_HISTORY_SIZE = 20
|
||||
if (expectHistoryExportRef.current) {
|
||||
// Store the full multi-page document (extractedXML is only the
|
||||
// first page), so restoring a version keeps every page
|
||||
const historyXml = chartXMLRef.current || extractedXML
|
||||
setDiagramHistory((prev) => {
|
||||
const newHistory = [
|
||||
...prev,
|
||||
{
|
||||
svg: data.data,
|
||||
xml: extractedXML,
|
||||
xml: historyXml,
|
||||
},
|
||||
]
|
||||
// Keep only the last MAX_HISTORY_SIZE entries (circular buffer)
|
||||
@@ -253,14 +250,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// react-drawio registers this callback once per iframe mount, so it must
|
||||
// read refs: state captured in its closure would stay stale after a remount
|
||||
const handleDiagramAutoSave = (data: { xml?: string }) => {
|
||||
if (!data?.xml) return
|
||||
// Don't overwrite a pending restore - if we have a real diagram in state
|
||||
// but DrawIO isn't ready yet, it means we're waiting to restore
|
||||
if (!isDrawioReady && isRealDiagram(chartXML)) {
|
||||
// Don't overwrite a pending restore - if we have a real diagram but
|
||||
// DrawIO hasn't loaded yet, it means we're waiting to restore
|
||||
if (!hasCalledOnLoadRef.current && isRealDiagram(chartXMLRef.current)) {
|
||||
return
|
||||
}
|
||||
setChartXML(data.xml)
|
||||
updateChartXML(data.xml)
|
||||
}
|
||||
|
||||
const clearDiagram = () => {
|
||||
@@ -288,14 +287,16 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Set up the resolver before triggering export
|
||||
saveResolverRef.current = {
|
||||
resolver: (exportData: string) => {
|
||||
resolver: (exportData: string, fullDiagramXML?: string) => {
|
||||
let fileContent: string | Blob
|
||||
let mimeType: string
|
||||
let extension: string
|
||||
|
||||
if (format === "drawio") {
|
||||
// Extract XML from SVG for .drawio format
|
||||
const xml = extractDiagramXML(exportData)
|
||||
// Prefer the complete document from the export event so all pages are saved.
|
||||
const xml = fullDiagramXML?.trim()
|
||||
? fullDiagramXML
|
||||
: extractDiagramXML(exportData)
|
||||
let xmlContent = xml
|
||||
if (!xml.includes("<mxfile")) {
|
||||
xmlContent = `<mxfile><diagram name="Page-1" id="page-1">${xml}</diagram></mxfile>`
|
||||
@@ -360,7 +361,10 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// Export diagram - callback will be handled in handleDiagramExport
|
||||
drawioRef.current.exportDiagram({ format: drawioFormat })
|
||||
drawioRef.current.exportDiagram({
|
||||
format: drawioFormat,
|
||||
message: "save",
|
||||
})
|
||||
}
|
||||
|
||||
// Log save event to Langfuse (just flags the trace, doesn't send content)
|
||||
|
||||
+14
-1
@@ -308,6 +308,19 @@ AI_MODEL=your_model_id
|
||||
QINIU_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### MiMo (小米)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
可选的自定义端点(Token Plan 订阅用户请设置专属 Base URL):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## 自动检测
|
||||
|
||||
如果您只配置了**一个**提供商的 API 密钥,系统将自动检测并使用该提供商。无需设置 `AI_PROVIDER`。
|
||||
@@ -315,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
||||
如果您配置了**多个** API 密钥,则必须显式设置 `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||
AI_PROVIDER=google # 或:openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
```
|
||||
|
||||
## 服务端多模型配置
|
||||
|
||||
+14
-1
@@ -323,6 +323,19 @@ Optional custom endpoint:
|
||||
QINIU_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### MiMo (Xiaomi)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
Optional custom endpoint (Token Plan subscribers should set their dedicated Base URL):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
If you only configure **one** provider's API key, the system will automatically detect and use that provider. No need to set `AI_PROVIDER`.
|
||||
@@ -330,7 +343,7 @@ If you only configure **one** provider's API key, the system will automatically
|
||||
If you configure **multiple** API keys, you must explicitly set `AI_PROVIDER`:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||
AI_PROVIDER=google # or: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
```
|
||||
|
||||
## Server-Side Multi-Model Configuration
|
||||
|
||||
+14
-1
@@ -308,6 +308,19 @@ AI_MODEL=your_model_id
|
||||
QINIU_BASE_URL=https://your-custom-endpoint
|
||||
```
|
||||
|
||||
### MiMo (Xiaomi)
|
||||
|
||||
```bash
|
||||
MIMO_API_KEY=your_api_key
|
||||
AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
オプションのカスタムエンドポイント(Token Plan 加入者は専用の Base URL を設定してください):
|
||||
|
||||
```bash
|
||||
MIMO_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
```
|
||||
|
||||
## 自動検出
|
||||
|
||||
**1つ**のプロバイダーの API キーのみを設定した場合、システムはそのプロバイダーを自動的に検出して使用します。`AI_PROVIDER` を設定する必要はありません。
|
||||
@@ -315,7 +328,7 @@ QINIU_BASE_URL=https://your-custom-endpoint
|
||||
**複数**の API キーを設定する場合は、`AI_PROVIDER` を明示的に設定する必要があります:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu
|
||||
AI_PROVIDER=google # または: openai, anthropic, aihubmix, deepseek, siliconflow, doubao, azure, bedrock, openrouter, ollama, gateway, sglang, modelscope, minimax, glm, qwen, kimi, qiniu, mimo
|
||||
```
|
||||
|
||||
## サーバーサイドマルチモデル設定
|
||||
|
||||
@@ -67,41 +67,62 @@ const MODEL_ALIASES: Record<string, string> = {
|
||||
"deepseek-v3-0324": "@tx/deepseek-ai/deepseek-v3-0324",
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standardized response with CORS headers
|
||||
* Create standardized JSON response
|
||||
*/
|
||||
function createResponse(body: any, status = 200, extraHeaders = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...CORS_HEADERS,
|
||||
...extraHeaders,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OPTIONS request for CORS preflight
|
||||
*/
|
||||
function handleOptionsRequest(): Response {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
})
|
||||
// Only the app's own server (/api/chat, /api/validate-model) calls this
|
||||
// function, so no CORS headers are sent: other sites' pages can't call it
|
||||
// from a browser and spend the deployment's Edge AI quota.
|
||||
// Same rule as lib/access-code.ts, but reading the edge function's env.
|
||||
// No codes configured (or env unavailable) means no check.
|
||||
function hasValidAccessCode(request: Request, env: any): boolean {
|
||||
const accessCodes: string[] =
|
||||
env?.ACCESS_CODE_LIST?.split(",")
|
||||
.map((code: string) => code.trim())
|
||||
.filter(Boolean) || []
|
||||
if (accessCodes.length === 0) return true
|
||||
const accessCode = request.headers.get("x-access-code")
|
||||
return !!accessCode && accessCodes.includes(accessCode)
|
||||
}
|
||||
|
||||
export async function onRequest({ request, env: _env }: any) {
|
||||
if (request.method === "OPTIONS") {
|
||||
return handleOptionsRequest()
|
||||
export async function onRequest({ request, env }: any) {
|
||||
// Requiring JSON also makes any cross-site browser request need a CORS
|
||||
// preflight, which fails without CORS headers
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
!request.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
return createResponse(
|
||||
{
|
||||
error: {
|
||||
message: "Expected a POST request with a JSON body",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasValidAccessCode(request, env)) {
|
||||
return createResponse(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid or missing access code",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
request.headers.delete("accept-encoding")
|
||||
@@ -153,7 +174,7 @@ export async function onRequest({ request, env: _env }: any) {
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
},
|
||||
429,
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -216,7 +237,6 @@ export async function onRequest({ request, env: _env }: any) {
|
||||
"Cache-Control": "no-cache, no-store, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
Connection: "keep-alive",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
|
||||
+57
-26
@@ -32,6 +32,55 @@ export function rebuildAppMenu(): void {
|
||||
buildAppMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a preset and restart the server so it takes effect.
|
||||
* If the restart fails, go back to the previous preset and restart again,
|
||||
* so the running server always matches the saved current preset.
|
||||
* Throws an error describing the outcome on failure.
|
||||
*/
|
||||
export async function switchPreset(
|
||||
id: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(id)
|
||||
if (!env) {
|
||||
throw new Error("Preset not found")
|
||||
}
|
||||
rebuildAppMenu()
|
||||
|
||||
// In development, scripts/electron-dev.mjs restarts the Next.js dev server
|
||||
if (!app.isPackaged) {
|
||||
return env
|
||||
}
|
||||
|
||||
try {
|
||||
await restartNextServer()
|
||||
return env
|
||||
} catch (error) {
|
||||
console.error("Failed to restart server:", error)
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (!previousPresetId || !applyPresetToEnv(previousPresetId)) {
|
||||
setCurrentPreset(null)
|
||||
}
|
||||
// Rebuild menu to restore previous checkmark state
|
||||
rebuildAppMenu()
|
||||
|
||||
try {
|
||||
await restartNextServer()
|
||||
} catch (retryError) {
|
||||
console.error("Failed to restart server again:", retryError)
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nPlease restart the app.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${reason}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the menu template with translations
|
||||
*/
|
||||
@@ -192,32 +241,14 @@ function buildConfigMenu(
|
||||
type: "radio",
|
||||
checked: preset.id === currentPresetId,
|
||||
click: async () => {
|
||||
const previousPresetId = getCurrentPresetId()
|
||||
const env = applyPresetToEnv(preset.id)
|
||||
|
||||
if (env) {
|
||||
try {
|
||||
await restartNextServer()
|
||||
rebuildAppMenu() // Rebuild menu to update checkmarks
|
||||
} catch (error) {
|
||||
console.error("Failed to restart server:", error)
|
||||
|
||||
// Revert to previous preset on failure
|
||||
if (previousPresetId) {
|
||||
applyPresetToEnv(previousPresetId)
|
||||
} else {
|
||||
setCurrentPreset(null)
|
||||
}
|
||||
|
||||
// Rebuild menu to restore previous checkmark state
|
||||
rebuildAppMenu()
|
||||
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". The server could not be restarted.\n\nThe previous configuration has been restored.\n\nError: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
await switchPreset(preset.id)
|
||||
} catch (error) {
|
||||
// Show error dialog to notify user
|
||||
dialog.showErrorBox(
|
||||
"Configuration Error",
|
||||
`Failed to apply preset "${preset.name}". ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
+123
-69
@@ -1,5 +1,11 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs"
|
||||
import path from "node:path"
|
||||
import { app, safeStorage } from "electron"
|
||||
|
||||
@@ -30,7 +36,9 @@ let hasWarnedAboutPlaintext = false
|
||||
* Warns if encryption is not available (API key stored in plaintext)
|
||||
*/
|
||||
function encryptValue(value: string): string {
|
||||
if (!value) {
|
||||
// Already encrypted (a value that could not be decrypted): keep it as is
|
||||
// instead of wrapping it in a second layer of encryption
|
||||
if (!value || value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -61,6 +69,7 @@ function encryptValue(value: string): string {
|
||||
/**
|
||||
* Decrypt a sensitive value using safeStorage
|
||||
* Returns the original value if it's not encrypted or decryption fails
|
||||
* (so saving writes the stored ciphertext back unchanged)
|
||||
*/
|
||||
function decryptValue(value: string): string {
|
||||
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
@@ -179,6 +188,15 @@ export function loadPresets(): ConfigPresetsFile {
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to load config presets:", error)
|
||||
// Move the unreadable file aside so the next save can't overwrite
|
||||
// the user's presets with an empty list
|
||||
const backupPath = `${configPath}.corrupt-${Date.now()}`
|
||||
try {
|
||||
renameSync(configPath, backupPath)
|
||||
console.error(`Unreadable config presets moved to ${backupPath}`)
|
||||
} catch (renameError) {
|
||||
console.error("Failed to back up config presets:", renameError)
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
currentPresetId: null,
|
||||
@@ -211,7 +229,11 @@ export function savePresets(data: ConfigPresetsFile): void {
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated config file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
writeFileSync(tempPath, JSON.stringify(dataToSave, null, 2), "utf-8")
|
||||
renameSync(tempPath, configPath)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config presets:", error)
|
||||
throw error
|
||||
@@ -307,9 +329,10 @@ export function deletePreset(id: string): boolean {
|
||||
|
||||
data.presets.splice(index, 1)
|
||||
|
||||
// Clear current preset if it was deleted
|
||||
// Clear current preset (and its env vars) if it was deleted
|
||||
if (data.currentPresetId === id) {
|
||||
data.currentPresetId = null
|
||||
setPresetEnv(null)
|
||||
}
|
||||
|
||||
savePresets(data)
|
||||
@@ -322,13 +345,15 @@ export function deletePreset(id: string): boolean {
|
||||
export function setCurrentPreset(id: string | null): boolean {
|
||||
const data = loadPresets()
|
||||
|
||||
let preset: ConfigPreset | null = null
|
||||
if (id !== null) {
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
preset = data.presets.find((p) => p.id === id) || null
|
||||
if (!preset) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setPresetEnv(preset)
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
return true
|
||||
@@ -365,78 +390,23 @@ const PROVIDER_ENV_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Map a preset's config to environment variables
|
||||
* Maps generic AI_API_KEY/AI_BASE_URL to provider-specific keys
|
||||
*/
|
||||
export function getCurrentPresetEnv(): Record<string, string> {
|
||||
const preset = getCurrentPreset()
|
||||
if (!preset) {
|
||||
return {}
|
||||
}
|
||||
|
||||
function presetToEnv(preset: ConfigPreset): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
const provider = preset.config.AI_PROVIDER?.toLowerCase()
|
||||
|
||||
for (const [key, value] of Object.entries(preset.config)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
// A key that could not be decrypted is useless to the server
|
||||
if (value.startsWith(ENCRYPTED_PREFIX)) {
|
||||
console.warn(
|
||||
`Preset "${preset.name}": ${key} could not be decrypted. Please enter it again in Settings.`,
|
||||
)
|
||||
}
|
||||
// Map generic AI_API_KEY to provider-specific key
|
||||
if (
|
||||
else if (
|
||||
key === "AI_API_KEY" &&
|
||||
provider &&
|
||||
PROVIDER_ENV_MAP[provider]
|
||||
@@ -466,6 +436,90 @@ export function getCurrentPresetEnv(): Record<string, string> {
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Values that env vars had before a preset first set them
|
||||
* (from the system or .env files), and the keys the active preset set
|
||||
*/
|
||||
const originalEnv: Record<string, string | undefined> = {}
|
||||
let presetEnvKeys: string[] = []
|
||||
|
||||
/**
|
||||
* Replace the env vars of the previous preset with those of the given preset
|
||||
* (null leaves no preset applied). Restoring first means switching presets
|
||||
* never leaves the previous preset's base URL, model or key behind.
|
||||
*/
|
||||
function setPresetEnv(preset: ConfigPreset | null): Record<string, string> {
|
||||
for (const key of presetEnvKeys) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
}
|
||||
}
|
||||
|
||||
const env = preset ? presetToEnv(preset) : {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
originalEnv[key] = process.env[key]
|
||||
}
|
||||
process.env[key] = value
|
||||
}
|
||||
presetEnvKeys = Object.keys(env)
|
||||
|
||||
writeDevPresetEnv(env)
|
||||
return env
|
||||
}
|
||||
|
||||
const DEV_ENV_FILE_NAME = "dev-preset-env.json"
|
||||
|
||||
/**
|
||||
* Development only: write the active preset's env vars (decrypted and mapped)
|
||||
* for scripts/electron-dev.mjs, which restarts the Next.js dev server when
|
||||
* this file changes. The dev server can't decrypt the config file itself.
|
||||
*/
|
||||
function writeDevPresetEnv(env: Record<string, string>): void {
|
||||
if (app.isPackaged) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const filePath = path.join(app.getPath("userData"), DEV_ENV_FILE_NAME)
|
||||
writeFileSync(filePath, JSON.stringify(env, null, 2), {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to write dev preset env:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preset environment variables to the current process
|
||||
* Returns the environment variables that were applied
|
||||
*/
|
||||
export function applyPresetToEnv(id: string): Record<string, string> | null {
|
||||
const data = loadPresets()
|
||||
const preset = data.presets.find((p) => p.id === id)
|
||||
|
||||
if (!preset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const appliedEnv = setPresetEnv(preset)
|
||||
|
||||
// Set as current preset
|
||||
data.currentPresetId = id
|
||||
savePresets(data)
|
||||
|
||||
return appliedEnv
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the saved current preset's environment variables (used at startup)
|
||||
*/
|
||||
export function applyCurrentPresetToEnv(): void {
|
||||
setPresetEnv(getCurrentPreset())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred locale from config
|
||||
* Returns undefined if not set
|
||||
|
||||
@@ -48,12 +48,16 @@ function loadEnvFromFile(filePath: string): void {
|
||||
const key = trimmed.slice(0, equalIndex).trim()
|
||||
let value = trimmed.slice(equalIndex + 1).trim()
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
const quote = value[0]
|
||||
const closingQuote =
|
||||
quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1
|
||||
if (closingQuote > 0) {
|
||||
// Quoted value: keep what's inside the quotes and drop
|
||||
// anything after them (e.g. a comment)
|
||||
value = value.slice(1, closingQuote)
|
||||
} else {
|
||||
// Unquoted value: drop an inline comment ("value # comment")
|
||||
value = value.replace(/\s+#.*$/, "")
|
||||
}
|
||||
|
||||
// Don't override existing environment variables
|
||||
|
||||
+48
-20
@@ -1,12 +1,17 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { getCurrentPresetEnv } from "./config-manager"
|
||||
import { applyCurrentPresetToEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
import { startNextServer, stopNextServer } from "./next-server"
|
||||
import { applyProxyToEnv } from "./proxy-manager"
|
||||
import { registerSettingsWindowHandlers } from "./settings-window"
|
||||
import { createWindow, getMainWindow } from "./window-manager"
|
||||
import {
|
||||
createWindow,
|
||||
getAppUrl,
|
||||
getMainWindow,
|
||||
isAppUrl,
|
||||
} from "./window-manager"
|
||||
|
||||
// Single instance lock
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
@@ -28,16 +33,14 @@ if (!gotTheLock) {
|
||||
// Apply proxy settings from saved config
|
||||
applyProxyToEnv()
|
||||
|
||||
// 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
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Apply saved preset environment variables (overrides .env).
|
||||
// Must run after ready: on Windows and Linux safeStorage can't
|
||||
// decrypt the API key before that.
|
||||
applyCurrentPresetToEnv()
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers()
|
||||
registerSettingsWindowHandlers()
|
||||
@@ -46,6 +49,7 @@ if (!gotTheLock) {
|
||||
buildAppMenu()
|
||||
|
||||
try {
|
||||
let serverUrl: string
|
||||
if (isDev) {
|
||||
// Development: use the dev server URL
|
||||
serverUrl =
|
||||
@@ -69,8 +73,9 @@ if (!gotTheLock) {
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
if (serverUrl) {
|
||||
createWindow(serverUrl)
|
||||
const appUrl = getAppUrl()
|
||||
if (appUrl) {
|
||||
createWindow(appUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -87,24 +92,47 @@ if (!gotTheLock) {
|
||||
stopNextServer()
|
||||
})
|
||||
|
||||
// Pages allowed inside app windows: the app server and draw.io
|
||||
const isInAppUrl = (url: string): boolean => {
|
||||
if (isAppUrl(url)) return true
|
||||
try {
|
||||
const { hostname } = new URL(url)
|
||||
return ["diagrams.net", "draw.io"].some(
|
||||
(domain) =>
|
||||
hostname === domain || hostname.endsWith(`.${domain}`),
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isWebUrl = (url: string): boolean =>
|
||||
url.startsWith("http://") || url.startsWith("https://")
|
||||
|
||||
// Open external links in default browser
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
// Allow diagrams.net iframe
|
||||
if (
|
||||
url.includes("diagrams.net") ||
|
||||
url.includes("draw.io") ||
|
||||
url.startsWith("http://localhost") ||
|
||||
url.startsWith("http://127.0.0.1")
|
||||
) {
|
||||
if (isInAppUrl(url)) {
|
||||
return { action: "allow" }
|
||||
}
|
||||
// Open other links in external browser
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
if (isWebUrl(url)) {
|
||||
shell.openExternal(url)
|
||||
return { action: "deny" }
|
||||
}
|
||||
return { action: "allow" }
|
||||
})
|
||||
|
||||
// Clicking a plain link would otherwise replace the app page with
|
||||
// an external site that keeps the preload API
|
||||
contents.on("will-navigate", (event) => {
|
||||
if (isInAppUrl(event.url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (isWebUrl(event.url)) {
|
||||
shell.openExternal(event.url)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain } from "electron"
|
||||
import { rebuildAppMenu } from "./app-menu"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
type IpcMainInvokeEvent,
|
||||
ipcMain,
|
||||
} from "electron"
|
||||
import { rebuildAppMenu, switchPreset } from "./app-menu"
|
||||
import {
|
||||
type ConfigPreset,
|
||||
createPreset,
|
||||
deletePreset,
|
||||
@@ -20,6 +25,7 @@ import {
|
||||
type ProxyConfig,
|
||||
saveProxyConfig,
|
||||
} from "./proxy-manager"
|
||||
import { isAppUrl } from "./window-manager"
|
||||
|
||||
/**
|
||||
* Allowed configuration keys for presets
|
||||
@@ -48,13 +54,32 @@ function sanitizePresetConfig(
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an IPC handler that only answers the app's own pages
|
||||
* (the main window on the app server, or the local settings page).
|
||||
* A main window that somehow ends up on an external site still gets the
|
||||
* preload API, so its calls must be rejected here.
|
||||
*/
|
||||
function handle<Args extends unknown[]>(
|
||||
channel: string,
|
||||
listener: (event: IpcMainInvokeEvent, ...args: Args) => unknown,
|
||||
): void {
|
||||
ipcMain.handle(channel, (event, ...args) => {
|
||||
const url = event.senderFrame?.url
|
||||
if (!isAppUrl(url) && !url?.startsWith("file://")) {
|
||||
throw new Error(`Blocked "${channel}" from untrusted page: ${url}`)
|
||||
}
|
||||
return listener(event, ...(args as Args))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(): void {
|
||||
// ==================== App Info ====================
|
||||
|
||||
ipcMain.handle("get-version", () => {
|
||||
handle("get-version", () => {
|
||||
return app.getVersion()
|
||||
})
|
||||
|
||||
@@ -81,7 +106,7 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ==================== File Dialogs ====================
|
||||
|
||||
ipcMain.handle("dialog-open-file", async (event) => {
|
||||
handle("dialog-open-file", async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return null
|
||||
|
||||
@@ -108,9 +133,9 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("dialog-save-file", async (event, data: string) => {
|
||||
handle("dialog-save-file", async (event, data: string) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return false
|
||||
if (!win || typeof data !== "string") return false
|
||||
|
||||
const result = await dialog.showSaveDialog(win, {
|
||||
filters: [
|
||||
@@ -135,28 +160,28 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ==================== Config Presets ====================
|
||||
|
||||
ipcMain.handle("config-presets:get-all", () => {
|
||||
handle("config-presets:get-all", () => {
|
||||
return getAllPresets()
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:get-current", () => {
|
||||
handle("config-presets:get-current", () => {
|
||||
return getCurrentPreset()
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets:get-current-id", () => {
|
||||
handle("config-presets:get-current-id", () => {
|
||||
return getCurrentPresetId()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
handle(
|
||||
"config-presets:save",
|
||||
(
|
||||
async (
|
||||
_event,
|
||||
preset: Omit<ConfigPreset, "id" | "createdAt" | "updatedAt"> & {
|
||||
id?: string
|
||||
},
|
||||
) => {
|
||||
// Validate preset name
|
||||
if (typeof preset.name !== "string" || !preset.name.trim()) {
|
||||
if (typeof preset?.name !== "string" || !preset.name.trim()) {
|
||||
throw new Error("Invalid preset name")
|
||||
}
|
||||
|
||||
@@ -165,42 +190,48 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
if (preset.id) {
|
||||
// Update existing preset
|
||||
return updatePreset(preset.id, {
|
||||
const updated = updatePreset(preset.id, {
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
// Re-apply the active preset so the edit takes effect
|
||||
if (updated && updated.id === getCurrentPresetId()) {
|
||||
await switchPreset(updated.id)
|
||||
} else {
|
||||
rebuildAppMenu()
|
||||
}
|
||||
return updated
|
||||
}
|
||||
// Create new preset
|
||||
return createPreset({
|
||||
const created = createPreset({
|
||||
name: preset.name.trim(),
|
||||
config: sanitizedConfig,
|
||||
})
|
||||
rebuildAppMenu()
|
||||
return created
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("config-presets:delete", (_event, id: string) => {
|
||||
return deletePreset(id)
|
||||
handle("config-presets:delete", async (_event, id: string) => {
|
||||
const wasCurrent = id === getCurrentPresetId()
|
||||
// Deleting the active preset also clears its env vars
|
||||
const deleted = deletePreset(id)
|
||||
rebuildAppMenu()
|
||||
|
||||
// Restart so the server stops using the deleted preset
|
||||
if (deleted && wasCurrent && app.isPackaged) {
|
||||
await restartNextServer()
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
|
||||
ipcMain.handle("config-presets: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
|
||||
handle("config-presets:apply", async (_event, id: string) => {
|
||||
try {
|
||||
await restartNextServer()
|
||||
return { success: true, env }
|
||||
const env = await switchPreset(id)
|
||||
// In development mode, electron-dev.mjs restarts Next.js
|
||||
return app.isPackaged
|
||||
? { success: true, env }
|
||||
: { success: true, env, devMode: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -212,30 +243,39 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"config-presets:set-current",
|
||||
(_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
},
|
||||
)
|
||||
handle("config-presets:set-current", (_event, id: string | null) => {
|
||||
return setCurrentPreset(id)
|
||||
})
|
||||
|
||||
// ==================== Proxy Settings ====================
|
||||
|
||||
ipcMain.handle("get-proxy", () => {
|
||||
handle("get-proxy", () => {
|
||||
return getProxyConfig()
|
||||
})
|
||||
|
||||
ipcMain.handle("set-proxy", async (_event, config: ProxyConfig) => {
|
||||
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 {
|
||||
// Save config to file
|
||||
saveProxyConfig(config)
|
||||
saveProxyConfig({
|
||||
httpProxy: config.httpProxy,
|
||||
httpsProxy: config.httpsProxy,
|
||||
})
|
||||
|
||||
// Apply to current process environment
|
||||
applyProxyToEnv()
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development"
|
||||
|
||||
if (isDev) {
|
||||
if (!app.isPackaged) {
|
||||
// In development, env vars are already applied
|
||||
// Next.js dev server may need manual restart
|
||||
return { success: true, devMode: true }
|
||||
@@ -257,11 +297,11 @@ export function registerIpcHandlers(): void {
|
||||
|
||||
// ==================== User Locale ====================
|
||||
|
||||
ipcMain.handle("get-user-locale", () => {
|
||||
handle("get-user-locale", () => {
|
||||
return getUserLocale()
|
||||
})
|
||||
|
||||
ipcMain.handle("set-user-locale", (_event, locale: string) => {
|
||||
handle("set-user-locale", (_event, locale: string) => {
|
||||
// Validate locale is one of the supported values
|
||||
if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
|
||||
return { success: false, error: "Invalid locale" }
|
||||
|
||||
@@ -6,10 +6,22 @@ import {
|
||||
getAllocatedPort,
|
||||
getServerUrl,
|
||||
isPortAvailable,
|
||||
saveServerPort,
|
||||
} from "./port-manager"
|
||||
import { setAppUrl } from "./window-manager"
|
||||
|
||||
let serverProcess: UtilityProcess | null = null
|
||||
|
||||
// Start and restart run one at a time, so overlapping calls (e.g. two quick
|
||||
// preset switches) can't leave two servers running
|
||||
let serverQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
function runExclusive<T>(task: () => Promise<T>): Promise<T> {
|
||||
const result = serverQueue.then(task)
|
||||
serverQueue = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the standalone server resources
|
||||
* In packaged app: resources/standalone
|
||||
@@ -45,7 +57,11 @@ async function waitForServer(url: string, timeout = 30000): Promise<void> {
|
||||
* Start the Next.js standalone server using Electron's utilityProcess
|
||||
* This API is designed for running Node.js code in the background
|
||||
*/
|
||||
export async function startNextServer(): Promise<string> {
|
||||
export function startNextServer(): Promise<string> {
|
||||
return runExclusive(startServer)
|
||||
}
|
||||
|
||||
async function startServer(): Promise<string> {
|
||||
const resourcePath = getResourcePath()
|
||||
const serverPath = path.join(resourcePath, "server.js")
|
||||
|
||||
@@ -73,6 +89,11 @@ export async function startNextServer(): Promise<string> {
|
||||
NODE_USE_ENV_PROXY: "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)
|
||||
// This is necessary because the packaged app might be on a read-only volume
|
||||
if (app.isPackaged) {
|
||||
@@ -96,28 +117,33 @@ export async function startNextServer(): Promise<string> {
|
||||
|
||||
// Use Electron's utilityProcess API for running Node.js in background
|
||||
// This is the recommended way to run Node.js code in Electron
|
||||
serverProcess = utilityProcess.fork(serverPath, [], {
|
||||
const proc = utilityProcess.fork(serverPath, [], {
|
||||
cwd: resourcePath,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
})
|
||||
serverProcess = proc
|
||||
|
||||
serverProcess.stdout?.on("data", (data) => {
|
||||
proc.stdout?.on("data", (data) => {
|
||||
console.log(`[Next.js] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
serverProcess.stderr?.on("data", (data) => {
|
||||
proc.stderr?.on("data", (data) => {
|
||||
console.error(`[Next.js Error] ${data.toString().trim()}`)
|
||||
})
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
proc.on("exit", (code) => {
|
||||
console.log(`Next.js server exited with code ${code}`)
|
||||
serverProcess = null
|
||||
// An old server can exit after a new one started; keep the new one
|
||||
if (serverProcess === proc) {
|
||||
serverProcess = null
|
||||
}
|
||||
})
|
||||
|
||||
const url = getServerUrl()
|
||||
await waitForServer(url)
|
||||
console.log(`Next.js server started at ${url}`)
|
||||
saveServerPort(port)
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -126,39 +152,36 @@ export async function startNextServer(): Promise<string> {
|
||||
* Stop the Next.js server process and wait for it to exit
|
||||
*/
|
||||
export async function stopNextServer(): Promise<void> {
|
||||
if (serverProcess) {
|
||||
console.log("Stopping Next.js server...")
|
||||
const proc = serverProcess
|
||||
if (!proc) {
|
||||
return
|
||||
}
|
||||
console.log("Stopping Next.js server...")
|
||||
serverProcess = null
|
||||
|
||||
// Create a promise that resolves when the process exits
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
const proc = serverProcess
|
||||
if (!proc) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
resolve()
|
||||
}
|
||||
|
||||
proc.once("exit", onExit)
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
proc.removeListener("exit", onExit)
|
||||
resolve()
|
||||
}, 5000)
|
||||
// Resolves true when the process exits, false after the timeout
|
||||
const waitForExit = (ms: number) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
proc.once("exit", () => resolve(true))
|
||||
setTimeout(() => resolve(false), ms)
|
||||
})
|
||||
|
||||
serverProcess.kill()
|
||||
serverProcess = null
|
||||
proc.kill()
|
||||
|
||||
// Wait for process to exit
|
||||
await exitPromise
|
||||
|
||||
// Additional wait for OS to release port
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
// Next.js waits for open requests (e.g. a streaming reply) before it
|
||||
// exits, so force kill it if it is still running after 5 seconds
|
||||
if (!(await waitForExit(5000)) && proc.pid) {
|
||||
console.warn("Next.js server did not exit in time, force killing it")
|
||||
try {
|
||||
process.kill(proc.pid, "SIGKILL")
|
||||
} catch (error) {
|
||||
console.error("Failed to force kill Next.js server:", error)
|
||||
}
|
||||
await waitForExit(2000)
|
||||
}
|
||||
|
||||
// Additional wait for OS to release port
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,15 +207,19 @@ async function waitForServerStop(timeout = 5000): Promise<void> {
|
||||
/**
|
||||
* Restart the Next.js server with new environment variables
|
||||
*/
|
||||
export async function restartNextServer(): Promise<string> {
|
||||
console.log("Restarting Next.js server...")
|
||||
export function restartNextServer(): Promise<string> {
|
||||
return runExclusive(async () => {
|
||||
console.log("Restarting Next.js server...")
|
||||
|
||||
// Stop the current server and wait for it to exit
|
||||
await stopNextServer()
|
||||
// Stop the current server and wait for it to exit
|
||||
await stopNextServer()
|
||||
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
// Wait for the port to be released
|
||||
await waitForServerStop()
|
||||
|
||||
// Start the server again
|
||||
return startNextServer()
|
||||
// Start the server again, and follow it if it moved to another port
|
||||
const url = await startServer()
|
||||
setAppUrl(url)
|
||||
return url
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,38 @@ const PORT_CONFIG = {
|
||||
*/
|
||||
let allocatedPort: number | null = null
|
||||
|
||||
/**
|
||||
* File that remembers the production port from the last launch, so the app
|
||||
* keeps the same origin (and its localStorage) instead of switching between
|
||||
* the legacy and new port depending on which one is free at startup
|
||||
*/
|
||||
function getSavedPortPath(): string {
|
||||
return path.join(app.getPath("userData"), "server-port.json")
|
||||
}
|
||||
|
||||
function loadSavedPort(): number | null {
|
||||
try {
|
||||
const { port } = JSON.parse(readFileSync(getSavedPortPath(), "utf-8"))
|
||||
return Number.isInteger(port) ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember the port the production server started on
|
||||
*/
|
||||
export function saveServerPort(port: number): void {
|
||||
if (!app.isPackaged || port === loadSavedPort()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
writeFileSync(getSavedPortPath(), JSON.stringify({ port }), "utf-8")
|
||||
} catch (error) {
|
||||
console.error("Failed to save server port:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific port is available
|
||||
*/
|
||||
@@ -44,7 +78,8 @@ export function isPortAvailable(port: number): Promise<boolean> {
|
||||
/**
|
||||
* Find an available port
|
||||
* - In development: uses fixed port (6002)
|
||||
* - In production: uses fixed port (13370) to preserve localStorage
|
||||
* - In production: uses the port from the last launch, then the legacy
|
||||
* port (61337), then 13370, to preserve localStorage
|
||||
* - Falls back to sequential ports if preferred port is unavailable
|
||||
* - Last resort: lets the OS assign a port (port 0)
|
||||
*
|
||||
@@ -69,6 +104,20 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
|
||||
allocatedPort = null
|
||||
}
|
||||
|
||||
// In production, use the port from the last launch first
|
||||
if (!isDev) {
|
||||
const savedPort = loadSavedPort()
|
||||
if (savedPort !== null) {
|
||||
if (await isPortAvailable(savedPort)) {
|
||||
allocatedPort = savedPort
|
||||
return savedPort
|
||||
}
|
||||
console.warn(
|
||||
`Port ${savedPort} from the last launch is unavailable. Data saved under it will not show on the new port.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// In production, try legacy port first to preserve existing users' localStorage
|
||||
if (!isDev) {
|
||||
const legacyPort = PORT_CONFIG.legacyProduction
|
||||
|
||||
@@ -13,18 +13,22 @@ function getConfigPath(): string {
|
||||
|
||||
/**
|
||||
* Load proxy configuration from JSON file
|
||||
* Returns null if the user never saved proxy settings (or the file is invalid)
|
||||
*/
|
||||
export function loadProxyConfig(): ProxyConfig {
|
||||
export function loadProxyConfig(): ProxyConfig | null {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
if (fs.existsSync(configPath)) {
|
||||
const data = fs.readFileSync(configPath, "utf-8")
|
||||
return JSON.parse(data) as ProxyConfig
|
||||
const data = JSON.parse(fs.readFileSync(configPath, "utf-8"))
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
return data as ProxyConfig
|
||||
}
|
||||
console.error("Ignoring invalid proxy config:", data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load proxy config:", error)
|
||||
}
|
||||
return {}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +37,11 @@ export function loadProxyConfig(): ProxyConfig {
|
||||
export function saveProxyConfig(config: ProxyConfig): void {
|
||||
try {
|
||||
const configPath = getConfigPath()
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
||||
// Write a temp file and rename it, so a crash mid-write can't leave
|
||||
// a truncated file
|
||||
const tempPath = `${configPath}.tmp`
|
||||
fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), "utf-8")
|
||||
fs.renameSync(tempPath, configPath)
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy config:", error)
|
||||
throw error
|
||||
@@ -47,6 +55,11 @@ export function saveProxyConfig(config: ProxyConfig): void {
|
||||
export function applyProxyToEnv(): void {
|
||||
const config = loadProxyConfig()
|
||||
|
||||
// No saved settings: keep proxy vars inherited from the system or .env
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.httpProxy) {
|
||||
process.env.HTTP_PROXY = config.httpProxy
|
||||
process.env.http_proxy = config.httpProxy
|
||||
|
||||
@@ -3,6 +3,9 @@ import { app, BrowserWindow, screen } from "electron"
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
// URL of the app server the main window loads
|
||||
let appUrl: string | null = null
|
||||
|
||||
/**
|
||||
* Get the icon path based on platform
|
||||
* Note: electron-builder converts icon.png during packaging,
|
||||
@@ -28,6 +31,7 @@ function getIconPath(): string | undefined {
|
||||
* Create the main application window
|
||||
*/
|
||||
export function createWindow(serverUrl: string): BrowserWindow {
|
||||
appUrl = serverUrl
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -56,7 +60,7 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
})
|
||||
|
||||
// Open DevTools in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
@@ -93,3 +97,36 @@ export function createWindow(serverUrl: string): BrowserWindow {
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the app server URL the main window loads
|
||||
*/
|
||||
export function getAppUrl(): string | null {
|
||||
return appUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the main window at a new app server URL
|
||||
* (the restarted server can come up on a different port)
|
||||
*/
|
||||
export function setAppUrl(url: string): void {
|
||||
if (url === appUrl) {
|
||||
return
|
||||
}
|
||||
appUrl = url
|
||||
mainWindow?.loadURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL belongs to the app server (same origin)
|
||||
*/
|
||||
export function isAppUrl(url: string | undefined): boolean {
|
||||
if (!url || !appUrl) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return new URL(url).origin === new URL(appUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,9 @@ async function savePreset() {
|
||||
}
|
||||
})
|
||||
|
||||
// closeModal() clears editingPresetId, so remember it for the toast
|
||||
const isEdit = Boolean(editingPresetId)
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true
|
||||
saveBtn.innerHTML = '<span class="loading"></span>'
|
||||
@@ -220,10 +223,7 @@ async function savePreset() {
|
||||
await window.settingsAPI.savePreset(preset)
|
||||
await loadPresets()
|
||||
closeModal()
|
||||
showToast(
|
||||
editingPresetId ? "Preset updated" : "Preset created",
|
||||
"success",
|
||||
)
|
||||
showToast(isEdit ? "Preset updated" : "Preset created", "success")
|
||||
} catch (error) {
|
||||
console.error("Failed to save preset:", error)
|
||||
showToast("Failed to save preset", "error")
|
||||
@@ -265,8 +265,6 @@ async function applyPreset(id) {
|
||||
|
||||
const result = await window.settingsAPI.applyPreset(id)
|
||||
if (result.success) {
|
||||
currentPresetId = id
|
||||
renderPresets()
|
||||
showToast("Preset applied, server restarting...", "success")
|
||||
} else {
|
||||
showToast(result.error || "Failed to apply preset", "error")
|
||||
@@ -274,6 +272,9 @@ async function applyPreset(id) {
|
||||
} catch (error) {
|
||||
console.error("Failed to apply preset:", error)
|
||||
showToast("Failed to apply preset", "error")
|
||||
} finally {
|
||||
// Reload to show the active preset and reset the Apply button
|
||||
await loadPresets()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
@@ -11,6 +11,12 @@ AI_PROVIDER=bedrock
|
||||
# Example: AI_MODEL=doubao-seed-1-8-251215,doubao-seed-1-6-flash,doubao-seed-1-6-pro
|
||||
AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
|
||||
# Output limit, all providers (default: 64000). Shared by reasoning and the diagram XML,
|
||||
# so a thinking model can spend it all before the tool call. Users can lower it in Settings,
|
||||
# and raise it only when they use their own API key, so this also caps cost on server keys.
|
||||
# If a model's own ceiling is lower, the request is retried with that ceiling automatically.
|
||||
# MAX_OUTPUT_TOKENS=64000
|
||||
|
||||
# AWS Bedrock Configuration
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=your-access-key-id
|
||||
@@ -189,3 +195,13 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
||||
# Get your API key from: https://novita.ai/dashboard/key
|
||||
# NOVITA_API_KEY=your_novita_api_key
|
||||
# NOVITA_BASE_URL=https://api.novita.ai/openai # Optional, default
|
||||
|
||||
# MiMo (Xiaomi) Configuration (Optional)
|
||||
# Get your API key from: https://platform.xiaomimimo.com/
|
||||
# MIMO_API_KEY=your_mimo_api_key
|
||||
# MIMO_BASE_URL=https://api.xiaomimimo.com/v1 # Optional, default. Token Plan users: https://token-plan-cn.xiaomimimo.com/v1
|
||||
|
||||
# Atlas Cloud Configuration (Optional)
|
||||
# Get your API key from: https://www.atlascloud.ai/console/api-keys
|
||||
# ATLASCLOUD_API_KEY=your_atlascloud_api_key
|
||||
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1 # Optional, default. LLM chat endpoint; media generation uses a separate API.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import { useRef } from "react"
|
||||
import type { DiagramOperation } from "@/components/chat/types"
|
||||
import type {
|
||||
ValidationState,
|
||||
@@ -48,6 +47,8 @@ type ValidateDiagramFn = (
|
||||
interface UseDiagramToolHandlersParams {
|
||||
partialXmlRef: MutableRefObject<string>
|
||||
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
|
||||
// Failed VLM validations in the current user turn (reset on each user message)
|
||||
validationRetryCountRef: MutableRefObject<number>
|
||||
chartXMLRef: MutableRefObject<string>
|
||||
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
|
||||
onFetchChart: (saveToHistory?: boolean) => Promise<string>
|
||||
@@ -72,6 +73,7 @@ interface UseDiagramToolHandlersParams {
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
@@ -82,9 +84,6 @@ export function useDiagramToolHandlers({
|
||||
sessionId,
|
||||
onValidationStateChange,
|
||||
}: UseDiagramToolHandlersParams) {
|
||||
// Track validation retry count per tool call
|
||||
const validationRetryCountRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
// Helper to update validation state
|
||||
const updateValidationState = (
|
||||
toolCallId: string,
|
||||
@@ -232,17 +231,15 @@ ${finalXml}
|
||||
)
|
||||
}
|
||||
|
||||
const retryCount =
|
||||
validationRetryCountRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
) || 0
|
||||
// Each retry is a new tool call, so count attempts per user turn
|
||||
const attempt = validationRetryCountRef.current + 1
|
||||
|
||||
// Notify UI that we're validating (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"validating",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
attempt,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
@@ -254,17 +251,14 @@ ${finalXml}
|
||||
)
|
||||
|
||||
if (!result.valid) {
|
||||
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current.set(
|
||||
toolCall.toolCallId,
|
||||
retryCount + 1,
|
||||
)
|
||||
if (attempt < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current = attempt
|
||||
|
||||
const feedback =
|
||||
formatValidationFeedback(result)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
|
||||
result.issues,
|
||||
)
|
||||
}
|
||||
@@ -274,7 +268,7 @@ ${finalXml}
|
||||
toolCall.toolCallId,
|
||||
"failed",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
attempt,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
result,
|
||||
imageData: capturedPngData,
|
||||
@@ -285,19 +279,17 @@ ${finalXml}
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Max retries reached - accept the diagram with warning
|
||||
// Last attempt - accept the diagram with warning
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
validationRetryCountRef.current = 0
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
@@ -314,10 +306,8 @@ ${finalXml}
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - clean up retry count
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
// Validation passed - reset retry count
|
||||
validationRetryCountRef.current = 0
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
@@ -382,12 +372,17 @@ ${finalXml}
|
||||
}
|
||||
|
||||
let currentXml = ""
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
// On failure, undo the streaming preview so the canvas matches the XML
|
||||
// reported back to the model
|
||||
const restoreOriginal = () => {
|
||||
if (originalXml) onDisplayChart(originalXml, true)
|
||||
}
|
||||
try {
|
||||
// Use the original XML captured during streaming (shared with chat-message-display)
|
||||
// This ensures we apply operations to the same base XML that streaming used
|
||||
const originalXml = editDiagramOriginalXmlRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (originalXml) {
|
||||
currentXml = originalXml
|
||||
} else {
|
||||
@@ -416,6 +411,7 @@ ${finalXml}
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -441,6 +437,7 @@ Please check the cell IDs and retry.`,
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -472,6 +469,7 @@ Please fix the operations to avoid structural issues.`,
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -496,6 +494,19 @@ Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Nothing to continue: loading the fragment alone would replace the whole diagram
|
||||
if (!partialXmlRef.current) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
|
||||
|
||||
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
|
||||
+57
-29
@@ -101,6 +101,15 @@ function saveConfig(config: MultiModelConfig): void {
|
||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* Server model to fall back to: the one marked default, else the first one
|
||||
*/
|
||||
function defaultServerModelId(
|
||||
serverModels: FlattenedServerModel[],
|
||||
): string | undefined {
|
||||
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
|
||||
}
|
||||
|
||||
export interface UseModelConfigReturn {
|
||||
// State
|
||||
config: MultiModelConfig
|
||||
@@ -144,6 +153,16 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
setIsLoaded(true)
|
||||
}, [])
|
||||
|
||||
// Pick up config changes saved by other tabs, so this tab neither shows a
|
||||
// stale model nor overwrites their changes on its next save
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
|
||||
}
|
||||
window.addEventListener("storage", handleStorage)
|
||||
return () => window.removeEventListener("storage", handleStorage)
|
||||
}, [])
|
||||
|
||||
// Load server models on mount (if any)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
@@ -165,17 +184,18 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
setServerModels(raw)
|
||||
setServerLoaded(true)
|
||||
|
||||
// Auto-select default server model if no model is currently selected
|
||||
// Auto-select the default server model if no model is selected,
|
||||
// or if the saved server model is gone (renamed or removed)
|
||||
setConfig((prev) => {
|
||||
if (!prev.selectedModelId && raw.length > 0) {
|
||||
const defaultModel = raw.find((m) => m.isDefault)
|
||||
if (defaultModel) {
|
||||
return { ...prev, selectedModelId: defaultModel.id }
|
||||
}
|
||||
// If no default marked, use first server model
|
||||
return { ...prev, selectedModelId: raw[0].id }
|
||||
}
|
||||
return prev
|
||||
const id = prev.selectedModelId
|
||||
const isStale =
|
||||
id?.startsWith("server:") &&
|
||||
!raw.some((m) => m.id === id)
|
||||
if (id && !isStale) return prev
|
||||
const fallback = defaultServerModelId(raw)
|
||||
return fallback === id
|
||||
? prev
|
||||
: { ...prev, selectedModelId: fallback }
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -260,24 +280,31 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
[],
|
||||
)
|
||||
|
||||
const deleteProvider = useCallback((providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
const deleteProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
setConfig((prev) => {
|
||||
const provider = prev.providers.find((p) => p.id === providerId)
|
||||
const modelIds = provider?.models.map((m) => m.id) || []
|
||||
|
||||
// Clear selected model if it belongs to deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
||||
? undefined
|
||||
: prev.selectedModelId
|
||||
// Fall back to the default server model if the selected model
|
||||
// belongs to the deleted provider
|
||||
const newSelectedId =
|
||||
prev.selectedModelId &&
|
||||
modelIds.includes(prev.selectedModelId)
|
||||
? defaultServerModelId(serverModels)
|
||||
: prev.selectedModelId
|
||||
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
return {
|
||||
...prev,
|
||||
providers: prev.providers.filter(
|
||||
(p) => p.id !== providerId,
|
||||
),
|
||||
selectedModelId: newSelectedId,
|
||||
}
|
||||
})
|
||||
},
|
||||
[serverModels],
|
||||
)
|
||||
|
||||
const addModel = useCallback(
|
||||
(providerId: string, modelId: string): ModelConfig => {
|
||||
@@ -334,14 +361,15 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
}
|
||||
: p,
|
||||
),
|
||||
// Clear selected model if it was deleted
|
||||
// Fall back to the default server model if the selected model
|
||||
// was deleted
|
||||
selectedModelId:
|
||||
prev.selectedModelId === modelConfigId
|
||||
? undefined
|
||||
? defaultServerModelId(serverModels)
|
||||
: prev.selectedModelId,
|
||||
}))
|
||||
},
|
||||
[],
|
||||
[serverModels],
|
||||
)
|
||||
|
||||
const resetConfig = useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import {
|
||||
type ChatSession,
|
||||
createEmptySession,
|
||||
@@ -44,6 +46,15 @@ export interface UseSessionManagerReturn {
|
||||
clearCurrentSession: () => void
|
||||
}
|
||||
|
||||
// Reading the session list loads every stored session in full, and window
|
||||
// focus also fires each time the user clicks back from the draw.io iframe
|
||||
const FOCUS_REFRESH_INTERVAL_MS = 30_000
|
||||
|
||||
function notifySaveFailed(message: string) {
|
||||
// Same id, so repeated failures update one toast instead of stacking
|
||||
toast.error(message, { id: "session-save-failed", duration: 8000 })
|
||||
}
|
||||
|
||||
interface UseSessionManagerOptions {
|
||||
/** Session ID from URL param - if provided, load this session; if null, start blank */
|
||||
initialSessionId?: string | null
|
||||
@@ -53,6 +64,7 @@ export function useSessionManager(
|
||||
options: UseSessionManagerOptions = {},
|
||||
): UseSessionManagerReturn {
|
||||
const { initialSessionId } = options
|
||||
const dict = useDictionary()
|
||||
const [sessions, setSessions] = useState<SessionMetadata[]>([])
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(
|
||||
null,
|
||||
@@ -163,9 +175,15 @@ export function useSessionManager(
|
||||
handleSessionIdChange()
|
||||
}, [initialSessionId, isAvailable])
|
||||
|
||||
// Refresh sessions on window focus (multi-tab sync)
|
||||
// Refresh sessions on window focus (multi-tab sync), at most once per interval
|
||||
const lastFocusRefreshRef = useRef(0)
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
const now = Date.now()
|
||||
if (now - lastFocusRefreshRef.current < FOCUS_REFRESH_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
lastFocusRefreshRef.current = now
|
||||
refreshSessions()
|
||||
}
|
||||
window.addEventListener("focus", handleFocus)
|
||||
@@ -238,6 +256,8 @@ export function useSessionManager(
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Nothing can be stored without IndexedDB
|
||||
if (!isIndexedDBAvailable()) return
|
||||
|
||||
if (!currentSession) {
|
||||
// Create a new session if none exists
|
||||
@@ -250,7 +270,12 @@ export function useSessionManager(
|
||||
diagramHistory: data.diagramHistory,
|
||||
title: extractTitle(data.messages),
|
||||
}
|
||||
await saveSession(newSession)
|
||||
// Without a stored session, keep no session id (it would end
|
||||
// up in the URL and point to nothing after a reload)
|
||||
if (!(await saveSession(newSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
}
|
||||
await enforceSessionLimit()
|
||||
setCurrentSession(newSession)
|
||||
setCurrentSessionId(newSession.id)
|
||||
@@ -277,7 +302,10 @@ export function useSessionManager(
|
||||
: currentSession.title,
|
||||
}
|
||||
|
||||
await saveSession(updatedSession)
|
||||
if (!(await saveSession(updatedSession))) {
|
||||
notifySaveFailed(dict.errors.sessionSaveFailed)
|
||||
return
|
||||
}
|
||||
setCurrentSession(updatedSession)
|
||||
|
||||
// Update sessions list metadata
|
||||
@@ -298,7 +326,7 @@ export function useSessionManager(
|
||||
),
|
||||
)
|
||||
},
|
||||
[currentSession, currentSessionId, refreshSessions],
|
||||
[currentSession, currentSessionId, refreshSessions, dict],
|
||||
)
|
||||
|
||||
// Clear current session state (for starting fresh without loading another session)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { experimental_useObject as useObject } from "@ai-sdk/react"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { getSelectedAIConfig } from "@/hooks/use-model-config"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
type ValidationResult,
|
||||
@@ -39,6 +40,8 @@ export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
|
||||
const { object, submit, isLoading, error, stop } = useObject({
|
||||
api: getApiEndpoint("/api/validate-diagram"),
|
||||
schema: ValidationResultSchema,
|
||||
// Resolved per request so a changed access code is picked up
|
||||
headers: () => ({ "x-access-code": getSelectedAIConfig().accessCode }),
|
||||
onFinish: ({
|
||||
object,
|
||||
error: finishError,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 },
|
||||
)
|
||||
}
|
||||
+19
-7
@@ -2,6 +2,7 @@ import { z } from "zod"
|
||||
import {
|
||||
ProviderNameSchema,
|
||||
type ServerModelsConfig,
|
||||
slugify,
|
||||
} from "@/lib/server-model-config"
|
||||
import {
|
||||
FIXED_CRED_PROVIDERS,
|
||||
@@ -182,12 +183,15 @@ 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.`
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
if (new Set(names).size !== names.length) {
|
||||
return "Provider display names must be unique."
|
||||
const slugs = names.map(slugify)
|
||||
if (new Set(slugs).size !== slugs.length) {
|
||||
return "Provider display names must be unique (ignoring case and punctuation)."
|
||||
}
|
||||
const envNames = new Set(envProviders.map((p) => p.name))
|
||||
const clash = names.find((n) => envNames.has(n))
|
||||
const envSlugs = new Set(envProviders.map((p) => slugify(p.name)))
|
||||
const clash = names.find((_, i) => envSlugs.has(slugs[i]))
|
||||
if (clash) {
|
||||
return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.`
|
||||
}
|
||||
@@ -240,10 +244,14 @@ export function deriveEnvUpdates(
|
||||
indexByProvider.set(p.provider, index + 1)
|
||||
|
||||
if (p.provider === "bedrock") {
|
||||
if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId
|
||||
// ADMIN_ names keep the standard AWS_* vars untouched, so other
|
||||
// AWS clients (e.g. the DynamoDB quota table) keep their own
|
||||
// credentials instead of picking up the panel's Bedrock keys
|
||||
if (p.awsAccessKeyId)
|
||||
updates.ADMIN_AWS_ACCESS_KEY_ID = p.awsAccessKeyId
|
||||
if (p.awsSecretAccessKey)
|
||||
updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
|
||||
if (p.awsRegion) updates.AWS_REGION = p.awsRegion
|
||||
updates.ADMIN_AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey
|
||||
if (p.awsRegion) updates.ADMIN_AWS_REGION = p.awsRegion
|
||||
} else if (p.provider === "vertexai") {
|
||||
if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey
|
||||
if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl
|
||||
@@ -284,6 +292,10 @@ function derivedEnvKeys(list: StoredAdminProvider[]): string[] {
|
||||
const index = indexByProvider.get(p.provider) ?? 0
|
||||
indexByProvider.set(p.provider, index + 1)
|
||||
if (p.provider === "bedrock") {
|
||||
keys.add("ADMIN_AWS_ACCESS_KEY_ID")
|
||||
keys.add("ADMIN_AWS_SECRET_ACCESS_KEY")
|
||||
keys.add("ADMIN_AWS_REGION")
|
||||
// Written by older versions; listed so the next save clears them
|
||||
keys.add("AWS_ACCESS_KEY_ID")
|
||||
keys.add("AWS_SECRET_ACCESS_KEY")
|
||||
keys.add("AWS_REGION")
|
||||
|
||||
+34
-19
@@ -10,13 +10,27 @@ interface SettingsFile {
|
||||
values: Record<string, string>
|
||||
}
|
||||
|
||||
// Original env values snapshotted before the first overlay, so removing a
|
||||
// key from the settings file restores the env default. null = was unset.
|
||||
const originalEnv: Record<string, string | null> = {}
|
||||
// Keys currently overlaid, so we can restore ones removed from the file.
|
||||
let overlaidKeys = new Set<string>()
|
||||
interface SettingsState {
|
||||
// Original env values snapshotted before the first overlay, so removing
|
||||
// a key from the settings file restores the env default. null = was unset.
|
||||
originalEnv: Record<string, string | null>
|
||||
// Keys currently overlaid, so we can restore ones removed from the file.
|
||||
overlaidKeys: Set<string>
|
||||
cachedSettings: Record<string, string> | null
|
||||
}
|
||||
|
||||
let cachedSettings: Record<string, string> | null = null
|
||||
// Kept on globalThis because the build can load this module more than once
|
||||
// (instrumentation.ts and the API routes get separate copies); per-module
|
||||
// state would make a route forget what instrumentation overlaid at startup.
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
__adminSettingsState?: SettingsState
|
||||
}
|
||||
globalState.__adminSettingsState ??= {
|
||||
originalEnv: {},
|
||||
overlaidKeys: new Set(),
|
||||
cachedSettings: null,
|
||||
}
|
||||
const state = globalState.__adminSettingsState
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
const custom = process.env.SETTINGS_FILE
|
||||
@@ -25,7 +39,7 @@ export function getSettingsPath(): string {
|
||||
}
|
||||
|
||||
export function loadSettings(): Record<string, string> {
|
||||
if (cachedSettings) return cachedSettings
|
||||
if (state.cachedSettings) return state.cachedSettings
|
||||
try {
|
||||
const raw = fs.readFileSync(getSettingsPath(), "utf8")
|
||||
const parsed = JSON.parse(raw) as SettingsFile
|
||||
@@ -43,21 +57,22 @@ export function loadSettings(): Record<string, string> {
|
||||
for (const [key, value] of Object.entries(rawValues)) {
|
||||
if (typeof value === "string") values[key] = value
|
||||
}
|
||||
cachedSettings = values
|
||||
state.cachedSettings = values
|
||||
} catch (err: any) {
|
||||
if (err?.code !== "ENOENT") {
|
||||
console.error("[admin-settings] Failed to read settings file:", err)
|
||||
}
|
||||
cachedSettings = {}
|
||||
state.cachedSettings = {}
|
||||
}
|
||||
return cachedSettings
|
||||
return state.cachedSettings
|
||||
}
|
||||
|
||||
export function applyToEnv(): void {
|
||||
const values = loadSettings()
|
||||
const { originalEnv } = state
|
||||
|
||||
// Restore env for keys that were overlaid before but are now gone
|
||||
for (const key of overlaidKeys) {
|
||||
for (const key of state.overlaidKeys) {
|
||||
if (!(key in values)) {
|
||||
const original = originalEnv[key]
|
||||
if (original === null) delete process.env[key]
|
||||
@@ -72,12 +87,12 @@ export function applyToEnv(): void {
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
overlaidKeys = new Set(Object.keys(values))
|
||||
state.overlaidKeys = new Set(Object.keys(values))
|
||||
}
|
||||
|
||||
// The effective env value if the file entry were removed (for fallback display)
|
||||
export function getEnvFallback(key: string): string | null {
|
||||
if (overlaidKeys.has(key)) return originalEnv[key] ?? null
|
||||
if (state.overlaidKeys.has(key)) return state.originalEnv[key] ?? null
|
||||
return process.env[key] ?? null
|
||||
}
|
||||
|
||||
@@ -101,7 +116,7 @@ export function saveSettings(updates: Record<string, string | null>): void {
|
||||
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
fs.renameSync(tmpPath, filePath)
|
||||
|
||||
cachedSettings = current
|
||||
state.cachedSettings = current
|
||||
applyToEnv()
|
||||
}
|
||||
|
||||
@@ -122,13 +137,13 @@ export function isSettingsWritable(): boolean {
|
||||
|
||||
// Test-only: reset module state
|
||||
export function _resetForTests(): void {
|
||||
cachedSettings = null
|
||||
state.cachedSettings = null
|
||||
writableCache = null
|
||||
for (const key of overlaidKeys) {
|
||||
const original = originalEnv[key]
|
||||
for (const key of state.overlaidKeys) {
|
||||
const original = state.originalEnv[key]
|
||||
if (original === null) delete process.env[key]
|
||||
else if (original !== undefined) process.env[key] = original
|
||||
}
|
||||
overlaidKeys = new Set()
|
||||
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
|
||||
state.overlaidKeys = new Set()
|
||||
state.originalEnv = {}
|
||||
}
|
||||
|
||||
+117
-92
@@ -10,6 +10,10 @@ import { aihubmix, createAihubmix } from "@aihubmix/ai-sdk-provider"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { createOllama, ollama } from "ollama-ai-provider-v2"
|
||||
import {
|
||||
adminProvidersToConfig,
|
||||
loadAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
export type { ProviderName }
|
||||
@@ -32,6 +36,7 @@ export const SINGLE_SYSTEM_PROVIDERS = new Set<ProviderName>([
|
||||
"kimi",
|
||||
"qiniu",
|
||||
"novita",
|
||||
"mimo",
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -116,6 +121,8 @@ const ALLOWED_CLIENT_PROVIDERS: ProviderName[] = [
|
||||
"kimi",
|
||||
"minimax",
|
||||
"novita",
|
||||
"mimo",
|
||||
"atlascloud",
|
||||
]
|
||||
|
||||
// Bedrock provider options for Anthropic beta features
|
||||
@@ -540,7 +547,9 @@ function buildProviderOptions(
|
||||
case "qwen":
|
||||
case "kimi":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "novita":
|
||||
case "atlascloud":
|
||||
case "mimo": {
|
||||
// These providers don't have reasoning configs in AI SDK yet
|
||||
// Gateway passes through to underlying providers which handle their own configs
|
||||
break
|
||||
@@ -577,6 +586,8 @@ export const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
kimi: "KIMI_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
novita: "NOVITA_API_KEY",
|
||||
mimo: "MIMO_API_KEY",
|
||||
atlascloud: "ATLASCLOUD_API_KEY",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -817,8 +828,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
// Use client-provided credentials if available, otherwise fall back to IAM/env vars
|
||||
const hasClientCredentials =
|
||||
overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey
|
||||
// Keys from the admin panel. The ADMIN_ names keep them out of the
|
||||
// default AWS credential chain, which other clients such as the
|
||||
// DynamoDB quota manager use with their own credentials.
|
||||
const adminAccessKeyId = process.env.ADMIN_AWS_ACCESS_KEY_ID
|
||||
const adminSecretAccessKey = process.env.ADMIN_AWS_SECRET_ACCESS_KEY
|
||||
const bedrockRegion =
|
||||
overrides?.awsRegion || process.env.AWS_REGION || "us-west-2"
|
||||
overrides?.awsRegion ||
|
||||
process.env.ADMIN_AWS_REGION ||
|
||||
process.env.AWS_REGION ||
|
||||
"us-west-2"
|
||||
|
||||
const bedrockProvider = hasClientCredentials
|
||||
? createAmazonBedrock({
|
||||
@@ -829,10 +848,16 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
sessionToken: overrides.awsSessionToken,
|
||||
}),
|
||||
})
|
||||
: createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
: adminAccessKeyId && adminSecretAccessKey
|
||||
? createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
accessKeyId: adminAccessKeyId,
|
||||
secretAccessKey: adminSecretAccessKey,
|
||||
})
|
||||
: createAmazonBedrock({
|
||||
region: bedrockRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
})
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
@@ -865,8 +890,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
// for compatibility (most proxies don't support /responses endpoint)
|
||||
const customOpenAI = createOpenAI({ apiKey, baseURL })
|
||||
model = customOpenAI.chat(modelId)
|
||||
} else if (overrides?.apiKey) {
|
||||
// Custom API key but official OpenAI endpoint, use Responses API
|
||||
} else if (overrides?.apiKey || overrides?.apiKeyEnv) {
|
||||
// Custom API key (the client's, or a server model's own env var)
|
||||
// but official OpenAI endpoint, use Responses API
|
||||
// to support reasoning for gpt-5, o1, o3, o4 models
|
||||
const customOpenAI = createOpenAI({ apiKey })
|
||||
model = customOpenAI(modelId)
|
||||
@@ -921,7 +947,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
overrides?.baseUrl,
|
||||
serverBaseUrl,
|
||||
)
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
// The default instance only reads GOOGLE_GENERATIVE_AI_API_KEY, so a
|
||||
// server model's own env var (apiKeyEnv) needs a custom instance too
|
||||
if (baseURL || overrides?.apiKey || overrides?.apiKeyEnv) {
|
||||
const customGoogle = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
@@ -934,8 +962,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
}
|
||||
case "vertexai": {
|
||||
// Express Mode: Use API key for authentication
|
||||
const vertexApiKey =
|
||||
overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
|
||||
// SECURITY: a client base URL only ever gets the client's key, so the
|
||||
// server's GOOGLE_VERTEX_API_KEY is never sent to a client-chosen host
|
||||
const vertexApiKey = overrides?.baseUrl
|
||||
? overrides.vertexApiKey
|
||||
: overrides?.vertexApiKey || process.env.GOOGLE_VERTEX_API_KEY
|
||||
|
||||
if (!vertexApiKey) {
|
||||
throw new Error(
|
||||
@@ -944,9 +975,13 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
)
|
||||
}
|
||||
|
||||
// Support custom base URL from env or client override
|
||||
const baseURL =
|
||||
overrides?.baseUrl || process.env.GOOGLE_VERTEX_BASE_URL
|
||||
// Support custom base URL from env or client override.
|
||||
// A client key only goes to the client's URL or the official one.
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.vertexApiKey,
|
||||
overrides?.baseUrl,
|
||||
process.env.GOOGLE_VERTEX_BASE_URL,
|
||||
)
|
||||
|
||||
const vertexProvider = createVertex({
|
||||
apiKey: vertexApiKey,
|
||||
@@ -1072,7 +1107,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
overrides?.baseUrl,
|
||||
serverBaseUrl,
|
||||
)
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
if (baseURL || overrides?.apiKey || overrides?.apiKeyEnv) {
|
||||
const customDeepSeek = createDeepSeek({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
@@ -1234,7 +1269,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
)
|
||||
// Only use custom configuration if explicitly set (local dev or custom Gateway)
|
||||
// Otherwise undefined → AI SDK uses Vercel default (https://ai-gateway.vercel.sh/v1/ai) + OIDC
|
||||
if (baseURL || overrides?.apiKey) {
|
||||
if (baseURL || overrides?.apiKey || overrides?.apiKeyEnv) {
|
||||
const customGateway = createGateway({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
@@ -1346,10 +1381,28 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
break
|
||||
}
|
||||
|
||||
case "mimo": {
|
||||
const apiKey = resolveApiKey(overrides, "MIMO_API_KEY")
|
||||
const baseURL = resolveBaseURL(
|
||||
overrides?.apiKey,
|
||||
overrides?.baseUrl,
|
||||
resolveBaseUrlEnv(overrides, "MIMO_BASE_URL"),
|
||||
PROVIDER_INFO.mimo?.defaultBaseUrl,
|
||||
)
|
||||
// Use createDeepSeek to properly handle reasoning_content for MiMo
|
||||
// thinking models (e.g., mimo-v2.5-pro). MiMo's API requires
|
||||
// reasoning_content to be passed back during multi-turn tool calls
|
||||
// (returns 400 otherwise), same convention as DeepSeek and Kimi.
|
||||
const mimoProvider = createDeepSeek({ apiKey, baseURL })
|
||||
model = mimoProvider(modelId)
|
||||
break
|
||||
}
|
||||
|
||||
case "glm":
|
||||
case "qwen":
|
||||
case "qiniu":
|
||||
case "novita": {
|
||||
case "novita":
|
||||
case "atlascloud": {
|
||||
const envVar = PROVIDER_ENV_VARS[provider]
|
||||
if (!envVar) {
|
||||
throw new Error(
|
||||
@@ -1393,7 +1446,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita`,
|
||||
`Unknown AI provider: ${provider}. Supported providers: bedrock, openai, anthropic, google, azure, ollama, openrouter, aihubmix, deepseek, siliconflow, sglang, gateway, edgeone, doubao, modelscope, glm, qwen, qiniu, kimi, minimax, novita, mimo, atlascloud`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1405,6 +1458,36 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
return { model, providerOptions, headers, modelId, provider }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the call is paid for by the server's own credentials (env keys or
|
||||
* IAM role) rather than credentials sent with the request. Mirrors which key
|
||||
* each branch of getAIModel ends up using.
|
||||
*/
|
||||
export function usesServerCredentials(
|
||||
provider: ProviderName,
|
||||
overrides?: ClientOverrides,
|
||||
): boolean {
|
||||
switch (provider) {
|
||||
case "bedrock":
|
||||
return !(overrides?.awsAccessKeyId && overrides?.awsSecretAccessKey)
|
||||
case "vertexai":
|
||||
return !overrides?.vertexApiKey
|
||||
case "edgeone":
|
||||
// The platform's own endpoint, no key involved
|
||||
return false
|
||||
case "ollama":
|
||||
// Only a server key costs money; a keyless local server or the
|
||||
// client's own server does not
|
||||
return (
|
||||
!overrides?.baseUrl &&
|
||||
!overrides?.apiKey &&
|
||||
!!(overrides?.apiKeyEnv || process.env.OLLAMA_API_KEY)
|
||||
)
|
||||
default:
|
||||
return !overrides?.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports prompt caching.
|
||||
* Currently only Claude models on Bedrock support prompt caching.
|
||||
@@ -1419,77 +1502,14 @@ export function supportsPromptCaching(modelId: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports image/vision input.
|
||||
* Some models silently drop image parts without error (AI SDK warning only).
|
||||
*/
|
||||
export function supportsImageInput(modelId: string): boolean {
|
||||
const lowerModelId = modelId.toLowerCase()
|
||||
|
||||
// Helper to check if model has vision capability indicator
|
||||
const hasVisionIndicator =
|
||||
lowerModelId.includes("vision") || lowerModelId.includes("vl")
|
||||
|
||||
// Models that DON'T support image/vision input (unless vision variant)
|
||||
// Kimi K2 doesn't support images, but K2.5 does
|
||||
// Only block kimi-k2 specifically, not other Kimi models
|
||||
if (
|
||||
(lowerModelId.includes("kimi-k2") ||
|
||||
lowerModelId.includes("kimi_k2")) &&
|
||||
!hasVisionIndicator &&
|
||||
!lowerModelId.includes("2.5") &&
|
||||
!lowerModelId.includes("k2.5")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Moonshot text models (moonshot-v1 series are text-only)
|
||||
if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) {
|
||||
return false
|
||||
}
|
||||
|
||||
// MiniMax text models (MiniMax-M2.x series are text-only; M3 supports image input)
|
||||
if (
|
||||
lowerModelId.includes("minimax") &&
|
||||
!hasVisionIndicator &&
|
||||
!lowerModelId.includes("m3")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// DeepSeek text models (not vision variants)
|
||||
if (lowerModelId.includes("deepseek") && !hasVisionIndicator) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Qwen text models (not vision variants like qwen-vl)
|
||||
// Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input
|
||||
// QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/"
|
||||
if (
|
||||
lowerModelId.includes("qwen") &&
|
||||
!hasVisionIndicator &&
|
||||
!lowerModelId.includes("qwen3.5") &&
|
||||
!lowerModelId.includes("qvq")
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// GLM text models (not vision variants)
|
||||
// GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking
|
||||
if (lowerModelId.includes("glm") && !hasVisionIndicator) {
|
||||
if (!/[\d.]v/.test(lowerModelId)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Default: assume model supports images
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the AI model for diagram validation.
|
||||
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
|
||||
* Throws if the model doesn't support image input.
|
||||
*
|
||||
* Note: we no longer guess whether the model supports image input from its
|
||||
* name — that heuristic misfired on newer models (see issue #874). If a
|
||||
* configured validation model can't handle images, the API call simply errors
|
||||
* and the validate-diagram route falls back to "valid".
|
||||
*/
|
||||
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||
// AI_MODEL may be comma-separated (multi-model fallback); pick the first.
|
||||
@@ -1502,12 +1522,17 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||
)
|
||||
}
|
||||
|
||||
if (!supportsImageInput(modelId)) {
|
||||
throw new Error(
|
||||
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
|
||||
)
|
||||
}
|
||||
// A default set in the admin panel becomes AI_PROVIDER/AI_MODEL, but its key
|
||||
// lives in an ADMIN_-prefixed env var. Point at it the way the chat route
|
||||
// does for server models, or the standard env var is required instead.
|
||||
const panelDefault = adminProvidersToConfig(
|
||||
loadAdminProviders(),
|
||||
).providers.find((p) => p.default && p.provider === process.env.AI_PROVIDER)
|
||||
|
||||
const { model } = getAIModel({ modelId })
|
||||
const { model } = getAIModel({
|
||||
modelId,
|
||||
apiKeyEnv: panelDefault?.apiKeyEnv,
|
||||
baseUrlEnv: panelDefault?.baseUrlEnv,
|
||||
})
|
||||
return model
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface CachedResponse {
|
||||
promptText: string
|
||||
hasImage: boolean
|
||||
// Name of the bundled example file the prompt is sent with
|
||||
fileName?: string
|
||||
xml: string
|
||||
}
|
||||
|
||||
@@ -254,6 +256,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
{
|
||||
promptText: "Replicate this in aws style",
|
||||
hasImage: true,
|
||||
fileName: "architecture.png",
|
||||
xml: `<mxCell id="2" value="AWS" style="sketch=0;outlineConnect=0;gradientColor=none;html=1;whiteSpace=wrap;fontSize=12;fontStyle=0;container=1;pointerEvents=0;collapsible=0;recursiveResize=0;shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud;strokeColor=#232F3E;fillColor=none;verticalAlign=top;align=left;spacingLeft=30;fontColor=#232F3E;dashed=0;rounded=1;arcSize=5;" vertex="1" parent="1">
|
||||
<mxGeometry x="340" y="40" width="880" height="520" as="geometry"/>
|
||||
</mxCell>
|
||||
@@ -318,6 +321,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
{
|
||||
promptText: "Replicate this flowchart.",
|
||||
hasImage: true,
|
||||
fileName: "example.png",
|
||||
xml: `<mxCell id="2" value="Lamp doesn't work" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffcccc;strokeColor=#000000;strokeWidth=2;fontSize=18;fontStyle=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="140" y="40" width="180" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
@@ -379,6 +383,7 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
{
|
||||
promptText: "Summarize this paper as a diagram",
|
||||
hasImage: true,
|
||||
fileName: "chain-of-thought.txt",
|
||||
xml: `<mxCell id="title_bg" parent="1"
|
||||
style="rounded=1;whiteSpace=wrap;html=1;fillColor=#1a237e;strokeColor=none;arcSize=8;"
|
||||
value="" vertex="1">
|
||||
@@ -879,14 +884,19 @@ export const CACHED_EXAMPLE_RESPONSES: CachedResponse[] = [
|
||||
},
|
||||
]
|
||||
|
||||
// Examples that come with a file only match when that exact example file is
|
||||
// attached, so a user's own file with the same prompt still goes to the model.
|
||||
// Callers that can't tell file names (the server) only get text-only examples.
|
||||
export function findCachedResponse(
|
||||
promptText: string,
|
||||
hasImage: boolean,
|
||||
fileName?: string,
|
||||
): CachedResponse | undefined {
|
||||
return CACHED_EXAMPLE_RESPONSES.find(
|
||||
(c) =>
|
||||
c.promptText === promptText &&
|
||||
c.hasImage === hasImage &&
|
||||
(!c.fileName || c.fileName === fileName) &&
|
||||
c.xml !== "",
|
||||
)
|
||||
}
|
||||
|
||||
+96
-43
@@ -6,25 +6,37 @@ export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
export const MAX_FILES = 5
|
||||
|
||||
// Helper function to validate file parts in messages
|
||||
// Checks every message, since history is sent to the model too
|
||||
export function validateFileParts(messages: any[]): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
} {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const fileParts =
|
||||
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
|
||||
for (const message of messages) {
|
||||
const fileParts =
|
||||
message?.parts?.filter((p: any) => p.type === "file") || []
|
||||
|
||||
if (fileParts.length > MAX_FILES) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||
if (fileParts.length > MAX_FILES) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePart of fileParts) {
|
||||
// Data URLs format: data:image/png;base64,<data>
|
||||
// Base64 increases size by ~33%, so we check the decoded size
|
||||
if (filePart.url?.startsWith("data:")) {
|
||||
for (const filePart of fileParts) {
|
||||
// The client sends files inline. Any other URL would be downloaded
|
||||
// by the server (AI SDK does that for models without URL support).
|
||||
if (
|
||||
typeof filePart.url !== "string" ||
|
||||
!filePart.url.startsWith("data:")
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Files must be uploaded inline as data URLs.",
|
||||
}
|
||||
}
|
||||
|
||||
// Data URLs format: data:image/png;base64,<data>
|
||||
// Base64 increases size by ~33%, so we check the decoded size
|
||||
const base64Data = filePart.url.split(",")[1]
|
||||
if (base64Data) {
|
||||
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
|
||||
@@ -42,48 +54,89 @@ export function validateFileParts(messages: any[]): {
|
||||
}
|
||||
|
||||
// Helper function to check if diagram is minimal/empty
|
||||
// Empty means no mxCell besides the root cells "0" and "1". Cells drawn in
|
||||
// draw.io get random ids, so checking for id="2" is not enough.
|
||||
export function isMinimalDiagram(xml: string): boolean {
|
||||
const stripped = xml.replace(/\s/g, "")
|
||||
return !stripped.includes('id="2"')
|
||||
return !/<mxCell\b[^>]*\bid="(?![01]")/.test(xml)
|
||||
}
|
||||
|
||||
// A tool-call input providers accept: a non-empty JSON object
|
||||
function isValidToolInput(input: unknown): boolean {
|
||||
return !!input && typeof input === "object" && Object.keys(input).length > 0
|
||||
}
|
||||
|
||||
// Helper function to replace historical tool call XML with placeholders
|
||||
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
|
||||
// Also fixes invalid/undefined inputs from interrupted streaming
|
||||
// Tool calls with invalid inputs are left for dropInvalidToolCalls to remove
|
||||
export function replaceHistoricalToolInputs(messages: any[]): any[] {
|
||||
return messages.map((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
||||
return msg
|
||||
}
|
||||
const replacedContent = msg.content
|
||||
.map((part: any) => {
|
||||
if (part.type === "tool-call") {
|
||||
const toolName = part.toolName
|
||||
// Fix invalid/undefined inputs from interrupted streaming
|
||||
if (
|
||||
!part.input ||
|
||||
typeof part.input !== "object" ||
|
||||
Object.keys(part.input).length === 0
|
||||
) {
|
||||
// Skip tool calls with invalid inputs entirely
|
||||
return null
|
||||
}
|
||||
if (
|
||||
toolName === "display_diagram" ||
|
||||
toolName === "edit_diagram"
|
||||
) {
|
||||
return {
|
||||
...part,
|
||||
input: {
|
||||
placeholder:
|
||||
"[XML content replaced - see current diagram XML in system context]",
|
||||
},
|
||||
}
|
||||
}
|
||||
const replacedContent = msg.content.map((part: any) => {
|
||||
if (
|
||||
part.type === "tool-call" &&
|
||||
isValidToolInput(part.input) &&
|
||||
(part.toolName === "display_diagram" ||
|
||||
part.toolName === "edit_diagram")
|
||||
) {
|
||||
return {
|
||||
...part,
|
||||
input: {
|
||||
placeholder:
|
||||
"[XML content replaced - see current diagram XML in system context]",
|
||||
},
|
||||
}
|
||||
return part
|
||||
})
|
||||
.filter(Boolean) // Remove null entries (invalid tool calls)
|
||||
}
|
||||
return part
|
||||
})
|
||||
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": "')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (Xiaomi)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "Describe your diagram or upload a file...",
|
||||
@@ -131,6 +132,8 @@
|
||||
"customSystemMessage": "Custom System Message",
|
||||
"customSystemMessageDescription": "Add custom instructions appended to the AI's system prompt.",
|
||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
||||
"maxOutputTokens": "Max Output Tokens",
|
||||
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
||||
"panelVisibility": "Lobby Panels",
|
||||
"panelVisibilityDescription": "Choose which panels to show on the chat lobby.",
|
||||
"showRecentChats": "Recent Chats",
|
||||
@@ -185,7 +188,8 @@
|
||||
"failedToExport": "Error fetching chart data",
|
||||
"failedToLoadExample": "Error loading example image",
|
||||
"failedToRecordFeedback": "Failed to record your feedback. Please try again.",
|
||||
"storageUpdateFailed": "Chat cleared but browser storage could not be updated"
|
||||
"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."
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "Daily Quota Reached",
|
||||
@@ -371,6 +375,7 @@
|
||||
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
|
||||
"customEndpoint": "Custom endpoint URL",
|
||||
"minimaxBaseUrlHint": "Use /anthropic for Anthropic-compatible API (recommended), or /v1 for OpenAI-compatible API",
|
||||
"mimoBaseUrlHint": "Default works with pay-as-you-go keys (sk-...). Token Plan subscribers (tp-... keys) must set https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "Models",
|
||||
"customModelId": "Custom model ID...",
|
||||
"allAdded": "All added",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (Xiaomi)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "ダイアグラムを説明するか、ファイルをアップロード...",
|
||||
@@ -131,6 +132,8 @@
|
||||
"customSystemMessage": "カスタムシステムメッセージ",
|
||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||
"maxOutputTokens": "最大出力トークン数",
|
||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||
"panelVisibility": "ロビーパネル",
|
||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||
"showRecentChats": "最近のチャット",
|
||||
@@ -185,7 +188,8 @@
|
||||
"failedToExport": "チャートデータの取得エラー",
|
||||
"failedToLoadExample": "例の画像の読み込みエラー",
|
||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "1日の割当量に達しました",
|
||||
@@ -325,6 +329,7 @@
|
||||
"baseUrlWithExample": "ベース URL(オプション、例: {example})",
|
||||
"customEndpoint": "カスタムエンドポイント URL",
|
||||
"minimaxBaseUrlHint": "/anthropic で Anthropic 互換 API(推奨)、または /v1 で OpenAI 互換 API を使用",
|
||||
"mimoBaseUrlHint": "デフォルトは従量課金キー(sk-...)用です。Token Plan 加入者(tp-... キー)は https://token-plan-cn.xiaomimimo.com/v1 を設定してください",
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
"allAdded": "すべて追加済み",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (小米)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的圖表或上傳檔案...",
|
||||
@@ -131,6 +132,8 @@
|
||||
"customSystemMessage": "自訂系統訊息",
|
||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||
"maxOutputTokens": "最大輸出 token 數",
|
||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||
"panelVisibility": "大廳面板",
|
||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
@@ -185,7 +188,8 @@
|
||||
"failedToExport": "取得圖表資料時出錯",
|
||||
"failedToLoadExample": "載入範例圖片時出錯",
|
||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已達每日配額",
|
||||
@@ -371,6 +375,7 @@
|
||||
"baseUrlWithExample": "基礎 URL(可選,例如 {example})",
|
||||
"customEndpoint": "自訂端點 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端點為 Anthropic 相容 API(推薦),或使用 /v1 端點為 OpenAI 相容 API",
|
||||
"mimoBaseUrlHint": "預設地址適用於按量付費金鑰(sk-...)。Token Plan 訂閱用戶(tp-... 金鑰)請設定為 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自訂模型 ID...",
|
||||
"allAdded": "已全部新增",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"glm": "GLM",
|
||||
"qwen": "Qwen",
|
||||
"kimi": "Kimi",
|
||||
"qiniu": "Qiniu"
|
||||
"qiniu": "Qiniu",
|
||||
"mimo": "MiMo (小米)"
|
||||
},
|
||||
"chat": {
|
||||
"placeholder": "描述您的图表或上传文件...",
|
||||
@@ -131,6 +132,8 @@
|
||||
"customSystemMessage": "自定义系统消息",
|
||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||
"maxOutputTokens": "最大输出 token 数",
|
||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||
"panelVisibility": "大厅面板",
|
||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
@@ -185,7 +188,8 @@
|
||||
"failedToExport": "获取图表数据时出错",
|
||||
"failedToLoadExample": "加载示例图片时出错",
|
||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已达每日配额",
|
||||
@@ -371,6 +375,7 @@
|
||||
"baseUrlWithExample": "基础 URL(可选,例如 {example})",
|
||||
"customEndpoint": "自定义端点 URL",
|
||||
"minimaxBaseUrlHint": "使用 /anthropic 端点为 Anthropic 兼容 API(推荐),或使用 /v1 端点为 OpenAI 兼容 API",
|
||||
"mimoBaseUrlHint": "默认地址适用于按量付费密钥(sk-...)。Token Plan 订阅用户(tp-... 密钥)请设置为 https://token-plan-cn.xiaomimimo.com/v1",
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
"allAdded": "已全部添加",
|
||||
|
||||
+8
-1
@@ -51,8 +51,15 @@ export function setTraceOutput(output: string) {
|
||||
if (!isLangfuseEnabled()) return
|
||||
|
||||
updateActiveTrace({ output })
|
||||
endTrace()
|
||||
}
|
||||
|
||||
// End the observe() wrapper span (AI SDK creates its own child spans with usage).
|
||||
// It uses endOnExit: false, so every request path has to end it, or the trace
|
||||
// is never exported: stream finish, stream error/abort, and early returns.
|
||||
export function endTrace() {
|
||||
if (!isLangfuseEnabled()) return
|
||||
|
||||
// End the observe() wrapper span (AI SDK creates its own child spans with usage)
|
||||
const activeSpan = api.trace.getActiveSpan()
|
||||
if (activeSpan) {
|
||||
activeSpan.end()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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. */
|
||||
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
|
||||
}
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
import { extractText, getDocumentProxy } from "unpdf"
|
||||
import { extractText } from "unpdf"
|
||||
|
||||
// Maximum characters allowed for extracted text (configurable via env)
|
||||
const DEFAULT_MAX_EXTRACTED_CHARS = 150000 // 150k chars
|
||||
@@ -14,6 +14,7 @@ const TEXT_EXTENSIONS = [
|
||||
".json",
|
||||
".csv",
|
||||
".xml",
|
||||
".svg",
|
||||
".html",
|
||||
".css",
|
||||
".js",
|
||||
@@ -43,8 +44,10 @@ const TEXT_EXTENSIONS = [
|
||||
*/
|
||||
export async function extractPdfText(file: File): Promise<string> {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const pdf = await getDocumentProxy(new Uint8Array(buffer))
|
||||
const { text } = await extractText(pdf, { mergePages: true })
|
||||
// Pass raw bytes so unpdf destroys the PDF document when it is done
|
||||
const { text } = await extractText(new Uint8Array(buffer), {
|
||||
mergePages: true,
|
||||
})
|
||||
return text as string
|
||||
}
|
||||
|
||||
|
||||
@@ -47,11 +47,14 @@ export interface FlattenedServerModel {
|
||||
|
||||
/**
|
||||
* Convert provider name to URL-safe slug for use in model ID
|
||||
* e.g., "OpenAI Production" → "openai-production"
|
||||
* e.g., "OpenAI Production" → "openai-production", "主力" → "4e3b-529b"
|
||||
* Non-ASCII characters become their hex code point so CJK names stay
|
||||
* distinct; the id is sent in HTTP headers, which must be ASCII.
|
||||
*/
|
||||
function slugify(name: string): string {
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{ASCII}]/gu, (c) => `-${c.codePointAt(0)?.toString(16)}-`)
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
}
|
||||
@@ -189,6 +192,7 @@ export async function loadFlattenedServerModels(): Promise<
|
||||
const defaultModelId = process.env.AI_MODEL
|
||||
|
||||
const flattened: FlattenedServerModel[] = []
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
for (const p of cfg.providers) {
|
||||
const providerLabel =
|
||||
@@ -199,6 +203,16 @@ export async function loadFlattenedServerModels(): Promise<
|
||||
|
||||
for (const modelId of p.models) {
|
||||
const id = `server:${nameSlug}:${modelId}`
|
||||
// Names that differ only in case or punctuation share a slug.
|
||||
// A repeated id would always resolve to the first provider's
|
||||
// credentials, so drop it instead.
|
||||
if (seenIds.has(id)) {
|
||||
console.warn(
|
||||
`[server-model-config] Skipping duplicate model id "${id}". Provider names must differ in letters or digits.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
seenIds.add(id)
|
||||
|
||||
// Default model priority:
|
||||
// 1. From ai-models.json: first model of provider with default: true
|
||||
|
||||
+31
-23
@@ -1,5 +1,6 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
||||
import { nanoid } from "nanoid"
|
||||
import { toast } from "sonner"
|
||||
import type { Template } from "./template-storage"
|
||||
|
||||
// Constants
|
||||
@@ -61,6 +62,7 @@ let dbPromise: Promise<IDBPDatabase<ChatSessionDB>> | null = null
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<ChatSessionDB>> {
|
||||
if (!dbPromise) {
|
||||
// A failed or lost connection is not cached: the next call reopens it
|
||||
dbPromise = openDB<ChatSessionDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
@@ -88,6 +90,28 @@ 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
|
||||
@@ -145,6 +169,8 @@ 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> {
|
||||
if (!isIndexedDBAvailable()) return false
|
||||
try {
|
||||
@@ -152,29 +178,11 @@ export async function saveSession(session: ChatSession): Promise<boolean> {
|
||||
await db.put(STORE_NAME, session)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Handle quota exceeded
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
error.name === "QuotaExceededError"
|
||||
) {
|
||||
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
|
||||
}
|
||||
console.error("Failed to save session:", error)
|
||||
// Reopen the connection next time in case it was lost (Safari reports
|
||||
// "Connection to Indexed Database server lost" without closing it)
|
||||
dbPromise = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+94
-66
@@ -2,80 +2,108 @@
|
||||
* SSRF (Server-Side Request Forgery) protection utilities
|
||||
*/
|
||||
|
||||
import { lookup } from "node:dns/promises"
|
||||
|
||||
/**
|
||||
* Check if URL points to private/internal network
|
||||
* Blocks: localhost, private IPs, link-local, AWS metadata service
|
||||
* Check if an IP address (IPv4 or IPv6) belongs to a private/internal range.
|
||||
* Works for both user-supplied literal IPs and DNS-resolved addresses.
|
||||
*/
|
||||
export function isPrivateUrl(urlString: string): boolean {
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
const addr = ip.toLowerCase().replace(/^\[|\]$/g, "")
|
||||
|
||||
// IPv6
|
||||
if (addr.includes(":")) {
|
||||
if (addr === "::1" || addr === "::") return true
|
||||
// unique-local (fc00::/7) and IPv4-mapped (::ffff:0:0/96)
|
||||
if (
|
||||
addr.startsWith("fc") ||
|
||||
addr.startsWith("fd") ||
|
||||
addr.startsWith("::ffff:")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// link-local (fe80::/10)
|
||||
const linkLocal = addr.match(/^fe([0-9a-f]{2}):/)
|
||||
if (linkLocal) {
|
||||
const high = parseInt(linkLocal[1], 16)
|
||||
if (high >= 0x80 && high <= 0xbf) return true
|
||||
}
|
||||
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)
|
||||
// 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(/\.$/, "")
|
||||
|
||||
// Block localhost
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "::"
|
||||
) {
|
||||
return true
|
||||
}
|
||||
// Fast path: obvious string matches and literal IPs.
|
||||
if (isPrivateHostname(hostname)) return true
|
||||
|
||||
// Block IPv6 unique-local (fc00::/7), link-local (fe80::/10),
|
||||
// and IPv4-mapped (::ffff:0:0/96) hosts.
|
||||
if (hostname.includes(":")) {
|
||||
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
|
||||
// Resolve DNS and reject if any address is private.
|
||||
const stripped = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "")
|
||||
const addresses = await lookup(stripped, { all: true })
|
||||
return addresses.some(({ address }) => isPrivateIp(address))
|
||||
} catch {
|
||||
return true // Invalid URL - block it
|
||||
return true // Invalid URL or DNS failure - block it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ export const STORAGE_KEYS = {
|
||||
// Custom system message
|
||||
customSystemMessage: "next-ai-draw-io-custom-system-message",
|
||||
|
||||
// Output token budget per turn (empty = server default)
|
||||
maxOutputTokens: "next-ai-draw-io-max-output-tokens",
|
||||
|
||||
// Panel visibility
|
||||
showRecentChats: "next-ai-draw-io-show-recent-chats",
|
||||
showMyTemplates: "next-ai-draw-io-show-my-templates",
|
||||
|
||||
@@ -41,7 +41,7 @@ parameters: {
|
||||
tool name: edit_diagram
|
||||
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||
parameters: {
|
||||
edits: Array<{search: string, replace: string}>
|
||||
operations: Array<{operation: "update" | "add" | "delete", cell_id: string, new_xml?: string}>
|
||||
}
|
||||
---Tool3---
|
||||
tool name: append_diagram
|
||||
|
||||
@@ -23,6 +23,8 @@ export type ProviderName =
|
||||
| "kimi"
|
||||
| "minimax"
|
||||
| "novita"
|
||||
| "mimo"
|
||||
| "atlascloud"
|
||||
|
||||
// Individual model configuration
|
||||
export interface ModelConfig {
|
||||
@@ -114,6 +116,8 @@ export const PROVIDER_LOGO_MAP: Record<string, string> = {
|
||||
modelscope: "modelscope",
|
||||
minimax: "minimax",
|
||||
novita: "novita",
|
||||
mimo: "xiaomi",
|
||||
atlascloud: "openai",
|
||||
}
|
||||
|
||||
// Provider metadata
|
||||
@@ -200,6 +204,14 @@ export const PROVIDER_INFO: Record<
|
||||
label: "Novita AI",
|
||||
defaultBaseUrl: "https://api.novita.ai/openai",
|
||||
},
|
||||
mimo: {
|
||||
label: "MiMo (Xiaomi)",
|
||||
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
|
||||
},
|
||||
atlascloud: {
|
||||
label: "Atlas Cloud",
|
||||
defaultBaseUrl: "https://api.atlascloud.ai/v1",
|
||||
},
|
||||
}
|
||||
|
||||
// Suggested models per provider for quick add
|
||||
@@ -437,6 +449,8 @@ export const SUGGESTED_MODELS: Partial<Record<ProviderName, string[]>> = {
|
||||
"moonshotai/kimi-k2.6",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
],
|
||||
mimo: ["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
atlascloud: ["qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"],
|
||||
}
|
||||
|
||||
// Helper to generate UUID
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
|
||||
export interface UrlData {
|
||||
url: string
|
||||
@@ -18,7 +19,11 @@ const UrlResponseSchema = z.object({
|
||||
export async function extractUrlContent(url: string): Promise<UrlData> {
|
||||
const response = await fetch(getApiEndpoint("/api/parse-url"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-code":
|
||||
localStorage.getItem(STORAGE_KEYS.accessCode) || "",
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
|
||||
|
||||
+55
-61
@@ -27,78 +27,72 @@ export function useFileProcessor() {
|
||||
const handleFileChange = async (newFiles: File[]) => {
|
||||
setFiles(newFiles)
|
||||
|
||||
// Extract text immediately for new PDF/text files
|
||||
for (const file of newFiles) {
|
||||
const needsExtraction =
|
||||
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file)
|
||||
if (needsExtraction) {
|
||||
// Mark as extracting
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(file, {
|
||||
text: "",
|
||||
charCount: 0,
|
||||
isExtracting: true,
|
||||
})
|
||||
return next
|
||||
})
|
||||
const pending = newFiles.filter(
|
||||
(file) =>
|
||||
(isPdfFile(file) || isTextFile(file)) && !pdfData.has(file),
|
||||
)
|
||||
|
||||
// Extract text asynchronously
|
||||
try {
|
||||
let text: string
|
||||
if (isPdfFile(file)) {
|
||||
text = await extractPdfText(file)
|
||||
} else {
|
||||
text = await extractTextFileContent(file)
|
||||
}
|
||||
// Before any await: drop data for removed files and mark every new
|
||||
// file as extracting, so queued files also block sending
|
||||
setPdfData((prev) => {
|
||||
const next = new Map<File, FileData>()
|
||||
for (const file of newFiles) {
|
||||
const existing = prev.get(file)
|
||||
if (existing) next.set(file, existing)
|
||||
}
|
||||
for (const file of pending) {
|
||||
next.set(file, { text: "", charCount: 0, isExtracting: true })
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Check character limit
|
||||
if (text.length > MAX_EXTRACTED_CHARS) {
|
||||
const limitK = MAX_EXTRACTED_CHARS / 1000
|
||||
toast.error(
|
||||
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
|
||||
)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
// Remove the file from the list
|
||||
setFiles((prev) => prev.filter((f) => f !== file))
|
||||
continue
|
||||
}
|
||||
// Extract one file at a time
|
||||
for (const file of pending) {
|
||||
try {
|
||||
let text: string
|
||||
if (isPdfFile(file)) {
|
||||
text = await extractPdfText(file)
|
||||
} else {
|
||||
text = await extractTextFileContent(file)
|
||||
}
|
||||
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(file, {
|
||||
text,
|
||||
charCount: text.length,
|
||||
isExtracting: false,
|
||||
})
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to extract text:", error)
|
||||
toast.error(`Failed to read file: ${file.name}`)
|
||||
// Check character limit
|
||||
if (text.length > MAX_EXTRACTED_CHARS) {
|
||||
const limitK = MAX_EXTRACTED_CHARS / 1000
|
||||
toast.error(
|
||||
`${file.name}: Content exceeds ${limitK}k character limit (${(text.length / 1000).toFixed(1)}k chars)`,
|
||||
)
|
||||
setPdfData((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
// Remove the file from the list
|
||||
setFiles((prev) => prev.filter((f) => f !== file))
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+262
-193
@@ -76,6 +76,17 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||
// No valid ending found at all
|
||||
if (lastValidEnd === -1) return false
|
||||
|
||||
// If the last mxCell has no </mxCell> after it, it must be self-closing.
|
||||
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
|
||||
// and the output was cut off before the cell was closed.
|
||||
const lastCellStart = trimmed.lastIndexOf("<mxCell")
|
||||
if (
|
||||
lastCellStart > lastMxCellClose &&
|
||||
!/^<mxCell\b[^<]*\/>/.test(trimmed.slice(lastCellStart))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check what comes after the last valid ending
|
||||
// For />: add 2 chars, for </mxCell>: add 9 chars
|
||||
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
|
||||
@@ -95,36 +106,12 @@ export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
|
||||
export function extractCompleteMxCells(xml: string | undefined | null): string {
|
||||
if (!xml) return ""
|
||||
|
||||
const completeCells: Array<{ index: number; text: string }> = []
|
||||
// Match self-closing <mxCell ... /> or <mxCell ...>...</mxCell>, in document order.
|
||||
// The lazy [^>]*? tries "/>" first, so a self-closing cell never swallows
|
||||
// the following cells up to the next </mxCell>.
|
||||
const cellPattern = /<mxCell\b[^>]*?(?:\/>|>[\s\S]*?<\/mxCell>)/g
|
||||
|
||||
// Match self-closing mxCell tags: <mxCell ... />
|
||||
// Also match mxCell with nested mxGeometry: <mxCell ...>...<mxGeometry .../></mxCell>
|
||||
const selfClosingPattern = /<mxCell\s+[^>]*\/>/g
|
||||
const nestedPattern = /<mxCell\s+[^>]*>[\s\S]*?<\/mxCell>/g
|
||||
|
||||
// Find all self-closing mxCell elements
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = selfClosingPattern.exec(xml)) !== null) {
|
||||
completeCells.push({ index: match.index, text: match[0] })
|
||||
}
|
||||
|
||||
// Find all mxCell elements with nested content (like mxGeometry)
|
||||
while ((match = nestedPattern.exec(xml)) !== null) {
|
||||
completeCells.push({ index: match.index, text: match[0] })
|
||||
}
|
||||
|
||||
// Sort by position to maintain order
|
||||
completeCells.sort((a, b) => a.index - b.index)
|
||||
|
||||
// Remove duplicates (a self-closing match might overlap with nested match)
|
||||
const seen = new Set<number>()
|
||||
const uniqueCells = completeCells.filter((cell) => {
|
||||
if (seen.has(cell.index)) return false
|
||||
seen.add(cell.index)
|
||||
return true
|
||||
})
|
||||
|
||||
return uniqueCells.map((c) => c.text).join("\n")
|
||||
return (xml.match(cellPattern) || []).join("\n")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -487,6 +474,31 @@ export interface ApplyOperationsResult {
|
||||
errors: OperationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* draw.io wraps cells that have links, tooltips or custom data in
|
||||
* <object>/<UserObject>, and the wrapper carries the id instead of the mxCell.
|
||||
*/
|
||||
function getCellWrapper(cell: Element): Element | null {
|
||||
const parent = cell.parentElement
|
||||
return parent?.tagName === "object" || parent?.tagName === "UserObject"
|
||||
? parent
|
||||
: null
|
||||
}
|
||||
|
||||
/** Id of a cell, read from its wrapper when the mxCell has none */
|
||||
function getCellId(cell: Element): string | null {
|
||||
return (
|
||||
cell.getAttribute("id") ||
|
||||
getCellWrapper(cell)?.getAttribute("id") ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
/** Element to replace or remove for a cell (the wrapper if there is one) */
|
||||
function getCellNode(cell: Element): Element {
|
||||
return getCellWrapper(cell) || cell
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||
* This replaces the text-matching approach with direct DOM manipulation.
|
||||
@@ -535,12 +547,14 @@ export function applyDiagramOperations(
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of cell IDs to elements
|
||||
// Build a map of cell IDs to elements (wrapper elements for wrapped cells)
|
||||
const cellMap = new Map<string, Element>()
|
||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
if (id) cellMap.set(id, cell)
|
||||
const id = getCellId(cell)
|
||||
if (id) cellMap.set(id, getCellNode(cell))
|
||||
})
|
||||
// Cells removed by delete operations in this batch
|
||||
const deletedIds = new Set<string>()
|
||||
|
||||
// Process each operation
|
||||
for (const op of operations) {
|
||||
@@ -580,7 +594,7 @@ export function applyDiagramOperations(
|
||||
}
|
||||
|
||||
// Validate ID matches
|
||||
const newCellId = newCell.getAttribute("id")
|
||||
const newCellId = getCellId(newCell)
|
||||
if (newCellId !== op.cell_id) {
|
||||
errors.push({
|
||||
type: "update",
|
||||
@@ -590,8 +604,8 @@ export function applyDiagramOperations(
|
||||
continue
|
||||
}
|
||||
|
||||
// Import and replace the node
|
||||
const importedNode = doc.importNode(newCell, true)
|
||||
// Import and replace the node (with its wrapper, if any)
|
||||
const importedNode = doc.importNode(getCellNode(newCell), true)
|
||||
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
||||
|
||||
// Update the map with the new element
|
||||
@@ -632,7 +646,7 @@ export function applyDiagramOperations(
|
||||
}
|
||||
|
||||
// Validate ID matches
|
||||
const newCellId = newCell.getAttribute("id")
|
||||
const newCellId = getCellId(newCell)
|
||||
if (newCellId !== op.cell_id) {
|
||||
errors.push({
|
||||
type: "add",
|
||||
@@ -642,8 +656,8 @@ export function applyDiagramOperations(
|
||||
continue
|
||||
}
|
||||
|
||||
// Import and append the node
|
||||
const importedNode = doc.importNode(newCell, true)
|
||||
// Import and append the node (with its wrapper, if any)
|
||||
const importedNode = doc.importNode(getCellNode(newCell), true)
|
||||
root.appendChild(importedNode)
|
||||
|
||||
// Add to map
|
||||
@@ -661,8 +675,15 @@ export function applyDiagramOperations(
|
||||
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
// Cell not found - might have been cascade-deleted by a previous operation
|
||||
// Skip silently instead of erroring (AI may redundantly list children/edges)
|
||||
// Cells cascade-deleted earlier in this batch are skipped silently
|
||||
// (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
|
||||
}
|
||||
|
||||
@@ -679,7 +700,7 @@ export function applyDiagramOperations(
|
||||
`mxCell[parent="${cellId}"]`,
|
||||
)
|
||||
children.forEach((child) => {
|
||||
const childId = child.getAttribute("id")
|
||||
const childId = getCellId(child)
|
||||
if (childId && childId !== "0" && childId !== "1") {
|
||||
collectDescendants(childId)
|
||||
}
|
||||
@@ -696,7 +717,7 @@ export function applyDiagramOperations(
|
||||
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
|
||||
)
|
||||
referencingEdges.forEach((edge) => {
|
||||
const edgeId = edge.getAttribute("id")
|
||||
const edgeId = getCellId(edge)
|
||||
// Protect root cells from being added via edge references
|
||||
if (edgeId && edgeId !== "0" && edgeId !== "1") {
|
||||
// Recurse to collect edge's children (like labels)
|
||||
@@ -718,6 +739,7 @@ export function applyDiagramOperations(
|
||||
if (cell) {
|
||||
cell.parentNode?.removeChild(cell)
|
||||
cellMap.delete(cellId)
|
||||
deletedIds.add(cellId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,24 +780,89 @@ function checkDuplicateAttributes(xml: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check for duplicate IDs in XML */
|
||||
function checkDuplicateIds(xml: string): string | null {
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
/** Matches one <diagram> page of a document (the last one may be unclosed) */
|
||||
const PAGE_PATTERN = /<diagram\b[\s\S]*?(?:<\/diagram>|$)/g
|
||||
|
||||
const ID_ATTR_PATTERN = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
|
||||
/**
|
||||
* Split XML into pages. Ids only need to be unique within a page: every
|
||||
* page of a multi-page document has its own root cells "0" and "1".
|
||||
*/
|
||||
function splitPages(xml: string): string[] {
|
||||
return xml.match(PAGE_PATTERN) || [xml]
|
||||
}
|
||||
|
||||
/** Ids that appear more than once, with their counts */
|
||||
function findDuplicateIds(xml: string): Map<string, number> {
|
||||
const ids = new Map<string, number>()
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(xml)) !== null) {
|
||||
const id = idMatch[1]
|
||||
ids.set(id, (ids.get(id) || 0) + 1)
|
||||
for (const match of xml.matchAll(ID_ATTR_PATTERN)) {
|
||||
ids.set(match[1], (ids.get(match[1]) || 0) + 1)
|
||||
}
|
||||
const duplicateIds = Array.from(ids.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||
if (duplicateIds.length > 0) {
|
||||
return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.`
|
||||
return new Map(Array.from(ids).filter(([, count]) => count > 1))
|
||||
}
|
||||
|
||||
/** Check for duplicate IDs in XML (per page) */
|
||||
function checkDuplicateIds(xml: string): string | null {
|
||||
for (const page of splitPages(xml)) {
|
||||
const duplicateIds = Array.from(findDuplicateIds(page)).map(
|
||||
([id, count]) => `'${id}' (${count}x)`,
|
||||
)
|
||||
if (duplicateIds.length > 0) {
|
||||
return `Invalid XML: Found duplicate ID(s): ${duplicateIds.slice(0, 3).join(", ")}. All id attributes must be unique.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Rename repeated ids in one page (keeps the first occurrence) */
|
||||
function renameDuplicateIds(xml: string): { xml: string; renamed: number } {
|
||||
const duplicateIds = findDuplicateIds(xml)
|
||||
if (duplicateIds.size === 0) return { xml, renamed: 0 }
|
||||
|
||||
const idCounters = new Map<string, number>()
|
||||
const renamedXml = xml.replace(ID_ATTR_PATTERN, (match, id) => {
|
||||
if (!duplicateIds.has(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
|
||||
if (count === 0) return match // Keep first occurrence
|
||||
|
||||
// Rename subsequent occurrences (the id sits just before the closing quote)
|
||||
return `${match.slice(0, -id.length - 1)}${id}_dup${count}${match.slice(-1)}`
|
||||
})
|
||||
return { xml: renamedXml, renamed: duplicateIds.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function telling whether a position is inside a quoted attribute
|
||||
* value. Positions must be queried in increasing order: the scan resumes where
|
||||
* it stopped instead of starting over, which keeps large documents fast.
|
||||
*/
|
||||
function createQuoteTracker(str: string): (pos: number) => boolean {
|
||||
let i = 0
|
||||
let inQuote = false
|
||||
let quoteChar = ""
|
||||
return (pos: number) => {
|
||||
for (; i < pos && i < str.length; i++) {
|
||||
const c = str[i]
|
||||
if (inQuote) {
|
||||
if (c === quoteChar) inQuote = false
|
||||
} else if (c === '"' || c === "'") {
|
||||
// Only quotes that follow "=" open an attribute value
|
||||
let j = i - 1
|
||||
while (j >= 0 && /\s/.test(str[j])) j--
|
||||
if (j >= 0 && str[j] === "=") {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return inQuote
|
||||
}
|
||||
}
|
||||
|
||||
/** Check for tag mismatches using parsed tags */
|
||||
function checkTagMismatches(xml: string): string | null {
|
||||
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
|
||||
@@ -1088,13 +1175,19 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
// 3b. Fix malformed attribute values where " is used as delimiter instead of actual quotes
|
||||
// Pattern: attr="value" should become attr="value" (the " was meant to be the quote delimiter)
|
||||
// This commonly happens with dashPattern="1 1;"
|
||||
const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/
|
||||
if (malformedQuotePattern.test(fixed)) {
|
||||
// Replace =" with =" and trailing " before next attribute or tag end with "
|
||||
fixed = fixed.replace(
|
||||
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g,
|
||||
'$1="$2"',
|
||||
)
|
||||
// Matches inside another attribute value are kept: rich text labels like
|
||||
// value="<font color="#ff0000">..." are valid.
|
||||
const isInsideQuotesFor3b = createQuoteTracker(fixed)
|
||||
let malformedQuotesFixed = false
|
||||
fixed = fixed.replace(
|
||||
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g,
|
||||
(match: string, attr: string, value: string, offset: number) => {
|
||||
if (isInsideQuotesFor3b(offset)) return match
|
||||
malformedQuotesFixed = true
|
||||
return `${attr}="${value}"`
|
||||
},
|
||||
)
|
||||
if (malformedQuotesFixed) {
|
||||
fixes.push(
|
||||
'Fixed malformed attribute quotes (="..." to ="...")',
|
||||
)
|
||||
@@ -1108,9 +1201,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
}
|
||||
|
||||
// 3d. Fix missing space between attributes like vertex="1"parent="1"
|
||||
const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g
|
||||
// Requires name=" right after the quote, so the opening quote of a value
|
||||
// such as style="rounded=1;..." is not mistaken for a closing one.
|
||||
const missingSpacePattern = /"([a-zA-Z_:][\w:.-]*=")/g
|
||||
if (missingSpacePattern.test(fixed)) {
|
||||
fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2")
|
||||
fixed = fixed.replace(missingSpacePattern, '" $1')
|
||||
fixes.push("Added missing space between attributes")
|
||||
}
|
||||
|
||||
@@ -1240,32 +1335,13 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
"mxPoint",
|
||||
"Array",
|
||||
"Object",
|
||||
// Wrappers of cells with links, tooltips or custom data
|
||||
"object",
|
||||
"UserObject",
|
||||
"mxRectangle",
|
||||
])
|
||||
|
||||
// Helper: Check if a position is inside a quoted attribute value
|
||||
// by counting unescaped quotes before that position
|
||||
const isInsideQuotes = (str: string, pos: number): boolean => {
|
||||
let inQuote = false
|
||||
let quoteChar = ""
|
||||
for (let i = 0; i < pos && i < str.length; i++) {
|
||||
const c = str[i]
|
||||
if (inQuote) {
|
||||
if (c === quoteChar) inQuote = false
|
||||
} else if (c === '"' || c === "'") {
|
||||
// Check if this quote is part of an attribute (preceded by =)
|
||||
// Look back for = sign
|
||||
let j = i - 1
|
||||
while (j >= 0 && /\s/.test(str[j])) j--
|
||||
if (j >= 0 && str[j] === "=") {
|
||||
inQuote = true
|
||||
quoteChar = c
|
||||
}
|
||||
}
|
||||
}
|
||||
return inQuote
|
||||
}
|
||||
|
||||
const isInsideQuotesFor8c = createQuoteTracker(fixed)
|
||||
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||
let foreignMatch
|
||||
const foreignTags = new Set<string>()
|
||||
@@ -1280,7 +1356,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
// Skip if this is a valid draw.io tag
|
||||
if (validDrawioTags.has(tagName)) continue
|
||||
// Skip if this tag is inside a quoted attribute value
|
||||
if (isInsideQuotes(fixed, foreignMatch.index)) continue
|
||||
if (isInsideQuotesFor8c(foreignMatch.index)) continue
|
||||
|
||||
foreignTags.add(tagName)
|
||||
foreignTagPositions.push({
|
||||
@@ -1352,10 +1428,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
>()
|
||||
// Match full tags to detect self-closing by checking if ends with />
|
||||
const fullTagPattern = /<(\/?[a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
const isInsideQuotesFor10b = createQuoteTracker(fixed)
|
||||
let tagCountMatch
|
||||
while ((tagCountMatch = fullTagPattern.exec(fixed)) !== null) {
|
||||
// Skip tags inside quoted attribute values (e.g., value="<b>Title</b>")
|
||||
if (isInsideQuotes(fixed, tagCountMatch.index)) continue
|
||||
if (isInsideQuotesFor10b(tagCountMatch.index)) continue
|
||||
|
||||
const fullMatch = tagCountMatch[0] // e.g., "<mxCell .../>" or "</mxCell>"
|
||||
const tagPart = tagCountMatch[1] // e.g., "mxCell" or "/mxCell"
|
||||
@@ -1445,125 +1522,112 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
// 11. Fix nested mxCell by flattening
|
||||
// Pattern A: <mxCell id="X">...<mxCell id="X">...</mxCell></mxCell> (duplicate ID)
|
||||
// Pattern B: <mxCell id="X">...<mxCell id="Y">...</mxCell></mxCell> (different ID - true nesting)
|
||||
const lines = fixed.split("\n")
|
||||
let newLines: string[] = []
|
||||
let nestedFixed = 0
|
||||
let extraClosingToRemove = 0
|
||||
// These passes work line by line and would break valid cells written on a
|
||||
// single line, so each one runs only when cells are really nested.
|
||||
if (checkNestedMxCells(fixed)) {
|
||||
const lines = fixed.split("\n")
|
||||
const newLines: string[] = []
|
||||
let nestedFixed = 0
|
||||
let extraClosingToRemove = 0
|
||||
|
||||
// First pass: fix duplicate ID nesting (same as before)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const nextLine = lines[i + 1]
|
||||
// First pass: fix duplicate ID nesting (same as before)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const nextLine = lines[i + 1]
|
||||
|
||||
// Check if current line and next line are both mxCell opening tags with same ID
|
||||
if (
|
||||
nextLine &&
|
||||
/<mxCell\s/.test(line) &&
|
||||
/<mxCell\s/.test(nextLine) &&
|
||||
!line.includes("/>") &&
|
||||
!nextLine.includes("/>")
|
||||
) {
|
||||
const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
|
||||
const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
|
||||
// Check if current line and next line are both mxCell opening tags with same ID
|
||||
if (
|
||||
nextLine &&
|
||||
/<mxCell\s/.test(line) &&
|
||||
/<mxCell\s/.test(nextLine) &&
|
||||
!line.includes("/>") &&
|
||||
!nextLine.includes("/>")
|
||||
) {
|
||||
const id1 = line.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
|
||||
const id2 = nextLine.match(/\bid\s*=\s*["']([^"']+)["']/)?.[1]
|
||||
|
||||
if (id1 && id1 === id2) {
|
||||
nestedFixed++
|
||||
extraClosingToRemove++ // Need to remove one </mxCell> later
|
||||
continue // Skip this duplicate opening line
|
||||
if (id1 && id1 === id2) {
|
||||
nestedFixed++
|
||||
extraClosingToRemove++ // Need to remove one </mxCell> later
|
||||
continue // Skip this duplicate opening line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove extra </mxCell> if we have pending removals
|
||||
if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) {
|
||||
extraClosingToRemove--
|
||||
continue // Skip this closing tag
|
||||
}
|
||||
|
||||
newLines.push(line)
|
||||
}
|
||||
|
||||
if (nestedFixed > 0) {
|
||||
fixed = newLines.join("\n")
|
||||
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
|
||||
}
|
||||
|
||||
// Second pass: fix true nesting (different IDs)
|
||||
// Insert </mxCell> before nested child to close parent
|
||||
const lines2 = fixed.split("\n")
|
||||
newLines = []
|
||||
let trueNestedFixed = 0
|
||||
let cellDepth = 0
|
||||
let pendingCloseRemoval = 0
|
||||
|
||||
for (let i = 0; i < lines2.length; i++) {
|
||||
const line = lines2[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Track mxCell depth
|
||||
const isOpenCell = /<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
|
||||
const isCloseCell = trimmed === "</mxCell>"
|
||||
|
||||
if (isOpenCell) {
|
||||
if (cellDepth > 0) {
|
||||
// Found nested cell - insert closing tag for parent before this line
|
||||
const indent = line.match(/^(\s*)/)?.[1] || ""
|
||||
newLines.push(indent + "</mxCell>")
|
||||
trueNestedFixed++
|
||||
pendingCloseRemoval++ // Need to remove one </mxCell> later
|
||||
// Remove extra </mxCell> if we have pending removals
|
||||
if (extraClosingToRemove > 0 && /^\s*<\/mxCell>\s*$/.test(line)) {
|
||||
extraClosingToRemove--
|
||||
continue // Skip this closing tag
|
||||
}
|
||||
cellDepth = 1 // Reset to 1 since we just opened a new cell
|
||||
|
||||
newLines.push(line)
|
||||
} else if (isCloseCell) {
|
||||
if (pendingCloseRemoval > 0) {
|
||||
pendingCloseRemoval--
|
||||
// Skip this extra closing tag
|
||||
}
|
||||
|
||||
if (nestedFixed > 0) {
|
||||
fixed = newLines.join("\n")
|
||||
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (checkNestedMxCells(fixed)) {
|
||||
// Second pass: fix true nesting (different IDs)
|
||||
// Insert </mxCell> before nested child to close parent
|
||||
const lines2 = fixed.split("\n")
|
||||
const newLines: string[] = []
|
||||
let trueNestedFixed = 0
|
||||
let cellDepth = 0
|
||||
let pendingCloseRemoval = 0
|
||||
|
||||
for (let i = 0; i < lines2.length; i++) {
|
||||
const line = lines2[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Track mxCell depth
|
||||
const isOpenCell =
|
||||
/<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
|
||||
const isCloseCell = trimmed === "</mxCell>"
|
||||
|
||||
if (isOpenCell) {
|
||||
if (cellDepth > 0) {
|
||||
// Found nested cell - insert closing tag for parent before this line
|
||||
const indent = line.match(/^(\s*)/)?.[1] || ""
|
||||
newLines.push(indent + "</mxCell>")
|
||||
trueNestedFixed++
|
||||
pendingCloseRemoval++ // Need to remove one </mxCell> later
|
||||
}
|
||||
cellDepth = 1 // Reset to 1 since we just opened a new cell
|
||||
newLines.push(line)
|
||||
} else if (isCloseCell) {
|
||||
if (pendingCloseRemoval > 0) {
|
||||
pendingCloseRemoval--
|
||||
// Skip this extra closing tag
|
||||
} else {
|
||||
cellDepth = Math.max(0, cellDepth - 1)
|
||||
newLines.push(line)
|
||||
}
|
||||
} else {
|
||||
cellDepth = Math.max(0, cellDepth - 1)
|
||||
newLines.push(line)
|
||||
}
|
||||
} else {
|
||||
newLines.push(line)
|
||||
}
|
||||
|
||||
if (trueNestedFixed > 0) {
|
||||
fixed = newLines.join("\n")
|
||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (trueNestedFixed > 0) {
|
||||
fixed = newLines.join("\n")
|
||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||
// 12. Fix duplicate IDs by appending suffix, page by page (ids such as the
|
||||
// root cells "0" and "1" legitimately repeat across pages)
|
||||
let renamedIds = 0
|
||||
const renamePage = (page: string) => {
|
||||
const { xml: renamed, renamed: count } = renameDuplicateIds(page)
|
||||
renamedIds += count
|
||||
return renamed
|
||||
}
|
||||
|
||||
// 12. Fix duplicate IDs by appending suffix
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
|
||||
// First pass: find duplicates
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
|
||||
// Find which IDs are duplicated
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
|
||||
// Second pass: rename duplicates (keep first occurrence, rename others)
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
|
||||
if (count === 0) return match // Keep first occurrence
|
||||
|
||||
// Rename subsequent occurrences
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
})
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
fixed = /<diagram\b/.test(fixed)
|
||||
? fixed.replace(PAGE_PATTERN, renamePage)
|
||||
: renamePage(fixed)
|
||||
if (renamedIds > 0) {
|
||||
fixes.push(`Renamed ${renamedIds} duplicate ID(s)`)
|
||||
}
|
||||
|
||||
// 9. Fix empty id attributes by generating unique IDs
|
||||
@@ -1673,6 +1737,11 @@ export function validateAndFixXml(xml: string): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an xmlsvg export (SVG data URL) into uncompressed diagram XML.
|
||||
* Only the first page is returned; for the full multi-page document use the
|
||||
* autosaved chartXML instead.
|
||||
*/
|
||||
export function extractDiagramXML(xml_svg_string: string): string {
|
||||
try {
|
||||
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
|
||||
|
||||
Generated
+1847
-1602
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -44,7 +44,7 @@
|
||||
"@aws-sdk/client-dynamodb": "^3.957.0",
|
||||
"@aws-sdk/credential-providers": "^3.943.0",
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
"@formatjs/intl-localematcher": "^0.8.0",
|
||||
"@formatjs/intl-localematcher": "^0.9.0",
|
||||
"@langfuse/client": "^4.4.9",
|
||||
"@langfuse/otel": "^4.4.4",
|
||||
"@langfuse/tracing": "^4.4.9",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@opennextjs/cloudflare": "^1.17.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.0.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.216.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
||||
"@opentelemetry/sdk-trace-node": "^2.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||
"@biomejs/biome": "2.4.13",
|
||||
"@biomejs/biome": "2.5.7",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
@@ -129,7 +129,7 @@
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.28.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
|
||||
@@ -116,9 +116,14 @@ Use the standard MCP configuration with:
|
||||
|------|-------------|
|
||||
| `start_session` | Opens browser with real-time diagram preview |
|
||||
| `create_new_diagram` | Create a new diagram from XML (requires `xml` argument) |
|
||||
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
|
||||
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells) |
|
||||
| `get_diagram` | Get the current diagram XML |
|
||||
| `export_diagram` | Save diagram to a `.drawio` file |
|
||||
| `export_diagram` | Save diagram to a `.drawio`, `.png`, or `.svg` file |
|
||||
| `list_pages` | List every page (tab) with id, name, index, and cell count |
|
||||
| `add_page` | Append a new page without touching existing ones |
|
||||
| `rename_page` | Rename a page |
|
||||
| `delete_page` | Delete a page (refuses to delete the last one) |
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
Generated
+1533
-196
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@next-ai-drawio/mcp-server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -11,6 +11,8 @@
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -39,12 +41,14 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"linkedom": "^0.18.0",
|
||||
"open": "^11.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/**
|
||||
* 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 { getXmlSyntaxError } from "./dom.js"
|
||||
import { log } from "./logger.js"
|
||||
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
|
||||
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
@@ -20,26 +28,37 @@ export interface ApplyOperationsResult {
|
||||
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"
|
||||
|
||||
/** 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.
|
||||
* This replaces the text-matching approach with direct DOM manipulation.
|
||||
*
|
||||
* @param xmlContent - The full mxfile XML content
|
||||
* @param operations - Array of operations to apply
|
||||
* @returns Object with result XML and any errors
|
||||
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
|
||||
* a full <mxfile> with one or more <diagram> children.
|
||||
* @param operations - Array of operations to apply.
|
||||
* @param pageSelector - Optional page selector for multi-page docs. Defaults
|
||||
* to the first page.
|
||||
* @returns Object with result XML (same shape as input) and any per-op errors.
|
||||
*/
|
||||
export function applyDiagramOperations(
|
||||
xmlContent: string,
|
||||
operations: DiagramOperation[],
|
||||
pageSelector?: PageSelector,
|
||||
): ApplyOperationsResult {
|
||||
const errors: OperationError[] = []
|
||||
|
||||
// Parse the XML
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlContent, "text/xml")
|
||||
|
||||
// Check for parse errors
|
||||
const parseError = doc.querySelector("parsererror")
|
||||
// Check for syntax errors, then parse the XML
|
||||
const parseError = getXmlSyntaxError(xmlContent)
|
||||
if (parseError) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
@@ -47,33 +66,90 @@ export function applyDiagramOperations(
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `XML parse error: ${parseError.textContent}`,
|
||||
message: `XML parse error: ${parseError}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlContent, "text/xml")
|
||||
|
||||
// Find the root element (inside mxGraphModel)
|
||||
const root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
// Locate the <root> element to operate on.
|
||||
//
|
||||
// - For <mxfile> input: resolve the page via pageSelector, then dive into
|
||||
// its <root>. This scopes querySelectorAll calls below to one page so
|
||||
// cells on other pages aren't accidentally matched.
|
||||
// - For bare <mxGraphModel> input: use the document's only <root>.
|
||||
let root: Element | null
|
||||
if (doc.documentElement?.tagName === "mxfile") {
|
||||
const found = findPageElement(doc as unknown as Document, pageSelector)
|
||||
if (!found) {
|
||||
const selDesc = hasPageSelector(pageSelector)
|
||||
? ` matching selector ${JSON.stringify(pageSelector)}`
|
||||
: ""
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page${selDesc} not found in <mxfile>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
root = found.element.querySelector("root")
|
||||
if (!root) {
|
||||
const pageId =
|
||||
found.element.getAttribute("id") || `(index ${found.index})`
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page "${pageId}" has no <root> element`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hasPageSelector(pageSelector)) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message:
|
||||
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of cell IDs to elements
|
||||
// Build a map of cell IDs to elements (scoped to the resolved page).
|
||||
const cellMap = new Map<string, Element>()
|
||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||
root.querySelectorAll(CELL_SELECTOR).forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
if (id) 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
|
||||
for (const op of operations) {
|
||||
@@ -102,7 +178,7 @@ export function applyDiagramOperations(
|
||||
`<wrapper>${op.new_xml}</wrapper>`,
|
||||
"text/xml",
|
||||
)
|
||||
const newCell = newDoc.querySelector("mxCell")
|
||||
const newCell = newDoc.querySelector(CELL_SELECTOR)
|
||||
if (!newCell) {
|
||||
errors.push({
|
||||
type: "update",
|
||||
@@ -154,7 +230,7 @@ export function applyDiagramOperations(
|
||||
`<wrapper>${op.new_xml}</wrapper>`,
|
||||
"text/xml",
|
||||
)
|
||||
const newCell = newDoc.querySelector("mxCell")
|
||||
const newCell = newDoc.querySelector(CELL_SELECTOR)
|
||||
if (!newCell) {
|
||||
errors.push({
|
||||
type: "add",
|
||||
@@ -194,8 +270,15 @@ export function applyDiagramOperations(
|
||||
|
||||
const existingCell = cellMap.get(op.cell_id)
|
||||
if (!existingCell) {
|
||||
// Cell not found - might have been cascade-deleted by a previous operation
|
||||
// Skip silently instead of erroring (AI may redundantly list children/edges)
|
||||
// Skip cells already cascade-deleted by a previous operation
|
||||
// (AI may redundantly list children/edges); warn otherwise
|
||||
if (!deletedIds.has(op.cell_id)) {
|
||||
errors.push({
|
||||
type: "delete",
|
||||
cellId: op.cell_id,
|
||||
message: `Cell with id="${op.cell_id}" not found`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -208,15 +291,17 @@ export function applyDiagramOperations(
|
||||
cellsToDelete.add(cellId)
|
||||
|
||||
// Find children (cells where parent === cellId)
|
||||
const children = root.querySelectorAll(
|
||||
`mxCell[parent="${cellId}"]`,
|
||||
)
|
||||
children.forEach((child) => {
|
||||
const childId = child.getAttribute("id")
|
||||
if (childId && childId !== "0" && childId !== "1") {
|
||||
// cellMap only holds this page's cells, so other pages' cells
|
||||
// with the same parent id (notably "1") are never touched.
|
||||
for (const [childId, child] of cellMap) {
|
||||
if (
|
||||
childId !== "0" &&
|
||||
childId !== "1" &&
|
||||
cellAttr(child, "parent") === cellId
|
||||
) {
|
||||
collectDescendants(childId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the target cell and all its descendants
|
||||
@@ -225,23 +310,23 @@ export function applyDiagramOperations(
|
||||
// Find edges referencing any of the cells to be deleted
|
||||
// Also recursively collect children of those edges (e.g., edge labels)
|
||||
for (const cellId of cellsToDelete) {
|
||||
const referencingEdges = root.querySelectorAll(
|
||||
`mxCell[source="${cellId}"], mxCell[target="${cellId}"]`,
|
||||
)
|
||||
referencingEdges.forEach((edge) => {
|
||||
const edgeId = edge.getAttribute("id")
|
||||
for (const [edgeId, edge] of cellMap) {
|
||||
// Protect root cells from being added via edge references
|
||||
if (edgeId && edgeId !== "0" && edgeId !== "1") {
|
||||
if (edgeId === "0" || edgeId === "1") continue
|
||||
if (
|
||||
cellAttr(edge, "source") === cellId ||
|
||||
cellAttr(edge, "target") === cellId
|
||||
) {
|
||||
// Recurse to collect edge's children (like labels)
|
||||
collectDescendants(edgeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Log what will be deleted
|
||||
// Log what will be deleted (stderr: stdout carries JSON-RPC)
|
||||
if (cellsToDelete.size > 1) {
|
||||
console.log(
|
||||
`[applyDiagramOperations] Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
|
||||
log.debug(
|
||||
`Cascade delete "${op.cell_id}" → deleting ${cellsToDelete.size} cells: ${Array.from(cellsToDelete).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -252,6 +337,7 @@ export function applyDiagramOperations(
|
||||
cell.parentNode?.removeChild(cell)
|
||||
cellMap.delete(cellId)
|
||||
}
|
||||
deletedIds.add(cellId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* DOM setup for Node.
|
||||
*
|
||||
* linkedom gives us a DOM with querySelector, but it is lenient: it never
|
||||
* reports syntax errors (no <parsererror>), and its serializer writes raw
|
||||
* newlines inside attribute values, which the browser reads back as spaces.
|
||||
* saxes, a strict XML parser, checks well-formedness the way draw.io's
|
||||
* DOMParser will, and serializeXml writes attribute values safely.
|
||||
*/
|
||||
import { DOMParser } from "linkedom"
|
||||
import { SaxesParser } from "saxes"
|
||||
|
||||
/**
|
||||
* Returns the first XML syntax error as "line:column: message", or null if
|
||||
* the XML is well-formed. Surrounding whitespace is ignored because every
|
||||
* caller trims before the XML reaches the browser.
|
||||
*/
|
||||
export function getXmlSyntaxError(xml: string): string | null {
|
||||
let error: string | null = null
|
||||
const parser = new SaxesParser()
|
||||
parser.on("error", (err) => {
|
||||
error ??= err.message
|
||||
})
|
||||
parser.write(xml.trim()).close()
|
||||
return error
|
||||
}
|
||||
|
||||
const ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"\t": "	",
|
||||
"\n": "
",
|
||||
"\r": "
",
|
||||
}
|
||||
const escapeChars = (text: string, chars: RegExp) =>
|
||||
text.replace(chars, (c) => ESCAPES[c])
|
||||
|
||||
/**
|
||||
* Serialize a linkedom node as XML. Attribute values escape tabs and line
|
||||
* breaks too, so multi-line labels (value="a
b") survive a round trip.
|
||||
*/
|
||||
export function serializeXml(node: Node): string {
|
||||
switch (node.nodeType) {
|
||||
case 9: {
|
||||
// Document
|
||||
const root = (node as Document).documentElement
|
||||
return root ? serializeXml(root) : ""
|
||||
}
|
||||
case 1: {
|
||||
// Element
|
||||
const el = node as Element
|
||||
let out = `<${el.tagName}`
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
out += ` ${attr.name}="${escapeChars(attr.value, /[&<>"\t\n\r]/g)}"`
|
||||
}
|
||||
if (el.childNodes.length === 0) return `${out}/>`
|
||||
out += ">"
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
out += serializeXml(child)
|
||||
}
|
||||
return `${out}</${el.tagName}>`
|
||||
}
|
||||
case 3:
|
||||
// Text
|
||||
return escapeChars(node.textContent ?? "", /[&<>]/g)
|
||||
case 4:
|
||||
// CDATA
|
||||
return `<![CDATA[${node.textContent ?? ""}]]>`
|
||||
case 8:
|
||||
// Comment
|
||||
return `<!--${node.textContent ?? ""}-->`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: Node): string {
|
||||
return serializeXml(node)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the DOMParser and XMLSerializer globals the XML helpers use. */
|
||||
export function installDomPolyfill(): void {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Workflow gate for edit_diagram.
|
||||
*
|
||||
* Instead of a wall-clock timeout (the old 30s rule rejected slow-but-correct
|
||||
* clients, see #885), we compare content: `lastSeenXml` is the state-store
|
||||
* XML the model last saw (get_diagram) or wrote itself (create_new_diagram /
|
||||
* edit_diagram / page CRUD). The store only changes on server writes or
|
||||
* browser pushes (user autosave, sync exports), so if the live store still
|
||||
* matches `lastSeenXml`, nothing happened that the model hasn't seen — the
|
||||
* edit is safe no matter how much time passed.
|
||||
*
|
||||
* "Matches" is structural, not byte-for-byte: draw.io re-serialises the
|
||||
* document when it pushes state back (different attribute order, pretty-
|
||||
* printed whitespace, regenerated diagram ids, viewport attributes like
|
||||
* dx/dy/pageWidth on <mxGraphModel>, a different mxfile host). None of that
|
||||
* is a user edit, so the fingerprint keeps only what a user can actually
|
||||
* change: the set of pages, each page's name, and each page's cell tree
|
||||
* (tags + sorted attributes + text). Byte equality is kept as a fast path.
|
||||
*/
|
||||
import { isMxGraphModel, normalizeToMxfile, parseMxfile } from "./pages.js"
|
||||
|
||||
export type EditGateResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: "no-context" | "stale" }
|
||||
|
||||
/**
|
||||
* Canonical serialisation of an element subtree: tag + attributes sorted by
|
||||
* name + child elements in order + non-whitespace text. Whitespace-only text
|
||||
* nodes (pretty-printing) are dropped.
|
||||
*/
|
||||
function canonicalizeElement(el: Element): string {
|
||||
const attrs = Array.from(el.attributes)
|
||||
.map((a) => `${a.name}=${JSON.stringify(a.value)}`)
|
||||
.sort()
|
||||
.join(" ")
|
||||
let children = ""
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child.nodeType === 1) {
|
||||
children += canonicalizeElement(child as Element)
|
||||
} else if (child.nodeType === 3 || child.nodeType === 4) {
|
||||
const text = (child.textContent ?? "").trim()
|
||||
if (text) children += JSON.stringify(text)
|
||||
}
|
||||
}
|
||||
return `<${el.tagName} ${attrs}>${children}</${el.tagName}>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural fingerprint of a diagram document: page names + each page's
|
||||
* <root> subtree, ignoring everything draw.io rewrites on re-serialisation
|
||||
* (mxfile/mxGraphModel attributes, diagram ids, formatting). A bare
|
||||
* <mxGraphModel> fingerprints identically to its single-page mxfile wrapping.
|
||||
* Unparseable input falls back to the trimmed raw string, degrading to the
|
||||
* plain string comparison.
|
||||
*
|
||||
* `includeNames=false` drops page names from the fingerprint — used when the
|
||||
* other side of a comparison is a bare <mxGraphModel>, which carries no page
|
||||
* name at all (normalizeToMxfile would invent "Page-1", falsely mismatching
|
||||
* any real page name).
|
||||
*/
|
||||
export function contentFingerprint(xml: string, includeNames = true): string {
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
const doc = normalized ? parseMxfile(normalized) : null
|
||||
if (!doc) return xml.trim()
|
||||
const pages: string[] = []
|
||||
doc.querySelectorAll("diagram").forEach((d) => {
|
||||
const name = includeNames ? d.getAttribute("name") || "" : ""
|
||||
const root = d.querySelector("root")
|
||||
// No <root> means the page content is not plain XML (e.g. draw.io's
|
||||
// compressed format) — fingerprint the raw text instead.
|
||||
const body = root
|
||||
? canonicalizeElement(root)
|
||||
: (d.textContent || "").trim()
|
||||
pages.push(`${name}=${body}`)
|
||||
})
|
||||
return pages.join("\n")
|
||||
}
|
||||
|
||||
export function checkEditGate(
|
||||
lastSeenXml: string,
|
||||
liveXml: string,
|
||||
): EditGateResult {
|
||||
// Model never fetched or produced any diagram state in this session.
|
||||
if (!lastSeenXml) return { ok: false, reason: "no-context" }
|
||||
// Browser state moved since the model last looked (e.g. manual user
|
||||
// edits): force a re-fetch so update/delete operations don't build on
|
||||
// stale cell contents. An empty liveXml means the store has no entry to
|
||||
// compare against, so there is nothing newer to have missed.
|
||||
if (liveXml && liveXml !== lastSeenXml) {
|
||||
// A bare <mxGraphModel> on either side carries no page name, so
|
||||
// comparing names would mismatch against anything not called
|
||||
// "Page-1". Compare cell trees only in that case.
|
||||
const includeNames =
|
||||
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
|
||||
if (
|
||||
contentFingerprint(liveXml, includeNames) !==
|
||||
contentFingerprint(lastSeenXml, includeNames)
|
||||
)
|
||||
return { ok: false, reason: "stale" }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -6,7 +6,15 @@
|
||||
import { log } from "./logger.js"
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
const historyStore = new Map<string, Array<{ xml: string; svg: string }>>()
|
||||
|
||||
interface HistoryEntry {
|
||||
id: number // Stable across shifts of the circular buffer
|
||||
xml: string
|
||||
svg: string
|
||||
}
|
||||
|
||||
let nextEntryId = 0
|
||||
const historyStore = new Map<string, HistoryEntry[]>()
|
||||
|
||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
let history = historyStore.get(sessionId)
|
||||
@@ -21,7 +29,7 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
history.push({ xml, svg })
|
||||
history.push({ id: nextEntryId++, xml, svg })
|
||||
|
||||
// Circular buffer
|
||||
if (history.length > MAX_HISTORY) {
|
||||
@@ -32,18 +40,16 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||
return history.length - 1
|
||||
}
|
||||
|
||||
export function getHistory(
|
||||
sessionId: string,
|
||||
): Array<{ xml: string; svg: string }> {
|
||||
export function getHistory(sessionId: string): HistoryEntry[] {
|
||||
return historyStore.get(sessionId) || []
|
||||
}
|
||||
|
||||
/** Look up an entry by its id; the array index shifts as old entries drop. */
|
||||
export function getHistoryEntry(
|
||||
sessionId: string,
|
||||
index: number,
|
||||
): { xml: string; svg: string } | undefined {
|
||||
const history = historyStore.get(sessionId)
|
||||
return history?.[index]
|
||||
id: number,
|
||||
): HistoryEntry | undefined {
|
||||
return historyStore.get(sessionId)?.find((entry) => entry.id === id)
|
||||
}
|
||||
|
||||
export function clearHistory(sessionId: string): void {
|
||||
|
||||
@@ -12,7 +12,9 @@ function readBody(
|
||||
res: http.ServerResponse,
|
||||
cb: (body: string) => void,
|
||||
): void {
|
||||
let body = ""
|
||||
// Decode once at the end: a multi-byte UTF-8 character can be split
|
||||
// across two chunks.
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
@@ -22,9 +24,9 @@ function readBody(
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
body += chunk
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on("end", () => cb(body))
|
||||
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
|
||||
}
|
||||
|
||||
import {
|
||||
@@ -62,9 +64,11 @@ function normalizeUrl(url: string): string {
|
||||
return url.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function isLikelyMcpSessionId(sessionId: string): boolean {
|
||||
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
|
||||
return sessionId.startsWith("mcp-") && sessionId.length <= 128
|
||||
// Session ids look like "mcp-<base36 time>-<base36 random>" (start_session).
|
||||
// Only this charset is accepted, because ids are written into the page's
|
||||
// HTML and script and into the redirect Location header.
|
||||
function isValidSessionId(sessionId: string): boolean {
|
||||
return /^mcp-[a-z0-9-]{1,64}$/.test(sessionId)
|
||||
}
|
||||
|
||||
// Find the most recent active session (for auto-redirect when no sessionId provided)
|
||||
@@ -80,7 +84,7 @@ function getMostRecentSessionId(): string | null {
|
||||
|
||||
function ensureSessionStateInitialized(sessionId: string): void {
|
||||
if (!sessionId) return
|
||||
if (!isLikelyMcpSessionId(sessionId)) return
|
||||
if (!isValidSessionId(sessionId)) return
|
||||
if (stateStore.has(sessionId)) return
|
||||
|
||||
setState(sessionId, DEFAULT_DIAGRAM_XML)
|
||||
@@ -89,10 +93,15 @@ function ensureSessionStateInitialized(sessionId: string): void {
|
||||
interface SessionState {
|
||||
xml: string
|
||||
version: number
|
||||
// Version of the last write the browser did not make itself (AI edit,
|
||||
// restore). A browser push based on an older version is rejected.
|
||||
serverVersion?: number
|
||||
lastUpdated: Date
|
||||
lastPolled?: number // Last browser poll; an open tab keeps the session alive
|
||||
svg?: string // Cached SVG from last browser save
|
||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
||||
exportData?: string // Base64/SVG data returned by browser after export
|
||||
}
|
||||
|
||||
@@ -107,22 +116,54 @@ export function getState(sessionId: string): SessionState | undefined {
|
||||
return stateStore.get(sessionId)
|
||||
}
|
||||
|
||||
export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||
export function setState(
|
||||
sessionId: string,
|
||||
xml: string,
|
||||
svg?: string,
|
||||
fromBrowser = false,
|
||||
): number {
|
||||
const existing = stateStore.get(sessionId)
|
||||
const newVersion = (existing?.version || 0) + 1
|
||||
stateStore.set(sessionId, {
|
||||
xml,
|
||||
version: newVersion,
|
||||
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
||||
lastUpdated: new Date(),
|
||||
lastPolled: existing?.lastPolled,
|
||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||
exportXml: existing?.exportXml, // Preserve pending projection
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
return newVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the browser bridge to export the current diagram as png/svg.
|
||||
*
|
||||
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
||||
* first, waits for draw.io's own load event, exports, then reloads the
|
||||
* session's real document — so a page-targeted export never mutates the
|
||||
* canonical session state and needs no fixed-delay guessing on the server.
|
||||
*
|
||||
* Returns false when the session is unknown. Callers should then poll
|
||||
* `getState(sessionId)?.exportData` for the result.
|
||||
*/
|
||||
export function requestExport(
|
||||
sessionId: string,
|
||||
format: "png" | "svg",
|
||||
projectionXml?: string,
|
||||
): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (!state) return false
|
||||
state.exportData = undefined
|
||||
state.exportXml = projectionXml
|
||||
state.exportFormat = format
|
||||
return true
|
||||
}
|
||||
|
||||
export function requestSync(sessionId: string): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) {
|
||||
@@ -196,7 +237,11 @@ export function stopHttpServer(): void {
|
||||
function cleanupExpiredSessions(): void {
|
||||
const now = Date.now()
|
||||
for (const [sessionId, state] of stateStore) {
|
||||
if (now - state.lastUpdated.getTime() > SESSION_TTL) {
|
||||
const lastActive = Math.max(
|
||||
state.lastUpdated.getTime(),
|
||||
state.lastPolled ?? 0,
|
||||
)
|
||||
if (now - lastActive > SESSION_TTL) {
|
||||
stateStore.delete(sessionId)
|
||||
clearHistory(sessionId)
|
||||
log.info(`Cleaned up expired session: ${sessionId}`)
|
||||
@@ -219,7 +264,48 @@ function handleRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
const url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||
// A bad request must never take down the MCP process
|
||||
try {
|
||||
routeRequest(req, res)
|
||||
} catch (err) {
|
||||
log.error("HTTP request failed:", err)
|
||||
if (!res.headersSent) res.writeHead(500)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
// Serve only requests addressed to localhost, sent by a localhost page or by
|
||||
// a non-browser client (no Origin header). This blocks DNS rebinding and
|
||||
// scripts on other websites.
|
||||
function isLocalRequest(req: http.IncomingMessage): boolean {
|
||||
const isLocalHost = (host: string) =>
|
||||
/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host)
|
||||
const origin = req.headers.origin
|
||||
return (
|
||||
isLocalHost(req.headers.host ?? "") &&
|
||||
(origin === undefined || isLocalHost(origin.replace(/^http:\/\//, "")))
|
||||
)
|
||||
}
|
||||
|
||||
function routeRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
): void {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(req.url || "/", `http://localhost:${serverPort}`)
|
||||
} catch {
|
||||
// e.g. "//" is not a valid URL path
|
||||
res.writeHead(400)
|
||||
res.end("Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLocalRequest(req)) {
|
||||
res.writeHead(403)
|
||||
res.end("Forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
const requestOrigin = req.headers.origin
|
||||
if (requestOrigin === `http://localhost:${serverPort}`) {
|
||||
@@ -236,12 +322,19 @@ function handleRequest(
|
||||
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
const sessionId = url.searchParams.get("mcp") || ""
|
||||
if (sessionId && !isValidSessionId(sessionId)) {
|
||||
res.writeHead(400)
|
||||
res.end("Invalid session id")
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-redirect to most recent session if no sessionId provided
|
||||
if (!sessionId) {
|
||||
const recentSessionId = getMostRecentSessionId()
|
||||
if (recentSessionId) {
|
||||
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
|
||||
res.writeHead(302, {
|
||||
Location: `/?mcp=${encodeURIComponent(recentSessionId)}`,
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -279,6 +372,9 @@ function handleStateApi(
|
||||
}
|
||||
ensureSessionStateInitialized(sessionId)
|
||||
const state = stateStore.get(sessionId)
|
||||
// Polling counts as activity, so a session stays alive while its
|
||||
// tab is open
|
||||
if (state) state.lastPolled = Date.now()
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
@@ -286,6 +382,7 @@ function handleStateApi(
|
||||
version: state?.version || 0,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
exportXml: state?.exportXml || null,
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
@@ -293,9 +390,11 @@ function handleStateApi(
|
||||
try {
|
||||
const data = JSON.parse(body)
|
||||
const { sessionId } = data
|
||||
if (!sessionId) {
|
||||
if (!sessionId || !isValidSessionId(sessionId)) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "sessionId required" }))
|
||||
res.end(
|
||||
JSON.stringify({ error: "valid sessionId required" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,6 +404,7 @@ function handleStateApi(
|
||||
if (state) {
|
||||
state.exportData = data.exportData
|
||||
state.exportFormat = undefined
|
||||
state.exportXml = undefined
|
||||
log.debug(
|
||||
`Export data received for session=${sessionId}`,
|
||||
)
|
||||
@@ -314,7 +414,25 @@ function handleStateApi(
|
||||
return
|
||||
}
|
||||
|
||||
const version = setState(sessionId, data.xml, data.svg)
|
||||
// The browser edited a version older than the latest AI write
|
||||
// (it has not loaded that write yet). Keep the AI write; the
|
||||
// browser loads it on its next poll.
|
||||
const current = stateStore.get(sessionId)
|
||||
if (
|
||||
typeof data.baseVersion === "number" &&
|
||||
data.baseVersion < (current?.serverVersion ?? 0)
|
||||
) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Diagram changed on the server",
|
||||
version: current?.version,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const version = setState(sessionId, data.xml, data.svg, true)
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, version }))
|
||||
} catch {
|
||||
@@ -350,7 +468,11 @@ function handleHistoryApi(
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
entries: history.map((entry, i) => ({ index: i, svg: entry.svg })),
|
||||
entries: history.map((entry, i) => ({
|
||||
index: i,
|
||||
id: entry.id,
|
||||
svg: entry.svg,
|
||||
})),
|
||||
count: history.length,
|
||||
}),
|
||||
)
|
||||
@@ -368,16 +490,14 @@ function handleRestoreApi(
|
||||
|
||||
readBody(req, res, (body) => {
|
||||
try {
|
||||
const { sessionId, index } = JSON.parse(body)
|
||||
if (!sessionId || index === undefined) {
|
||||
const { sessionId, id } = JSON.parse(body)
|
||||
if (!sessionId || typeof id !== "number") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({ error: "sessionId and index required" }),
|
||||
)
|
||||
res.end(JSON.stringify({ error: "sessionId and id required" }))
|
||||
return
|
||||
}
|
||||
|
||||
const entry = getHistoryEntry(sessionId, index)
|
||||
const entry = getHistoryEntry(sessionId, id)
|
||||
if (!entry) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "Entry not found" }))
|
||||
@@ -387,7 +507,7 @@ function handleRestoreApi(
|
||||
const newVersion = setState(sessionId, entry.xml)
|
||||
addHistory(sessionId, entry.xml, entry.svg)
|
||||
|
||||
log.info(`Restored session ${sessionId} to index ${index}`)
|
||||
log.info(`Restored session ${sessionId} to history entry ${id}`)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ success: true, newVersion }))
|
||||
@@ -669,12 +789,15 @@ function getHtmlPage(sessionId: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const sessionId = "${sessionId}";
|
||||
const sessionId = ${JSON.stringify(sessionId).replace(/</g, "\\u003c")};
|
||||
const iframe = document.getElementById('drawio');
|
||||
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
|
||||
let pendingSvgExport = null;
|
||||
let pendingSvgBase = 0; // version the pending autosave was based on
|
||||
let pendingAiSvg = false;
|
||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
||||
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||
@@ -684,18 +807,33 @@ function getHtmlPage(sessionId: string): string {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||
// Request SVG export, then push state with SVG
|
||||
// Ignore autosave while a single-page projection is on screen
|
||||
// for a page-targeted export — otherwise we'd push the
|
||||
// transient projection back as the canonical session state.
|
||||
if (projectionExportActive) return;
|
||||
// Request SVG export, then push state with SVG. Remember the
|
||||
// version this edit is based on, so the server can reject it
|
||||
// if the AI wrote a newer version that is not loaded yet.
|
||||
pendingSvgExport = msg.xml;
|
||||
pendingSvgBase = currentVersion;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
// Fallback if export doesn't respond
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, ''); pendingSvgExport = null; } }, 2000);
|
||||
setTimeout(() => { if (pendingSvgExport === msg.xml) { pushState(msg.xml, '', pendingSvgBase); pendingSvgExport = null; } }, 2000);
|
||||
} else if (msg.event === 'export' && msg.format === 'xml') {
|
||||
// Sync export requested by the server (get_diagram).
|
||||
// draw.io returns the XML in msg.xml, with no msg.data.
|
||||
if (pendingSyncExport && msg.xml) {
|
||||
pendingSyncExport = false;
|
||||
pushState(msg.xml, '');
|
||||
}
|
||||
} else if (msg.event === 'export' && msg.data) {
|
||||
// Handle MCP server export request (png/svg)
|
||||
// Verify the response matches the requested format to avoid capturing
|
||||
// unrelated exports (autosave SVG, sync XML)
|
||||
if (pendingMcpExport) {
|
||||
// Handle MCP server export request (png/svg). fireExport tags
|
||||
// the request with mcpExport and draw.io echoes the request
|
||||
// back in msg.message, which tells it apart from autosave and
|
||||
// preview SVG exports.
|
||||
if (msg.message && msg.message.mcpExport) {
|
||||
const d = msg.data;
|
||||
const isPng = pendingMcpExport === 'png' && (d.startsWith('data:image/png') || (typeof d === 'string' && d.length > 100 && !d.startsWith('<')));
|
||||
const isPng = pendingMcpExport === 'png' && d.startsWith('data:image/png');
|
||||
const isSvg = pendingMcpExport === 'svg' && (d.startsWith('data:image/svg') || d.startsWith('<svg'));
|
||||
if (isPng || isSvg) {
|
||||
pendingMcpExport = null;
|
||||
@@ -704,8 +842,11 @@ function getHtmlPage(sessionId: string): string {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d })
|
||||
}).catch(() => {});
|
||||
return;
|
||||
// Page-targeted export: restore the user's real
|
||||
// multi-page document now that we have the image.
|
||||
restoreFromProjection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||
@@ -724,19 +865,13 @@ function getHtmlPage(sessionId: string): string {
|
||||
saveConfirmBtn.textContent = 'Save';
|
||||
return;
|
||||
}
|
||||
// Handle sync export (XML format) - server requested fresh state
|
||||
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
|
||||
pendingSyncExport = false;
|
||||
pushState(msg.data, '');
|
||||
return;
|
||||
}
|
||||
// Handle SVG export
|
||||
let svg = msg.data;
|
||||
if (!svg.startsWith('data:')) svg = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||
if (pendingSvgExport) {
|
||||
const xml = pendingSvgExport;
|
||||
pendingSvgExport = null;
|
||||
pushState(xml, svg);
|
||||
pushState(xml, svg, pendingSvgBase);
|
||||
} else if (pendingAiSvg) {
|
||||
pendingAiSvg = false;
|
||||
fetch('/api/history-svg', {
|
||||
@@ -761,15 +896,33 @@ function getHtmlPage(sessionId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushState(xml, svg = '') {
|
||||
// Restore the user's real document after a page-targeted projection
|
||||
// export. If we never captured one (lastXml was null at projection
|
||||
// start), fall back to forcing a reload from the server on the next
|
||||
// poll by rewinding currentVersion — never leave the iframe stuck on
|
||||
// the transient projection.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
projectionExportActive = false;
|
||||
if (projectionRestoreXml) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
|
||||
projectionRestoreXml = null;
|
||||
} else {
|
||||
currentVersion = -1; // force the next poll to reload from server
|
||||
}
|
||||
}
|
||||
|
||||
async function pushState(xml, svg = '', baseVersion = currentVersion) {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const r = await fetch('/api/state', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, xml, svg })
|
||||
body: JSON.stringify({ sessionId, xml, svg, baseVersion })
|
||||
});
|
||||
if (r.ok) { const d = await r.json(); currentVersion = d.version; lastXml = xml; }
|
||||
// 409: the AI wrote a newer version; load it now
|
||||
else if (r.status === 409) poll();
|
||||
} catch (e) { console.error('Push failed:', e); }
|
||||
}
|
||||
|
||||
@@ -777,29 +930,72 @@ function getHtmlPage(sessionId: string): string {
|
||||
|
||||
async function poll() {
|
||||
if (!sessionId) return;
|
||||
const knownVersion = currentVersion;
|
||||
try {
|
||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
// Handle sync request - server needs fresh state
|
||||
if (s.syncRequested && !pendingSyncExport) {
|
||||
// Handle sync request - server needs fresh state. Reset after a
|
||||
// while in case draw.io never answers, so later syncs still run.
|
||||
if (s.syncRequested && !pendingSyncExport && isReady) {
|
||||
pendingSyncExport = true;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
setTimeout(() => { pendingSyncExport = false; }, 5000);
|
||||
}
|
||||
// Load new diagram from server (before export, so we export latest)
|
||||
if (s.version > currentVersion && s.xml) {
|
||||
// The server lost this session (e.g. it expired) and rebuilt it
|
||||
// with a blank diagram: push back what the browser shows.
|
||||
if (s.version < knownVersion && lastXml) {
|
||||
pushState(lastXml);
|
||||
}
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, skip the reload
|
||||
// so it doesn't fight the projection — and leave currentVersion
|
||||
// unadvanced so this bump is re-detected and applied once the
|
||||
// real document is restored.
|
||||
if (s.version > currentVersion && s.xml && !projectionExportActive) {
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
}
|
||||
// Handle export request from MCP server (png/svg) - after version update
|
||||
// Handle export request from MCP server (png/svg).
|
||||
//
|
||||
// Plain export: capture whatever tab is currently displayed.
|
||||
//
|
||||
// Page-targeted export: the server sends a single-page <mxfile>
|
||||
// projection in s.exportXml. We load it into the iframe, let
|
||||
// draw.io render it, export, then reload the user's real
|
||||
// document — all browser-side. The canonical session state is
|
||||
// never mutated, so there is no server-side restore race and no
|
||||
// dependence on poll timing. autosave is suppressed while the
|
||||
// projection is showing (see projectionExportActive guard).
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const exportOpts = s.exportFormat === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
// Timeout: reset if draw.io never responds
|
||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||
const fireExport = () => {
|
||||
// mcpExport is echoed back in msg.message (see the handler)
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2, mcpExport: true }
|
||||
: { action: 'export', format: 'svg', mcpExport: true };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
// Stash the real document so we can restore after export.
|
||||
projectionRestoreXml = lastXml;
|
||||
projectionExportActive = true;
|
||||
// Load the projection without touching lastXml/server state.
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
||||
// Let draw.io render the loaded page before exporting
|
||||
// (same proven settle delay as the AI-preview path).
|
||||
setTimeout(fireExport, 600);
|
||||
} else {
|
||||
fireExport();
|
||||
}
|
||||
// Timeout: reset if draw.io never responds, and restore the
|
||||
// real document if a projection was left showing.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport) {
|
||||
pendingMcpExport = null;
|
||||
restoreFromProjection();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -839,7 +1035,11 @@ function getHtmlPage(sessionId: string): string {
|
||||
saveConfirmBtn.textContent = 'Exporting...';
|
||||
|
||||
if (format === 'drawio') {
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
|
||||
// session.xml is canonically <mxfile> after the multi-page refactor,
|
||||
// so no wrapper injection is needed. The legacy fallback below
|
||||
// remains only for documents that somehow slipped past
|
||||
// normalisation (e.g. an older session loaded from external state).
|
||||
let xmlData = lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
@@ -871,7 +1071,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
const historyEmpty = document.getElementById('history-empty');
|
||||
const restoreBtn = document.getElementById('restore-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
let historyData = [], selectedIdx = null;
|
||||
let historyData = [], selectedId = null;
|
||||
|
||||
historyBtn.onclick = async () => {
|
||||
if (!sessionId) return;
|
||||
@@ -886,7 +1086,7 @@ function getHtmlPage(sessionId: string): string {
|
||||
historyModal.classList.add('open');
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedIdx = null; restoreBtn.disabled = true; };
|
||||
cancelBtn.onclick = () => { historyModal.classList.remove('open'); selectedId = null; restoreBtn.disabled = true; };
|
||||
historyModal.onclick = (e) => { if (e.target === historyModal) cancelBtn.onclick(); };
|
||||
|
||||
function renderHistory() {
|
||||
@@ -898,30 +1098,30 @@ function getHtmlPage(sessionId: string): string {
|
||||
historyGrid.style.display = 'grid';
|
||||
historyEmpty.style.display = 'none';
|
||||
historyGrid.innerHTML = historyData.map((e, i) => \`
|
||||
<div class="history-item" data-idx="\${e.index}">
|
||||
<div class="history-item" data-id="\${e.id}">
|
||||
<div class="thumb">\${e.svg ? \`<img src="\${e.svg}">\` : '#' + e.index}</div>
|
||||
<div class="label">#\${e.index}</div>
|
||||
</div>
|
||||
\`).join('');
|
||||
historyGrid.querySelectorAll('.history-item').forEach(item => {
|
||||
item.onclick = () => {
|
||||
const idx = parseInt(item.dataset.idx);
|
||||
if (selectedIdx === idx) { selectedIdx = null; restoreBtn.disabled = true; }
|
||||
else { selectedIdx = idx; restoreBtn.disabled = false; }
|
||||
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.idx) === selectedIdx));
|
||||
const id = parseInt(item.dataset.id);
|
||||
if (selectedId === id) { selectedId = null; restoreBtn.disabled = true; }
|
||||
else { selectedId = id; restoreBtn.disabled = false; }
|
||||
historyGrid.querySelectorAll('.history-item').forEach(el => el.classList.toggle('selected', parseInt(el.dataset.id) === selectedId));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
restoreBtn.onclick = async () => {
|
||||
if (selectedIdx === null) return;
|
||||
if (selectedId === null) return;
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = 'Restoring...';
|
||||
try {
|
||||
const r = await fetch('/api/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, index: selectedIdx })
|
||||
body: JSON.stringify({ sessionId, id: selectedId })
|
||||
});
|
||||
if (r.ok) { cancelBtn.onclick(); await poll(); }
|
||||
else { alert('Restore failed'); }
|
||||
|
||||
+967
-124
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* File-loading helpers for the load_diagram tool.
|
||||
*
|
||||
* A .drawio file is an <mxfile> whose <diagram> children hold each page's
|
||||
* <mxGraphModel> either as plain XML or — draw.io's default save format —
|
||||
* compressed: encodeURIComponent(xml) → raw deflate → base64 as the
|
||||
* diagram's text content. The rest of the server assumes plain XML inside
|
||||
* every <diagram>, so loading decompresses all pages up front.
|
||||
*/
|
||||
import { inflateRawSync } from "node:zlib"
|
||||
import { DOMParser } from "linkedom"
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
import {
|
||||
isMxFile,
|
||||
isMxGraphModel,
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
serializeMxfile,
|
||||
} from "./pages.js"
|
||||
|
||||
export type LoadResult =
|
||||
| { ok: true; xml: string }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Decode one compressed page body (base64 → raw deflate → URI-decode).
|
||||
* Returns null if the text isn't in that format.
|
||||
*/
|
||||
export function decompressPageContent(compressed: string): string | null {
|
||||
try {
|
||||
const inflated = inflateRawSync(
|
||||
Buffer.from(compressed.trim(), "base64"),
|
||||
).toString("utf-8")
|
||||
try {
|
||||
return decodeURIComponent(inflated)
|
||||
} catch {
|
||||
// Not URI-encoded (older files) — the inflated text is the XML.
|
||||
return inflated
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content of a .drawio file into the canonical session shape:
|
||||
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
|
||||
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
|
||||
* any compressed pages.
|
||||
*/
|
||||
export function parseDrawioFileContent(content: string): LoadResult {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) return { ok: false, error: "File is empty." }
|
||||
|
||||
if (isMxGraphModel(trimmed)) {
|
||||
const normalized = normalizeToMxfile(trimmed)
|
||||
return normalized
|
||||
? { ok: true, xml: normalized }
|
||||
: { ok: false, error: "Failed to parse <mxGraphModel> XML." }
|
||||
}
|
||||
if (!isMxFile(trimmed)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
|
||||
}
|
||||
}
|
||||
const doc = parseMxfile(trimmed)
|
||||
if (!doc) return { ok: false, error: "Failed to parse <mxfile> XML." }
|
||||
|
||||
let decompressedAny = false
|
||||
for (const d of Array.from(doc.querySelectorAll("diagram"))) {
|
||||
if (d.querySelector("mxGraphModel")) continue
|
||||
const text = (d.textContent || "").trim()
|
||||
if (!text) continue // an empty page is valid
|
||||
const pageLabel =
|
||||
d.getAttribute("name") || d.getAttribute("id") || "unnamed"
|
||||
const xml = decompressPageContent(text)
|
||||
if (!xml || !isMxGraphModel(xml)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Page "${pageLabel}" has content that is neither plain <mxGraphModel> XML nor draw.io's compressed format.`,
|
||||
}
|
||||
}
|
||||
const inner = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (
|
||||
getXmlSyntaxError(xml) ||
|
||||
inner.documentElement?.tagName !== "mxGraphModel"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Page "${pageLabel}" decompressed but its XML failed to parse.`,
|
||||
}
|
||||
}
|
||||
d.textContent = ""
|
||||
d.appendChild(
|
||||
doc.importNode(inner.documentElement as unknown as Node, true),
|
||||
)
|
||||
decompressedAny = true
|
||||
}
|
||||
// Nothing changed — keep the file's own serialisation.
|
||||
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Multi-page (mxfile) helpers for draw.io diagrams.
|
||||
*
|
||||
* The on-disk and embed-protocol shape of a draw.io document is:
|
||||
*
|
||||
* <mxfile host="...">
|
||||
* <diagram id="..." name="...">
|
||||
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
|
||||
* </diagram>
|
||||
* ...one or more <diagram> children...
|
||||
* </mxfile>
|
||||
*
|
||||
* This module centralises page CRUD so that index.ts, xml-validation.ts,
|
||||
* and diagram-operations.ts can all agree on:
|
||||
* - what "the canonical in-memory shape" is (always mxfile),
|
||||
* - how to find a page (id, name, or index),
|
||||
* - how to add/rename/delete pages without re-parsing ad-hoc.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
|
||||
export interface PageInfo {
|
||||
id: string
|
||||
name: string
|
||||
index: number
|
||||
cellCount: number
|
||||
}
|
||||
|
||||
/** Selector used by all multi-page-aware tools. All fields optional. */
|
||||
export interface PageSelector {
|
||||
page_id?: string
|
||||
page_name?: string
|
||||
page_index?: number
|
||||
}
|
||||
|
||||
/** True if the selector targets a specific page (any field set). */
|
||||
export function hasPageSelector(s?: PageSelector | null): boolean {
|
||||
if (!s) return false
|
||||
return (
|
||||
Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short page id similar in shape to drawio's auto-assigned ids.
|
||||
* Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself
|
||||
* uses short ids; collisions are still astronomically unlikely for one session.
|
||||
*/
|
||||
export function generatePageId(): string {
|
||||
const a = Math.random().toString(36).substring(2, 10)
|
||||
const b = Math.random().toString(36).substring(2, 6)
|
||||
return `${a}-${b}`
|
||||
}
|
||||
|
||||
/** Cheap regex check — does the XML start with an <mxfile> root? */
|
||||
export function isMxFile(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
||||
}
|
||||
|
||||
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
|
||||
export function isMxGraphModel(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading <?xml ... ?> declaration from an XML string. The XML spec
|
||||
* only permits the declaration at the very start of a document, so embedding
|
||||
* a declaration inside another element produces invalid XML. Callers must
|
||||
* strip before splicing a fragment into a wrapper.
|
||||
*/
|
||||
function stripXmlDeclaration(xml: string): string {
|
||||
return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bare <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
||||
* If the input is already an mxfile, returns it unchanged.
|
||||
* If the input is neither shape, returns null so the caller can surface a clear error.
|
||||
*
|
||||
* Strips any leading <?xml ?> declaration before embedding — a declaration is
|
||||
* only valid at the very start of a document, never inside a <diagram>.
|
||||
*/
|
||||
export function normalizeToMxfile(
|
||||
xml: string,
|
||||
opts: { pageId?: string; pageName?: string; host?: string } = {},
|
||||
): string | null {
|
||||
const trimmed = xml.trim()
|
||||
if (!trimmed) return null
|
||||
if (isMxFile(trimmed)) return trimmed
|
||||
if (!isMxGraphModel(trimmed)) return null
|
||||
|
||||
const pageId = opts.pageId || generatePageId()
|
||||
const pageName = opts.pageName || "Page-1"
|
||||
const host = opts.host || "app.diagrams.net"
|
||||
const inner = stripXmlDeclaration(trimmed)
|
||||
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an mxfile XML string. Returns null on parse error or if the root
|
||||
* isn't <mxfile> — callers are expected to have run normalizeToMxfile first.
|
||||
*/
|
||||
export function parseMxfile(xml: string): Document | null {
|
||||
try {
|
||||
if (getXmlSyntaxError(xml)) return null
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (doc.documentElement?.tagName !== "mxfile") return null
|
||||
return doc as unknown as Document
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */
|
||||
export function serializeMxfile(doc: Document): string {
|
||||
const serializer = new XMLSerializer()
|
||||
return serializer.serializeToString(doc)
|
||||
}
|
||||
|
||||
export type PageProjection =
|
||||
| { ok: true; xml: string; index: number; name: string }
|
||||
| { ok: false; reason: "parse" | "notfound" }
|
||||
|
||||
/**
|
||||
* Project a single page out of an mxfile string into a standalone one-page
|
||||
* <mxfile>. Used by get_diagram and export_diagram so the three call sites
|
||||
* share one parse → find → serialise path.
|
||||
*
|
||||
* Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile,
|
||||
* or { ok:false, reason:"notfound" } if the selector matches no page.
|
||||
*/
|
||||
export function projectPage(
|
||||
xml: string,
|
||||
selector: PageSelector,
|
||||
): PageProjection {
|
||||
const doc = parseMxfile(xml)
|
||||
if (!doc) return { ok: false, reason: "parse" }
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) return { ok: false, reason: "notfound" }
|
||||
const serializer = new XMLSerializer()
|
||||
return {
|
||||
ok: true,
|
||||
xml: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
|
||||
index: found.index,
|
||||
name: found.element.getAttribute("name") || "",
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk every <diagram> child of <mxfile> and return summary info. */
|
||||
export function listPagesFromDoc(doc: Document): PageInfo[] {
|
||||
const diagrams = doc.querySelectorAll("diagram")
|
||||
const result: PageInfo[] = []
|
||||
diagrams.forEach((d, idx) => {
|
||||
const root = d.querySelector("root")
|
||||
const cellCount = root ? root.querySelectorAll("mxCell").length : 0
|
||||
result.push({
|
||||
id: d.getAttribute("id") || "",
|
||||
name: d.getAttribute("name") || `Page-${idx + 1}`,
|
||||
index: idx,
|
||||
cellCount,
|
||||
})
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a page selector to its <diagram> element.
|
||||
* Resolution order: page_id → page_name → page_index → default (first page).
|
||||
*
|
||||
* When no selector field is set we return the first page — the "active page
|
||||
* by convention" mentioned in §3.4 of the design doc.
|
||||
*/
|
||||
export function findPageElement(
|
||||
doc: Document,
|
||||
selector?: PageSelector,
|
||||
): { element: Element; index: number } | null {
|
||||
const diagrams = Array.from(doc.querySelectorAll("diagram"))
|
||||
if (diagrams.length === 0) return null
|
||||
|
||||
if (!hasPageSelector(selector)) {
|
||||
return { element: diagrams[0], index: 0 }
|
||||
}
|
||||
|
||||
if (selector?.page_id) {
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
if (diagrams[i].getAttribute("id") === selector.page_id) {
|
||||
return { element: diagrams[i], index: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (selector?.page_name) {
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
if (diagrams[i].getAttribute("name") === selector.page_name) {
|
||||
return { element: diagrams[i], index: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (selector && selector.page_index !== undefined) {
|
||||
const idx = selector.page_index
|
||||
if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) {
|
||||
return { element: diagrams[idx], index: idx }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a new <diagram> to the mxfile doc. The new page's model defaults to
|
||||
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
|
||||
*
|
||||
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
|
||||
* end up nested inside <diagram>, which is malformed. We reject the mxfile
|
||||
* shape explicitly and strip any <?xml ?> declaration (only valid at
|
||||
* document start, never inside <diagram>).
|
||||
*
|
||||
* Returns the new PageInfo. Throws if the requested id collides or the xml
|
||||
* shape is wrong.
|
||||
*/
|
||||
export function addPageToDoc(
|
||||
doc: Document,
|
||||
opts: { id?: string; name?: string; xml?: string } = {},
|
||||
): PageInfo {
|
||||
const existing = listPagesFromDoc(doc)
|
||||
const id = opts.id || generatePageId()
|
||||
if (existing.some((p) => p.id === id)) {
|
||||
throw new Error(`Page id "${id}" already exists`)
|
||||
}
|
||||
const name = opts.name || `Page-${existing.length + 1}`
|
||||
|
||||
let inner: string
|
||||
if (opts.xml?.trim()) {
|
||||
const trimmed = stripXmlDeclaration(opts.xml.trim())
|
||||
if (isMxFile(trimmed)) {
|
||||
throw new Error(
|
||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
|
||||
)
|
||||
}
|
||||
if (!isMxGraphModel(trimmed)) {
|
||||
throw new Error(
|
||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
|
||||
)
|
||||
}
|
||||
inner = trimmed
|
||||
} else {
|
||||
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
||||
}
|
||||
|
||||
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
||||
if (getXmlSyntaxError(snippet)) {
|
||||
throw new Error(
|
||||
"Failed to parse new page xml — make sure it is a valid <mxGraphModel>",
|
||||
)
|
||||
}
|
||||
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
|
||||
const newDiagram = tempDoc.querySelector("diagram")
|
||||
if (!newDiagram) {
|
||||
throw new Error("Failed to construct <diagram> element for new page")
|
||||
}
|
||||
|
||||
const imported = doc.importNode(newDiagram, true) as Element
|
||||
doc.documentElement.appendChild(imported)
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
index: existing.length,
|
||||
cellCount: imported.querySelectorAll("mxCell").length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename the page matched by selector. Returns true on success. */
|
||||
export function renamePageInDoc(
|
||||
doc: Document,
|
||||
selector: PageSelector,
|
||||
newName: string,
|
||||
): boolean {
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) return false
|
||||
found.element.setAttribute("name", newName)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a page. Refuses to delete the last remaining page — the embed needs
|
||||
* at least one diagram to render anything, and silently recreating one would
|
||||
* be surprising behaviour for an MCP caller.
|
||||
*/
|
||||
export function deletePageFromDoc(
|
||||
doc: Document,
|
||||
selector: PageSelector,
|
||||
): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } {
|
||||
const pages = listPagesFromDoc(doc)
|
||||
if (pages.length <= 1) {
|
||||
return { ok: false, reason: "Cannot delete the only remaining page" }
|
||||
}
|
||||
const found = findPageElement(doc, selector)
|
||||
if (!found) {
|
||||
return { ok: false, reason: "Page not found" }
|
||||
}
|
||||
const id = found.element.getAttribute("id") || ""
|
||||
const index = found.index
|
||||
found.element.parentNode?.removeChild(found.element)
|
||||
return { ok: true, deletedId: id, deletedIndex: index }
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* Copied from lib/utils.ts to avoid cross-package imports
|
||||
*/
|
||||
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
@@ -10,9 +12,6 @@
|
||||
/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */
|
||||
const MAX_XML_SIZE = 1_000_000
|
||||
|
||||
/** Maximum iterations for aggressive cell dropping to prevent infinite loops */
|
||||
const MAX_DROP_ITERATIONS = 10
|
||||
|
||||
/** Structural attributes that should not be duplicated in draw.io */
|
||||
const STRUCTURAL_ATTRS = [
|
||||
"edge",
|
||||
@@ -91,6 +90,21 @@ function parseXmlTags(xml: string): ParsedTag[] {
|
||||
return tags
|
||||
}
|
||||
|
||||
/** Rewrite every opening tag with fn, leaving text and closing tags as is. */
|
||||
function replaceInOpeningTags(
|
||||
xml: string,
|
||||
fn: (tag: string) => string,
|
||||
): string {
|
||||
let out = ""
|
||||
let last = 0
|
||||
for (const { tag, isClosing, startIndex, endIndex } of parseXmlTags(xml)) {
|
||||
if (isClosing) continue
|
||||
out += xml.slice(last, startIndex) + fn(tag)
|
||||
last = endIndex + 1
|
||||
}
|
||||
return out + xml.slice(last)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Helper Functions
|
||||
// ============================================================================
|
||||
@@ -119,8 +133,69 @@ function checkDuplicateAttributes(xml: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check for duplicate IDs in XML */
|
||||
/**
|
||||
* Check for duplicate IDs in XML.
|
||||
*
|
||||
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
|
||||
* IDs are unique **within a page**, not across the whole document — drawio
|
||||
* legitimately reuses "0" and "1" for the root cells of every page. So we
|
||||
* scope the cell-ID uniqueness check per <diagram>, and additionally check
|
||||
* that the <diagram> ids themselves are unique.
|
||||
*
|
||||
* The legacy regex-based check is kept as a fallback for non-mxfile inputs.
|
||||
*/
|
||||
function checkDuplicateIds(xml: string): string | null {
|
||||
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
|
||||
// bare <mxGraphModel> inputs (the overwhelming majority of historic
|
||||
// traffic), the cheap regex fallback at the bottom is enough. A quick
|
||||
// string check avoids paying the DOMParser cost on every call.
|
||||
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
|
||||
|
||||
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
|
||||
if (mightBeMxFile)
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
const rootEl = doc.documentElement
|
||||
if (rootEl && rootEl.tagName === "mxfile") {
|
||||
const diagrams = doc.querySelectorAll("diagram")
|
||||
|
||||
// 1) <diagram> ids must be unique across the file.
|
||||
const diagramIds = new Map<string, number>()
|
||||
diagrams.forEach((d) => {
|
||||
const id = d.getAttribute("id")
|
||||
if (id) diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dupDiagrams = Array.from(diagramIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id]) => `'${id}'`)
|
||||
if (dupDiagrams.length > 0) {
|
||||
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
||||
}
|
||||
|
||||
// 2) Within each page, mxCell ids must be unique.
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
const diagram = diagrams[i]
|
||||
const pageId = diagram.getAttribute("id") || `(index ${i})`
|
||||
const cells = diagram.querySelectorAll("mxCell")
|
||||
const cellIds = new Map<string, number>()
|
||||
cells.forEach((c) => {
|
||||
const id = c.getAttribute("id")
|
||||
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dups = Array.from(cellIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||
if (dups.length > 0) {
|
||||
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
// fall through to regex
|
||||
}
|
||||
|
||||
// Legacy regex-based check for bare <mxGraphModel> inputs.
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
const ids = new Map<string, number>()
|
||||
let idMatch
|
||||
@@ -249,14 +324,11 @@ export function validateMxCellStructure(xml: string): string | null {
|
||||
)
|
||||
}
|
||||
|
||||
// 0. First use DOM parser to catch syntax errors (most accurate)
|
||||
// 0. DOM-based checks. Syntax errors are caught by the strict check at
|
||||
// the end: linkedom's DOMParser never reports them.
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xml, "text/xml")
|
||||
const parseError = doc.querySelector("parsererror")
|
||||
if (parseError) {
|
||||
return `Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values.`
|
||||
}
|
||||
|
||||
// DOM-based checks for nested mxCell
|
||||
const allCells = doc.querySelectorAll("mxCell")
|
||||
@@ -338,6 +410,14 @@ export function validateMxCellStructure(xml: string): string | null {
|
||||
return nestedCellError
|
||||
}
|
||||
|
||||
// 11. Strict XML syntax check, run last so the checks above can give
|
||||
// more specific messages. Catches what they miss, e.g. duplicate or
|
||||
// unquoted attributes, which make draw.io refuse to load the diagram.
|
||||
const syntaxError = getXmlSyntaxError(xml)
|
||||
if (syntaxError) {
|
||||
return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (< for <, & for &, " for "), quote every attribute value, and do not repeat an attribute.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -428,13 +508,21 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Fix malformed attribute quotes
|
||||
const malformedQuotePattern = /(\s[a-zA-Z][a-zA-Z0-9_:-]*)="/
|
||||
if (malformedQuotePattern.test(fixed)) {
|
||||
fixed = fixed.replace(
|
||||
/(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g,
|
||||
'$1="$2"',
|
||||
)
|
||||
// 6. Fix malformed attribute quotes (name="value"). Quoted
|
||||
// values are matched first and kept, so " inside a rich-text
|
||||
// label like value="<font style="...">" is left alone.
|
||||
let quotesFixed = false
|
||||
fixed = replaceInOpeningTags(fixed, (tag) =>
|
||||
tag.replace(
|
||||
/("[^"]*"|'[^']*')|(\s[a-zA-Z][a-zA-Z0-9_:-]*)="([^&]*?)"/g,
|
||||
(match, quoted, name, value) => {
|
||||
if (quoted) return match
|
||||
quotesFixed = true
|
||||
return `${name}="${value}"`
|
||||
},
|
||||
),
|
||||
)
|
||||
if (quotesFixed) {
|
||||
fixes.push("Fixed malformed attribute quotes")
|
||||
}
|
||||
|
||||
@@ -445,10 +533,21 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Fixed malformed closing tags")
|
||||
}
|
||||
|
||||
// 8. Fix missing space between attributes
|
||||
const missingSpacePattern = /("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g
|
||||
if (missingSpacePattern.test(fixed)) {
|
||||
fixed = fixed.replace(/("[^"]*")([a-zA-Z][a-zA-Z0-9_:-]*=)/g, "$1 $2")
|
||||
// 8. Fix missing space between attributes (id="2"vertex="1"). Every
|
||||
// quoted value is consumed whole, so quotes always pair up within one
|
||||
// attribute.
|
||||
let spaceAdded = false
|
||||
fixed = replaceInOpeningTags(fixed, (tag) =>
|
||||
tag.replace(
|
||||
/("[^"]*"|'[^']*')([a-zA-Z_:])?/g,
|
||||
(match, quoted, next) => {
|
||||
if (!next) return match
|
||||
spaceAdded = true
|
||||
return `${quoted} ${next}`
|
||||
},
|
||||
),
|
||||
)
|
||||
if (spaceAdded) {
|
||||
fixes.push("Added missing space between attributes")
|
||||
}
|
||||
|
||||
@@ -566,6 +665,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
"Array",
|
||||
"Object",
|
||||
"mxRectangle",
|
||||
// Wrappers draw.io writes for cells with links, tooltips or data
|
||||
"UserObject",
|
||||
"object",
|
||||
])
|
||||
const foreignTagPattern = /<\/?([a-zA-Z][a-zA-Z0-9_]*)[^>]*>/g
|
||||
let foreignMatch
|
||||
@@ -730,8 +832,10 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push(`Flattened ${nestedFixed} duplicate-ID nested mxCell(s)`)
|
||||
}
|
||||
|
||||
// 21. Fix true nested mxCell (different IDs)
|
||||
const lines2 = fixed.split("\n")
|
||||
// 21. Fix true nested mxCell (different IDs). Runs only when the nesting
|
||||
// check finds real nesting, because this line-based rewrite can break
|
||||
// valid cells written over several lines.
|
||||
const lines2 = checkNestedMxCells(fixed) ? fixed.split("\n") : []
|
||||
newLines = []
|
||||
let trueNestedFixed = 0
|
||||
let cellDepth = 0
|
||||
@@ -741,7 +845,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
const line = lines2[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
const isOpenCell = /<mxCell\s/.test(trimmed) && !trimmed.endsWith("/>")
|
||||
// A line holding a whole cell (<mxCell ...>...</mxCell>) opens nothing
|
||||
const isOpenCell =
|
||||
/<mxCell\s/.test(trimmed) &&
|
||||
!trimmed.endsWith("/>") &&
|
||||
!trimmed.endsWith("</mxCell>")
|
||||
const isCloseCell = trimmed === "</mxCell>"
|
||||
|
||||
if (isOpenCell) {
|
||||
@@ -770,35 +878,47 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||
}
|
||||
|
||||
// 22. Fix duplicate IDs by appending suffix
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
// 22. Fix duplicate IDs by appending suffix.
|
||||
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
|
||||
// across pages legitimately (every page has its own <root> with id="0"/"1"
|
||||
// sentinel cells). Renaming them would break drawio's parent references.
|
||||
// For mxfile inputs, duplicate-id validation is page-scoped in
|
||||
// checkDuplicateIds() and a true duplicate produces a hard error rather
|
||||
// than a silent rename.
|
||||
if (!/<mxfile[\s>]/i.test(fixed)) {
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
// Rebuild from the captured parts so only the value changes (an id
|
||||
// like "d" or "i" also occurs in the attribute name itself)
|
||||
fixed = fixed.replace(
|
||||
/(\bid\s*=\s*["'])([^"']+)(["'])/gi,
|
||||
(match, before, id, after) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
|
||||
if (count === 0) return match
|
||||
if (count === 0) return match
|
||||
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
})
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
return `${before}${id}_dup${count}${after}`
|
||||
},
|
||||
)
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
// 23. Fix empty id attributes
|
||||
@@ -815,49 +935,6 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push(`Generated ${emptyIdCount} missing ID(s)`)
|
||||
}
|
||||
|
||||
// 24. Aggressive: drop broken mxCell elements
|
||||
if (typeof DOMParser !== "undefined") {
|
||||
let droppedCells = 0
|
||||
let maxIterations = MAX_DROP_ITERATIONS
|
||||
while (maxIterations-- > 0) {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(fixed, "text/xml")
|
||||
const parseError = doc.querySelector("parsererror")
|
||||
if (!parseError) break
|
||||
|
||||
const errText = parseError.textContent || ""
|
||||
const match = errText.match(/(\d+):\d+:/)
|
||||
if (!match) break
|
||||
|
||||
const errLine = parseInt(match[1], 10) - 1
|
||||
const lines = fixed.split("\n")
|
||||
|
||||
let cellStart = errLine
|
||||
let cellEnd = errLine
|
||||
|
||||
while (cellStart > 0 && !lines[cellStart].includes("<mxCell")) {
|
||||
cellStart--
|
||||
}
|
||||
|
||||
while (cellEnd < lines.length - 1) {
|
||||
if (
|
||||
lines[cellEnd].includes("</mxCell>") ||
|
||||
lines[cellEnd].trim().endsWith("/>")
|
||||
) {
|
||||
break
|
||||
}
|
||||
cellEnd++
|
||||
}
|
||||
|
||||
lines.splice(cellStart, cellEnd - cellStart + 1)
|
||||
fixed = lines.join("\n")
|
||||
droppedCells++
|
||||
}
|
||||
if (droppedCells > 0) {
|
||||
fixes.push(`Dropped ${droppedCells} unfixable mxCell element(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
return { fixed, fixes }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tests for edit_diagram operations on cells that draw.io wraps in
|
||||
* <UserObject> or <object> (cells with links, tooltips or custom data).
|
||||
* The id sits on the wrapper; the inner mxCell has none.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.js"
|
||||
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
||||
|
||||
const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
describe("wrapped cells", () => {
|
||||
it("deletes a UserObject cell with its edges and children", () => {
|
||||
const { result, errors } = applyDiagramOperations(DOC, [
|
||||
{ operation: "delete", cell_id: "a" },
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
expect(result).not.toContain('id="a"')
|
||||
expect(result).not.toContain('id="e1"')
|
||||
expect(result).not.toContain('id="child"')
|
||||
expect(result).toContain('id="b"')
|
||||
})
|
||||
|
||||
it("cascades to a wrapped edge when deleting a plain cell", () => {
|
||||
const { result, errors } = applyDiagramOperations(DOC, [
|
||||
{ operation: "delete", cell_id: "b" },
|
||||
{ operation: "delete", cell_id: "e1" },
|
||||
])
|
||||
// e1 was already removed by the cascade, so no warning for it
|
||||
expect(errors).toEqual([])
|
||||
expect(result).not.toContain('id="e1"')
|
||||
expect(result).toContain('id="a"')
|
||||
})
|
||||
|
||||
it("warns when deleting a cell that does not exist", () => {
|
||||
const { errors } = applyDiagramOperations(DOC, [
|
||||
{ operation: "delete", cell_id: "missing" },
|
||||
])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toMatchObject({ type: "delete", cellId: "missing" })
|
||||
})
|
||||
|
||||
it("updates a UserObject cell", () => {
|
||||
const { result, errors } = applyDiagramOperations(DOC, [
|
||||
{
|
||||
operation: "update",
|
||||
cell_id: "a",
|
||||
new_xml: `<UserObject id="a" label="A2" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
|
||||
},
|
||||
])
|
||||
expect(errors).toEqual([])
|
||||
expect(result).toContain('label="A2"')
|
||||
expect(result.match(/id="a"/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("refuses to add a cell whose id a UserObject already uses", () => {
|
||||
const { errors } = applyDiagramOperations(DOC, [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "a",
|
||||
new_xml: `<mxCell id="a" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`,
|
||||
},
|
||||
])
|
||||
expect(errors[0]?.message).toContain("already exists")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cascade delete logging", () => {
|
||||
it("does not write cascade logs to stdout (the JSON-RPC channel)", () => {
|
||||
const plain = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="x" vertex="1" parent="1"/><mxCell id="y" vertex="1" parent="1"/><mxCell id="e" edge="1" source="x" target="y" parent="1"/></root></mxGraphModel>`
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {})
|
||||
const { result } = applyDiagramOperations(plain, [
|
||||
{ operation: "delete", cell_id: "x" },
|
||||
])
|
||||
expect(result).not.toContain('id="e"')
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Unit tests for the edit_diagram workflow gate (edit-gate.ts).
|
||||
*
|
||||
* The gate replaced the old 30-second wall-clock rule (#885): an edit is
|
||||
* allowed when the model has seen the current browser state, no matter how
|
||||
* long ago — and rejected when the browser state moved since. "Seen" is
|
||||
* judged structurally, so draw.io's re-serialisation of the same content
|
||||
* (attribute order, whitespace, viewport attributes, wrapper shape) never
|
||||
* reads as a user edit.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.js"
|
||||
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
|
||||
|
||||
const XML_A = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
// The same document as draw.io re-serialises it on autosave: different host,
|
||||
// regenerated diagram id, viewport attributes on mxGraphModel, re-ordered
|
||||
// cell attributes, pretty-printed whitespace.
|
||||
const XML_A_RESERIALIZED = `<mxfile host="embed.diagrams.net">
|
||||
<diagram id="regenerated-id" name="Page-1">
|
||||
<mxGraphModel dx="1596" dy="743" grid="1" pageWidth="827" pageHeight="1169">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="box1" parent="1" style="rounded=0;" value="Hello" vertex="1">
|
||||
<mxGeometry height="60" width="120" x="40" y="40" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>`
|
||||
|
||||
// A real user edit: box1 moved to a different position.
|
||||
const XML_B = XML_A.replace('x="40" y="40"', 'x="300" y="200"')
|
||||
|
||||
// Bare mxGraphModel with identical page content to XML_A.
|
||||
const XML_A_BARE = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
|
||||
describe("checkEditGate", () => {
|
||||
it("rejects when no diagram context was ever established", () => {
|
||||
expect(checkEditGate("", XML_A)).toEqual({
|
||||
ok: false,
|
||||
reason: "no-context",
|
||||
})
|
||||
})
|
||||
|
||||
it("allows when the browser state is exactly what the model saw", () => {
|
||||
expect(checkEditGate(XML_A, XML_A)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it("allows when the browser state is a re-serialisation of the same content", () => {
|
||||
expect(checkEditGate(XML_A, XML_A_RESERIALIZED)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it("rejects when a cell actually changed", () => {
|
||||
expect(checkEditGate(XML_A, XML_B)).toEqual({
|
||||
ok: false,
|
||||
reason: "stale",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects a real edit even when wrapped in re-serialisation noise", () => {
|
||||
const movedAndReserialized = XML_A_RESERIALIZED.replace(
|
||||
'x="40" y="40"',
|
||||
'x="300" y="200"',
|
||||
)
|
||||
expect(checkEditGate(XML_A, movedAndReserialized)).toEqual({
|
||||
ok: false,
|
||||
reason: "stale",
|
||||
})
|
||||
})
|
||||
|
||||
it("allows when the store has no live entry to compare against", () => {
|
||||
expect(checkEditGate(XML_A, "")).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
// A bare <mxGraphModel> push carries no page name, so the gate must not
|
||||
// compare the invented "Page-1" wrapper name against the real one.
|
||||
it("allows a bare mxGraphModel push when the page has a custom name", () => {
|
||||
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||
expect(checkEditGate(seenRenamed, XML_A_BARE)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it("still rejects a bare mxGraphModel push whose cells changed", () => {
|
||||
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
|
||||
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
|
||||
expect(checkEditGate(seenRenamed, bareMoved)).toEqual({
|
||||
ok: false,
|
||||
reason: "stale",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("contentFingerprint", () => {
|
||||
it("is invariant under draw.io re-serialisation", () => {
|
||||
expect(contentFingerprint(XML_A)).toBe(
|
||||
contentFingerprint(XML_A_RESERIALIZED),
|
||||
)
|
||||
})
|
||||
|
||||
it("treats a bare mxGraphModel like its one-page mxfile wrapping", () => {
|
||||
expect(contentFingerprint(XML_A_BARE)).toBe(contentFingerprint(XML_A))
|
||||
})
|
||||
|
||||
it("changes when a cell attribute changes", () => {
|
||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(XML_B))
|
||||
})
|
||||
|
||||
it("changes when a page is renamed", () => {
|
||||
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
|
||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(renamed))
|
||||
})
|
||||
|
||||
it("changes when a page is added", () => {
|
||||
const twoPages = XML_A.replace(
|
||||
"</mxfile>",
|
||||
`<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`,
|
||||
)
|
||||
expect(contentFingerprint(XML_A)).not.toBe(contentFingerprint(twoPages))
|
||||
})
|
||||
|
||||
it("falls back to the raw string for unparseable input", () => {
|
||||
expect(contentFingerprint("not xml at all")).toBe("not xml at all")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Tests for the embedded HTTP server (browser bridge).
|
||||
*
|
||||
* The server runs in-process on a random high port (never 6002, which is
|
||||
* also the default port of the Next.js dev server). Requests go through
|
||||
* node:http so tests can set raw paths and Host/Origin headers.
|
||||
*/
|
||||
|
||||
import http from "node:http"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { addHistory } from "../src/history.js"
|
||||
import {
|
||||
getState,
|
||||
setState,
|
||||
shutdown,
|
||||
startHttpServer,
|
||||
} from "../src/http-server.js"
|
||||
|
||||
let port = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
shutdown()
|
||||
})
|
||||
|
||||
interface Response {
|
||||
status: number
|
||||
headers: http.IncomingHttpHeaders
|
||||
body: string
|
||||
}
|
||||
|
||||
/** Send a request; `body` may be split into several writes. */
|
||||
function request(
|
||||
path: string,
|
||||
opts: {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: Buffer[]
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
path,
|
||||
method: opts.method ?? "GET",
|
||||
headers: { host: `localhost:${port}`, ...opts.headers },
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on("data", (c: Buffer) => chunks.push(c))
|
||||
res.on("end", () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
req.on("error", reject)
|
||||
const parts = opts.body ?? []
|
||||
// Pause between parts so the server reads them as separate chunks
|
||||
const writeNext = (i: number) => {
|
||||
if (i >= parts.length) return req.end()
|
||||
req.write(parts[i])
|
||||
setTimeout(() => writeNext(i + 1), 30)
|
||||
}
|
||||
writeNext(0)
|
||||
})
|
||||
}
|
||||
|
||||
const postJson = (path: string, data: unknown, headers = {}) =>
|
||||
request(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: [Buffer.from(JSON.stringify(data))],
|
||||
})
|
||||
|
||||
describe("session id in the page URL", () => {
|
||||
it("rejects a session id that could inject script", async () => {
|
||||
const res = await request(`/?mcp=${encodeURIComponent('";alert(1)//')}`)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body).not.toContain("alert")
|
||||
})
|
||||
|
||||
it("writes a valid session id into the page script as a JSON string", async () => {
|
||||
const res = await request("/?mcp=mcp-test-page")
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toContain('const sessionId = "mcp-test-page";')
|
||||
})
|
||||
})
|
||||
|
||||
describe("requests that used to crash the process", () => {
|
||||
it("answers 400 for a path that is not a valid URL", async () => {
|
||||
const res = await request("//")
|
||||
expect(res.status).toBe(400)
|
||||
// The server is still alive
|
||||
expect((await request("/api/state?sessionId=mcp-alive")).status).toBe(
|
||||
200,
|
||||
)
|
||||
})
|
||||
|
||||
it("never creates sessions with ids unsafe for the Location header", async () => {
|
||||
const badId = "mcp-中"
|
||||
await request(`/api/state?sessionId=${encodeURIComponent(badId)}`)
|
||||
expect(getState(badId)).toBeUndefined()
|
||||
const post = await postJson("/api/state", {
|
||||
sessionId: badId,
|
||||
xml: "<mxfile/>",
|
||||
})
|
||||
expect(post.status).toBe(400)
|
||||
expect(getState(badId)).toBeUndefined()
|
||||
|
||||
const res = await request("/")
|
||||
expect([200, 302]).toContain(res.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe("request origin checks", () => {
|
||||
it("refuses a foreign Host header (DNS rebinding)", async () => {
|
||||
const res = await request("/api/state?sessionId=mcp-alive", {
|
||||
headers: { host: `evil.example:${port}` },
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it("refuses writes from another website", async () => {
|
||||
const res = await postJson(
|
||||
"/api/state",
|
||||
{ sessionId: "mcp-csrf", xml: "<mxfile/>" },
|
||||
{ origin: "https://evil.example" },
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
expect(getState("mcp-csrf")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("accepts writes from the page itself", async () => {
|
||||
const res = await postJson(
|
||||
"/api/state",
|
||||
{ sessionId: "mcp-same-origin", xml: "<mxfile/>" },
|
||||
{ origin: `http://localhost:${port}` },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /api/state", () => {
|
||||
it("decodes UTF-8 characters split across body chunks", async () => {
|
||||
const xml = `<mxfile>${"数据".repeat(30000)}</mxfile>`
|
||||
const body = Buffer.from(JSON.stringify({ sessionId: "mcp-utf8", xml }))
|
||||
// Cut inside a 3-byte character
|
||||
const cut = body.indexOf(Buffer.from("数")) + 1
|
||||
const res = await request("/api/state", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: [body.subarray(0, cut), body.subarray(cut)],
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(getState("mcp-utf8")?.xml).toBe(xml)
|
||||
})
|
||||
|
||||
it("rejects a browser push based on a version older than an AI write", async () => {
|
||||
const id = "mcp-conflict"
|
||||
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
|
||||
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
|
||||
|
||||
const stale = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml: "<mxfile>user edit on old version</mxfile>",
|
||||
baseVersion: aiVersion - 1,
|
||||
})
|
||||
expect(stale.status).toBe(409)
|
||||
expect(getState(id)?.xml).toBe("<mxfile>AI edit</mxfile>")
|
||||
|
||||
// Pushes based on the AI version are accepted, including a second
|
||||
// push sent before the first one's response updated the browser
|
||||
for (const xml of ["<mxfile>a</mxfile>", "<mxfile>b</mxfile>"]) {
|
||||
const ok = await postJson("/api/state", {
|
||||
sessionId: id,
|
||||
xml,
|
||||
baseVersion: aiVersion,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
expect(getState(id)?.xml).toBe(xml)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("history restore", () => {
|
||||
it("restores the entry the user picked after older entries drop", async () => {
|
||||
const id = "mcp-history"
|
||||
setState(id, "<mxfile/>")
|
||||
for (let i = 0; i < 20; i++) addHistory(id, `<mxfile>${i}</mxfile>`)
|
||||
|
||||
const list = await request(`/api/history?sessionId=${id}`)
|
||||
const picked = JSON.parse(list.body).entries[5]
|
||||
|
||||
// A new AI edit shifts the buffer before the user clicks Restore
|
||||
addHistory(id, "<mxfile>new</mxfile>")
|
||||
|
||||
const res = await postJson("/api/restore", {
|
||||
sessionId: id,
|
||||
id: picked.id,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(getState(id)?.xml).toBe("<mxfile>5</mxfile>")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Unit tests for load_diagram's file parsing (load-diagram.ts).
|
||||
*
|
||||
* A .drawio file stores each page's <mxGraphModel> either as plain XML or
|
||||
* as draw.io's compressed default (encodeURIComponent → raw deflate →
|
||||
* base64 text content). The loader must produce the canonical session
|
||||
* shape: an <mxfile> whose every page is plain XML.
|
||||
*/
|
||||
|
||||
import { deflateRawSync } from "node:zlib"
|
||||
import { DOMParser } from "linkedom"
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.js"
|
||||
|
||||
// Install the DOM polyfills exactly as index.ts does at runtime.
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import {
|
||||
decompressPageContent,
|
||||
parseDrawioFileContent,
|
||||
} from "../src/load-diagram.js"
|
||||
|
||||
const MODEL_XML = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="box1" value="Hello" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
|
||||
/** Compress a page body exactly the way draw.io does when saving. */
|
||||
function drawioCompress(xml: string): string {
|
||||
return deflateRawSync(
|
||||
Buffer.from(encodeURIComponent(xml), "utf-8"),
|
||||
).toString("base64")
|
||||
}
|
||||
|
||||
const PLAIN_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="p1" name="Page-1">${MODEL_XML}</diagram></mxfile>`
|
||||
const COMPRESSED_MXFILE = `<mxfile host="app.diagrams.net" compressed="true"><diagram id="p1" name="Page-1">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||
|
||||
describe("decompressPageContent", () => {
|
||||
it("round-trips draw.io's compressed format", () => {
|
||||
expect(decompressPageContent(drawioCompress(MODEL_XML))).toBe(MODEL_XML)
|
||||
})
|
||||
|
||||
it("handles non-URI-encoded legacy payloads", () => {
|
||||
const legacy = deflateRawSync(Buffer.from(MODEL_XML, "utf-8")).toString(
|
||||
"base64",
|
||||
)
|
||||
expect(decompressPageContent(legacy)).toBe(MODEL_XML)
|
||||
})
|
||||
|
||||
it("returns null for garbage", () => {
|
||||
expect(decompressPageContent("not base64 deflate")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseDrawioFileContent", () => {
|
||||
it("passes a plain-XML mxfile through unchanged", () => {
|
||||
const r = parseDrawioFileContent(PLAIN_MXFILE)
|
||||
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
|
||||
})
|
||||
|
||||
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
|
||||
const r = parseDrawioFileContent(MODEL_XML)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.xml).toContain("<mxfile")
|
||||
expect(r.xml).toContain('value="Hello"')
|
||||
}
|
||||
})
|
||||
|
||||
it("decompresses a compressed mxfile into plain XML pages", () => {
|
||||
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.xml).toContain("<mxGraphModel")
|
||||
expect(r.xml).toContain('value="Hello"')
|
||||
// The compressed blob must be gone.
|
||||
expect(r.xml).not.toContain(drawioCompress(MODEL_XML))
|
||||
}
|
||||
})
|
||||
|
||||
it("decompresses only the compressed pages of a mixed file", () => {
|
||||
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
|
||||
const r = parseDrawioFileContent(mixed)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
|
||||
const diagrams = Array.from(
|
||||
doc.querySelectorAll("diagram"),
|
||||
) as Element[]
|
||||
expect(diagrams).toHaveLength(2)
|
||||
for (const d of diagrams) {
|
||||
expect(d.querySelector("mxGraphModel")).not.toBeNull()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps empty pages as-is", () => {
|
||||
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
|
||||
const r = parseDrawioFileContent(withEmpty)
|
||||
expect(r).toEqual({ ok: true, xml: withEmpty })
|
||||
})
|
||||
|
||||
it("rejects empty files", () => {
|
||||
const r = parseDrawioFileContent(" ")
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects non-drawio content", () => {
|
||||
const r = parseDrawioFileContent("<svg><rect/></svg>")
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
|
||||
})
|
||||
|
||||
it("rejects a page whose content is neither XML nor compressed", () => {
|
||||
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
|
||||
const r = parseDrawioFileContent(bad)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.error).toContain('"Broken"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* Unit tests for multi-page (mxfile) support.
|
||||
*
|
||||
* Pinned to the user-visible contract described in
|
||||
* multi-page-mcp-support-plan.md §5 (acceptance criteria):
|
||||
*
|
||||
* AC1. create_new_diagram accepts both bare <mxGraphModel> and full <mxfile>.
|
||||
* AC2. get_diagram returns the full <mxfile> regardless of page count.
|
||||
* AC3. edit_diagram accepts an optional page selector.
|
||||
* AC6. Two tool calls reproduce the Transformer/CNN scenario.
|
||||
* AC9. The wrapper-injection hack at http-server.ts:845 is unnecessary.
|
||||
*
|
||||
* These tests pin the helpers (pages.ts), the validator update
|
||||
* (xml-validation.ts), and the page-targeted edit logic
|
||||
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.js"
|
||||
|
||||
// Install the DOM polyfill exactly as index.ts does at runtime — the
|
||||
// helpers under test rely on it.
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
||||
import {
|
||||
addPageToDoc,
|
||||
deletePageFromDoc,
|
||||
findPageElement,
|
||||
generatePageId,
|
||||
hasPageSelector,
|
||||
isMxFile,
|
||||
isMxGraphModel,
|
||||
listPagesFromDoc,
|
||||
normalizeToMxfile,
|
||||
parseMxfile,
|
||||
projectPage,
|
||||
renamePageInDoc,
|
||||
serializeMxfile,
|
||||
} from "../src/pages.js"
|
||||
import { validateAndFixXml } from "../src/xml-validation.js"
|
||||
|
||||
const BARE_MODEL_ONE_CELL = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Hello"><mxGeometry x="40" y="40" width="100" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
|
||||
const TWO_PAGE_MXFILE = `<mxfile host="app.diagrams.net"><diagram id="page-transformer" name="Transformer"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Encoder"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="page-cnn" name="CNN"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
describe("pages.ts — shape detection", () => {
|
||||
it("isMxFile detects a multi-page mxfile", () => {
|
||||
expect(isMxFile(TWO_PAGE_MXFILE)).toBe(true)
|
||||
})
|
||||
|
||||
it("isMxFile rejects a bare mxGraphModel", () => {
|
||||
expect(isMxFile(BARE_MODEL_ONE_CELL)).toBe(false)
|
||||
})
|
||||
|
||||
it("isMxGraphModel detects a bare model", () => {
|
||||
expect(isMxGraphModel(BARE_MODEL_ONE_CELL)).toBe(true)
|
||||
expect(isMxGraphModel(TWO_PAGE_MXFILE)).toBe(false)
|
||||
})
|
||||
|
||||
it("isMxFile tolerates an XML declaration prefix", () => {
|
||||
expect(
|
||||
isMxFile(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>${TWO_PAGE_MXFILE}`,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — normalizeToMxfile (backward compatibility, AC1)", () => {
|
||||
it("wraps a bare mxGraphModel into a single-page mxfile", () => {
|
||||
const out = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
||||
pageId: "p1",
|
||||
pageName: "Page-1",
|
||||
})
|
||||
expect(out).not.toBeNull()
|
||||
expect(out).toMatch(/^<mxfile/)
|
||||
expect(out).toContain(`<diagram id="p1" name="Page-1">`)
|
||||
expect(out).toContain("<mxGraphModel>")
|
||||
})
|
||||
|
||||
it("returns mxfile inputs unchanged", () => {
|
||||
const out = normalizeToMxfile(TWO_PAGE_MXFILE)
|
||||
expect(out).toBe(TWO_PAGE_MXFILE)
|
||||
})
|
||||
|
||||
it("returns null for neither shape", () => {
|
||||
expect(normalizeToMxfile("<random/>")).toBeNull()
|
||||
expect(normalizeToMxfile("")).toBeNull()
|
||||
})
|
||||
|
||||
it("generated page ids look reasonable", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const id = generatePageId()
|
||||
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
|
||||
}
|
||||
})
|
||||
|
||||
it("strips a leading <?xml ?> declaration when wrapping a bare model", () => {
|
||||
// Regression for the bug Copilot caught: isMxGraphModel tolerates a
|
||||
// declaration prefix, but the wrapper used to embed it inside
|
||||
// <diagram>, producing invalid XML (<?xml ?> is only valid at the
|
||||
// document start). The result must round-trip through parseMxfile
|
||||
// and the declaration must be gone from inside <diagram>.
|
||||
const withDecl = `<?xml version="1.0" encoding="UTF-8"?>${BARE_MODEL_ONE_CELL}`
|
||||
const out = normalizeToMxfile(withDecl, {
|
||||
pageId: "p1",
|
||||
pageName: "Page-1",
|
||||
})
|
||||
expect(out).not.toBeNull()
|
||||
expect(out).toMatch(/^<mxfile/)
|
||||
// No <?xml inside the body of the wrapped document.
|
||||
expect(out!.indexOf("<?xml")).toBe(-1)
|
||||
// And it must still parse cleanly.
|
||||
const doc = parseMxfile(out!)
|
||||
expect(doc).not.toBeNull()
|
||||
expect(listPagesFromDoc(doc!)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — addPageToDoc input validation", () => {
|
||||
it("rejects opts.xml shaped as a full <mxfile>", () => {
|
||||
// Regression for the Copilot-flagged bug: an mxfile passed as
|
||||
// starting page xml would end up nested inside <diagram>, corrupting
|
||||
// the document. Must throw with a clear message.
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(() =>
|
||||
addPageToDoc(doc, { name: "Bad", xml: TWO_PAGE_MXFILE }),
|
||||
).toThrowError(/bare <mxGraphModel>/i)
|
||||
})
|
||||
|
||||
it("rejects opts.xml that is neither mxGraphModel nor mxfile", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(() =>
|
||||
addPageToDoc(doc, { name: "Junk", xml: "<root><x/></root>" }),
|
||||
).toThrowError(/bare <mxGraphModel>/i)
|
||||
})
|
||||
|
||||
it("strips a <?xml ?> declaration prefix on opts.xml", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const withDecl = `<?xml version="1.0"?>${BARE_MODEL_ONE_CELL}`
|
||||
const info = addPageToDoc(doc, { name: "Sequence", xml: withDecl })
|
||||
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
||||
// Serialised document must not have <?xml ?> inside <diagram>.
|
||||
const out = serializeMxfile(doc)
|
||||
// The mxfile may have one <?xml ?> at the very start (the doc decl),
|
||||
// but no further occurrence inside <diagram>.
|
||||
const matches = out.match(/<\?xml/g) || []
|
||||
expect(matches.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — listPagesFromDoc / findPageElement", () => {
|
||||
it("lists both pages in a two-page mxfile", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages[0]).toMatchObject({
|
||||
id: "page-transformer",
|
||||
name: "Transformer",
|
||||
index: 0,
|
||||
})
|
||||
expect(pages[1]).toMatchObject({
|
||||
id: "page-cnn",
|
||||
name: "CNN",
|
||||
index: 1,
|
||||
})
|
||||
// Cell count is per-page (3 cells per page including the two root sentinels).
|
||||
expect(pages[0].cellCount).toBe(3)
|
||||
expect(pages[1].cellCount).toBe(3)
|
||||
})
|
||||
|
||||
it("findPageElement defaults to the first page when selector is empty", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const found = findPageElement(doc)
|
||||
expect(found?.index).toBe(0)
|
||||
expect(found?.element.getAttribute("id")).toBe("page-transformer")
|
||||
})
|
||||
|
||||
it("findPageElement matches by id, name, and index — id wins when several are set", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(findPageElement(doc, { page_id: "page-cnn" })?.index).toBe(1)
|
||||
expect(findPageElement(doc, { page_name: "CNN" })?.index).toBe(1)
|
||||
expect(findPageElement(doc, { page_index: 1 })?.index).toBe(1)
|
||||
// id beats name beats index
|
||||
const winner = findPageElement(doc, {
|
||||
page_id: "page-cnn",
|
||||
page_name: "Transformer",
|
||||
page_index: 0,
|
||||
})
|
||||
expect(winner?.index).toBe(1)
|
||||
})
|
||||
|
||||
it("findPageElement returns null for an unknown selector", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(findPageElement(doc, { page_id: "ghost" })).toBeNull()
|
||||
expect(findPageElement(doc, { page_name: "ghost" })).toBeNull()
|
||||
expect(findPageElement(doc, { page_index: 99 })).toBeNull()
|
||||
expect(findPageElement(doc, { page_index: -1 })).toBeNull()
|
||||
})
|
||||
|
||||
it("hasPageSelector correctly detects empty vs populated selectors", () => {
|
||||
expect(hasPageSelector()).toBe(false)
|
||||
expect(hasPageSelector({})).toBe(false)
|
||||
expect(hasPageSelector({ page_id: "x" })).toBe(true)
|
||||
expect(hasPageSelector({ page_index: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — addPageToDoc", () => {
|
||||
it("appends a third page and returns its info", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const info = addPageToDoc(doc, { name: "Sequence" })
|
||||
expect(info.name).toBe("Sequence")
|
||||
expect(info.index).toBe(2)
|
||||
expect(info.id).toMatch(/.+/)
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages).toHaveLength(3)
|
||||
expect(pages[2].name).toBe("Sequence")
|
||||
})
|
||||
|
||||
it("rejects a duplicate explicit id", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(() =>
|
||||
addPageToDoc(doc, { id: "page-transformer", name: "X" }),
|
||||
).toThrowError(/already exists/)
|
||||
})
|
||||
|
||||
it("uses a sensible default name when none is supplied", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const info = addPageToDoc(doc, {})
|
||||
expect(info.name).toBe("Page-3")
|
||||
})
|
||||
|
||||
it("accepts an inline starting mxGraphModel", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="A"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
const info = addPageToDoc(doc, { name: "Custom", xml: inner })
|
||||
expect(info.cellCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pages.ts — renamePageInDoc / deletePageFromDoc", () => {
|
||||
it("renames an existing page by name", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const ok = renamePageInDoc(doc, { page_name: "CNN" }, "CNN-v2")
|
||||
expect(ok).toBe(true)
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages[1].name).toBe("CNN-v2")
|
||||
})
|
||||
|
||||
it("rename returns false when target page is missing", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
expect(renamePageInDoc(doc, { page_id: "ghost" }, "Z")).toBe(false)
|
||||
})
|
||||
|
||||
it("deletes a page and removes the <diagram> element from the doc", () => {
|
||||
const doc = parseMxfile(TWO_PAGE_MXFILE)!
|
||||
const outcome = deletePageFromDoc(doc, { page_id: "page-cnn" })
|
||||
expect(outcome.ok).toBe(true)
|
||||
expect(outcome.deletedId).toBe("page-cnn")
|
||||
expect(listPagesFromDoc(doc)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("refuses to delete the only remaining page", () => {
|
||||
// Build a single-page doc to test the guard.
|
||||
const single = normalizeToMxfile(BARE_MODEL_ONE_CELL)!
|
||||
const doc = parseMxfile(single)!
|
||||
const outcome = deletePageFromDoc(doc, { page_index: 0 })
|
||||
expect(outcome.ok).toBe(false)
|
||||
expect(outcome.reason).toMatch(/only remaining page/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("xml-validation.ts — multi-page support", () => {
|
||||
it("accepts a valid two-page mxfile (the exact payload that used to fail)", () => {
|
||||
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it("does NOT flag root sentinel ids 0 and 1 repeating across pages", () => {
|
||||
// This is the regression the planning doc explicitly called out:
|
||||
// before this work, the legacy regex-based duplicate-id check rejected
|
||||
// any multi-page document because cells "0" and "1" appear in every page.
|
||||
const result = validateAndFixXml(TWO_PAGE_MXFILE)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects duplicate cell ids WITHIN a single page", () => {
|
||||
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="P1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="dup" vertex="1" parent="1"/><mxCell id="dup" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const result = validateAndFixXml(bad)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toMatch(/duplicate cell ID/i)
|
||||
})
|
||||
|
||||
it("rejects duplicate <diagram> ids across the file", () => {
|
||||
const bad = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram><diagram id="p1" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const result = validateAndFixXml(bad)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toMatch(/duplicate <diagram> id/i)
|
||||
})
|
||||
|
||||
it("still validates a bare <mxGraphModel> (legacy callers)", () => {
|
||||
const result = validateAndFixXml(BARE_MODEL_ONE_CELL)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it("auto-fix does NOT rename mxfile root cells 0/1 (would break drawio refs)", () => {
|
||||
// Build a doc that triggers some other auto-fix (so autoFixXml runs)
|
||||
// but contains valid multi-page 0/1 cells that must NOT be renamed.
|
||||
const malformedButMultiPage = `<mxfile host="app.diagrams.net"><diagram id="p1" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Q & A"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="p2" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const result = validateAndFixXml(malformedButMultiPage)
|
||||
// The doc has an unescaped & — autoFix will repair that. After repair
|
||||
// it should be valid AND must not have renamed the 0/1 cells.
|
||||
const finalXml = result.fixed || malformedButMultiPage
|
||||
expect(finalXml).not.toMatch(/id="0_dup/)
|
||||
expect(finalXml).not.toMatch(/id="1_dup/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("diagram-operations.ts — page-targeted edits (AC3)", () => {
|
||||
it("adds a cell to the targeted page by id, leaving the other page untouched", () => {
|
||||
const { result, errors } = applyDiagramOperations(
|
||||
TWO_PAGE_MXFILE,
|
||||
[
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "conv-2",
|
||||
new_xml: `<mxCell id="conv-2" vertex="1" parent="1" value="Conv2"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`,
|
||||
},
|
||||
],
|
||||
{ page_id: "page-cnn" },
|
||||
)
|
||||
expect(errors).toHaveLength(0)
|
||||
const doc = parseMxfile(result)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
// Transformer untouched (still 3 cells), CNN gained one cell.
|
||||
expect(pages[0].cellCount).toBe(3)
|
||||
expect(pages[1].cellCount).toBe(4)
|
||||
expect(result).toContain(`id="conv-2"`)
|
||||
})
|
||||
|
||||
it("defaults to the first page when no selector is given", () => {
|
||||
const { result, errors } = applyDiagramOperations(TWO_PAGE_MXFILE, [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "shape-x",
|
||||
new_xml: `<mxCell id="shape-x" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>`,
|
||||
},
|
||||
])
|
||||
expect(errors).toHaveLength(0)
|
||||
const doc = parseMxfile(result)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
expect(pages[0].cellCount).toBe(4) // Transformer (first page) grew
|
||||
expect(pages[1].cellCount).toBe(3) // CNN untouched
|
||||
})
|
||||
|
||||
it("errors clearly when the page is not found", () => {
|
||||
const { errors } = applyDiagramOperations(
|
||||
TWO_PAGE_MXFILE,
|
||||
[
|
||||
{
|
||||
operation: "delete",
|
||||
cell_id: "2",
|
||||
},
|
||||
],
|
||||
{ page_id: "does-not-exist" },
|
||||
)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].message).toMatch(/Page.*not found/i)
|
||||
// Page-level errors carry an empty cellId — edit_diagram relies on
|
||||
// this to distinguish "nothing applied" from per-cell warnings and
|
||||
// return a hard error instead of a false success.
|
||||
expect(errors[0].cellId).toBe("")
|
||||
})
|
||||
|
||||
it("delete on page 2 does NOT touch page 1's mxCell with the same id", () => {
|
||||
// Both pages have a cell with id="2". A delete on CNN's "2" must not
|
||||
// remove Transformer's "2".
|
||||
const { result, errors } = applyDiagramOperations(
|
||||
TWO_PAGE_MXFILE,
|
||||
[{ operation: "delete", cell_id: "2" }],
|
||||
{ page_id: "page-cnn" },
|
||||
)
|
||||
expect(errors).toHaveLength(0)
|
||||
const doc = parseMxfile(result)!
|
||||
const pages = listPagesFromDoc(doc)
|
||||
// CNN lost its only non-sentinel cell, Transformer keeps its three.
|
||||
expect(pages[1].cellCount).toBe(2)
|
||||
expect(pages[0].cellCount).toBe(3)
|
||||
})
|
||||
|
||||
it("legacy bare-mxGraphModel input still works when no selector is given", () => {
|
||||
const { result, errors } = applyDiagramOperations(BARE_MODEL_ONE_CELL, [
|
||||
{
|
||||
operation: "add",
|
||||
cell_id: "new",
|
||||
new_xml: `<mxCell id="new" vertex="1" parent="1"><mxGeometry x="100" y="100" width="50" height="50" as="geometry"/></mxCell>`,
|
||||
},
|
||||
])
|
||||
expect(errors).toHaveLength(0)
|
||||
expect(result).toContain(`id="new"`)
|
||||
})
|
||||
|
||||
it("page selector on a bare mxGraphModel returns a clear error", () => {
|
||||
const { errors } = applyDiagramOperations(
|
||||
BARE_MODEL_ONE_CELL,
|
||||
[{ operation: "delete", cell_id: "2" }],
|
||||
{ page_id: "page-1" },
|
||||
)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].message).toMatch(/not multi-page/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe("export_diagram — single-page projection (regression for selectPage bug)", () => {
|
||||
// The previous implementation tried to drive drawio's iframe with an
|
||||
// `action: 'selectPage'` postMessage, which the embed protocol silently
|
||||
// ignores. The result was that PNG/SVG exports targeted the currently
|
||||
// active tab regardless of the page selector — two visually different
|
||||
// pages would yield byte-identical PNGs.
|
||||
//
|
||||
// The current implementation builds a single-page <mxfile> projection via
|
||||
// the shared pages.ts:projectPage helper and hands it to the browser
|
||||
// bridge to load BEFORE triggering export. These tests pin that helper so
|
||||
// a future refactor can't silently re-introduce the multi-page drift.
|
||||
function projectSinglePage(fullMxfile: string, sel: any): string {
|
||||
const result = projectPage(fullMxfile, sel)
|
||||
if (!result.ok) throw new Error(`projection failed: ${result.reason}`)
|
||||
return result.xml
|
||||
}
|
||||
|
||||
it("returns a parse error for a non-mxfile source", () => {
|
||||
const result = projectPage(BARE_MODEL_ONE_CELL, { page_id: "x" })
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe("parse")
|
||||
})
|
||||
|
||||
it("returns a notfound error for an unknown page", () => {
|
||||
const result = projectPage(TWO_PAGE_MXFILE, { page_id: "ghost" })
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe("notfound")
|
||||
})
|
||||
|
||||
it("projects only the requested page when targeted by id", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_id: "page-cnn",
|
||||
})
|
||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||
expect(pages).toHaveLength(1)
|
||||
expect(pages[0].id).toBe("page-cnn")
|
||||
expect(pages[0].name).toBe("CNN")
|
||||
// The projection must NOT contain the Transformer page anywhere.
|
||||
expect(projected).not.toContain('id="page-transformer"')
|
||||
expect(projected).not.toContain('name="Transformer"')
|
||||
})
|
||||
|
||||
it("projects only the requested page when targeted by name", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_name: "Transformer",
|
||||
})
|
||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||
expect(pages).toHaveLength(1)
|
||||
expect(pages[0].name).toBe("Transformer")
|
||||
expect(projected).not.toContain('id="page-cnn"')
|
||||
})
|
||||
|
||||
it("projects only the requested page when targeted by index", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_index: 1,
|
||||
})
|
||||
const pages = listPagesFromDoc(parseMxfile(projected)!)
|
||||
expect(pages).toHaveLength(1)
|
||||
expect(pages[0].index).toBe(0) // re-indexed: it's the only page in the projection
|
||||
expect(pages[0].id).toBe("page-cnn")
|
||||
})
|
||||
|
||||
it("two different page selectors produce visually distinct projections", () => {
|
||||
// The regression: under the old selectPage bug, two exports would
|
||||
// return the same active tab. With the projection approach, the
|
||||
// payload that drawio renders is provably different.
|
||||
const a = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_id: "page-transformer",
|
||||
})
|
||||
const b = projectSinglePage(TWO_PAGE_MXFILE, { page_id: "page-cnn" })
|
||||
expect(a).not.toBe(b)
|
||||
expect(a).toContain('"Encoder"')
|
||||
expect(a).not.toContain('"Conv1"')
|
||||
expect(b).toContain('"Conv1"')
|
||||
expect(b).not.toContain('"Encoder"')
|
||||
})
|
||||
|
||||
it("the projection parses to a valid one-page mxfile", () => {
|
||||
const projected = projectSinglePage(TWO_PAGE_MXFILE, {
|
||||
page_id: "page-cnn",
|
||||
})
|
||||
// Validator accepts it.
|
||||
expect(validateAndFixXml(projected).valid).toBe(true)
|
||||
// And it has a real <root> with the cells from the source page.
|
||||
const doc = parseMxfile(projected)!
|
||||
const root = doc.querySelector("root")
|
||||
expect(root).not.toBeNull()
|
||||
const conv1 = doc.querySelector('mxCell[value="Conv1"]')
|
||||
expect(conv1).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("end-to-end — Transformer + CNN scenario (AC6)", () => {
|
||||
it("two tool-equivalent steps reproduce the motivating user scenario", () => {
|
||||
// Step 1 — caller passes a single-page mxfile.
|
||||
const step1 = normalizeToMxfile(BARE_MODEL_ONE_CELL, {
|
||||
pageId: "page-transformer",
|
||||
pageName: "Transformer",
|
||||
})
|
||||
expect(step1).not.toBeNull()
|
||||
let xml = step1 as string
|
||||
const validate1 = validateAndFixXml(xml)
|
||||
expect(validate1.valid).toBe(true)
|
||||
|
||||
// Step 2 — equivalent of add_page("CNN") with a starting model.
|
||||
const doc = parseMxfile(xml)!
|
||||
addPageToDoc(doc, {
|
||||
id: "page-cnn",
|
||||
name: "CNN",
|
||||
xml: `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" vertex="1" parent="1" value="Conv1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`,
|
||||
})
|
||||
xml = serializeMxfile(doc)
|
||||
|
||||
// Now: two pages, both valid, with the right names.
|
||||
const pages = listPagesFromDoc(parseMxfile(xml)!)
|
||||
expect(pages.map((p) => p.name)).toEqual(["Transformer", "CNN"])
|
||||
expect(validateAndFixXml(xml).valid).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
|
||||
* and drive it the way a real MCP client does — initialize handshake,
|
||||
* tools/list — to catch registration/schema regressions that the unit tests
|
||||
* (which import helpers directly) can't see.
|
||||
*
|
||||
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
|
||||
* dist/index.js and was therefore never run in CI (CI doesn't build this
|
||||
* package before testing). Running from source via tsx means it executes as
|
||||
* part of the normal `vitest run`.
|
||||
*
|
||||
* We deliberately do NOT call start_session — it would open a real browser
|
||||
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
||||
*/
|
||||
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const entry = path.resolve(__dirname, "..", "src", "index.ts")
|
||||
const tsxBin = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"node_modules",
|
||||
".bin",
|
||||
process.platform === "win32" ? "tsx.cmd" : "tsx",
|
||||
)
|
||||
|
||||
const EXPECTED_TOOLS = [
|
||||
"start_session",
|
||||
"create_new_diagram",
|
||||
"load_diagram",
|
||||
"edit_diagram",
|
||||
"get_diagram",
|
||||
"export_diagram",
|
||||
"list_pages",
|
||||
"add_page",
|
||||
"rename_page",
|
||||
"delete_page",
|
||||
]
|
||||
|
||||
let proc: ChildProcessWithoutNullStreams
|
||||
let stdoutBuf = ""
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
|
||||
>()
|
||||
let nextId = 1
|
||||
|
||||
function send(method: string, params: unknown, isNotification = false) {
|
||||
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
|
||||
if (!isNotification) msg.id = nextId++
|
||||
proc.stdin.write(`${JSON.stringify(msg)}\n`)
|
||||
if (isNotification) return Promise.resolve(undefined)
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const id = msg.id as number
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
reject(new Error(`Timed out waiting for response to ${method}`))
|
||||
}, 15000)
|
||||
pending.set(id, { resolve, reject, timeout })
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
proc = spawn(tsxBin, [entry], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}) as ChildProcessWithoutNullStreams
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBuf += chunk.toString()
|
||||
const lines = stdoutBuf.split("\n")
|
||||
stdoutBuf = lines.pop() || ""
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
let msg: any
|
||||
try {
|
||||
msg = JSON.parse(trimmed)
|
||||
} catch {
|
||||
// Non-JSON-RPC log line — ignore.
|
||||
continue
|
||||
}
|
||||
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
|
||||
if (p) {
|
||||
clearTimeout(p.timeout)
|
||||
pending.delete(msg.id)
|
||||
p.resolve(msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const initResp = await send("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "wiring-test", version: "0.0.0" },
|
||||
})
|
||||
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
|
||||
expect(initResp.result?.serverInfo?.name).toBeTruthy()
|
||||
await send("notifications/initialized", {}, true)
|
||||
}, 30000)
|
||||
|
||||
afterAll(() => {
|
||||
proc?.kill("SIGTERM")
|
||||
})
|
||||
|
||||
describe("MCP server wiring", () => {
|
||||
it("registers all nine multi-page tools", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
||||
const names: string[] = (resp.result?.tools ?? []).map(
|
||||
(t: { name: string }) => t.name,
|
||||
)
|
||||
for (const expected of EXPECTED_TOOLS) {
|
||||
expect(names, `missing tool: ${expected}`).toContain(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it("advertises page-selector params on edit_diagram", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
const edit = resp.result.tools.find(
|
||||
(t: { name: string }) => t.name === "edit_diagram",
|
||||
)
|
||||
const props = edit?.inputSchema?.properties ?? {}
|
||||
expect(props.page_id).toBeTruthy()
|
||||
expect(props.page_name).toBeTruthy()
|
||||
expect(props.page_index).toBeTruthy()
|
||||
})
|
||||
|
||||
it("advertises name/id/xml on add_page", async () => {
|
||||
const resp = await send("tools/list", {})
|
||||
const addPage = resp.result.tools.find(
|
||||
(t: { name: string }) => t.name === "add_page",
|
||||
)
|
||||
const props = addPage?.inputSchema?.properties ?? {}
|
||||
expect(props.name).toBeTruthy()
|
||||
expect(props.id).toBeTruthy()
|
||||
expect(props.xml).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Tests for XML syntax checking, autoFixXml and the XML serializer.
|
||||
*
|
||||
* linkedom (the DOM used in Node) parses leniently and never reports syntax
|
||||
* errors, so validation relies on the strict check in dom.ts. autoFixXml
|
||||
* runs on the whole document whenever any check fails, so its steps must
|
||||
* leave valid parts of the document untouched.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
import { getXmlSyntaxError, installDomPolyfill } from "../src/dom.js"
|
||||
|
||||
beforeAll(() => {
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.js"
|
||||
import { validateAndFixXml } from "../src/xml-validation.js"
|
||||
|
||||
/** Bare model with the root cells plus the given cells. */
|
||||
const model = (cells: string) =>
|
||||
`<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
|
||||
|
||||
// A bare & makes the first validation fail, which triggers autoFixXml on
|
||||
// the whole document.
|
||||
const BROKEN_CELL = `<mxCell id="9" value="R&D" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
|
||||
|
||||
describe("getXmlSyntaxError", () => {
|
||||
it("accepts well-formed XML", () => {
|
||||
expect(getXmlSyntaxError(model(""))).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
["duplicate attribute", `<a style="x" style="y"/>`],
|
||||
["unquoted attribute", `<a id=2/>`],
|
||||
["missing space between attributes", `<a id="2"vertex="1"/>`],
|
||||
["bare ampersand", `<a v="R&D"/>`],
|
||||
["unclosed tag", `<a><b></a>`],
|
||||
["plain text", `hello`],
|
||||
])("reports %s", (_name, xml) => {
|
||||
expect(getXmlSyntaxError(xml)).toMatch(/^\d+:\d+: /)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateAndFixXml", () => {
|
||||
it("rejects a duplicate style attribute", () => {
|
||||
const r = validateAndFixXml(
|
||||
model(
|
||||
`<mxCell id="2" style="a=1;" style="b=1;" vertex="1" parent="1"/>`,
|
||||
),
|
||||
)
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toContain("duplicate attribute: style")
|
||||
})
|
||||
|
||||
it("rejects an unquoted attribute value", () => {
|
||||
const r = validateAndFixXml(
|
||||
model(`<mxCell id=2 vertex="1" parent="1"/>`),
|
||||
)
|
||||
expect(r.valid).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps style values intact while fixing another cell", () => {
|
||||
const cells = `<mxCell id="2" style="shape=cylinder3;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="2" target="2"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||
const r = validateAndFixXml(model(cells + BROKEN_CELL))
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain('style="shape=cylinder3;whiteSpace=wrap;"')
|
||||
expect(r.fixed).toContain('style="edgeStyle=orthogonalEdgeStyle;"')
|
||||
expect(r.fixed).toContain('value="R&D"')
|
||||
})
|
||||
|
||||
it("keeps " inside rich-text labels", () => {
|
||||
const rich = `<mxCell id="4" value="<font style="color: red;">Hi</font>" style="html=1;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>`
|
||||
const r = validateAndFixXml(model(rich + BROKEN_CELL))
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain(
|
||||
'value="<font style="color: red;">Hi</font>"',
|
||||
)
|
||||
expect(getXmlSyntaxError(r.fixed ?? "")).toBeNull()
|
||||
})
|
||||
|
||||
it("keeps UserObject and object wrappers", () => {
|
||||
const wrapped = `<UserObject id="u" label="L" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><object id="o" label="O"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></object>`
|
||||
const r = validateAndFixXml(model(wrapped + BROKEN_CELL))
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain('<UserObject id="u"')
|
||||
expect(r.fixed).toContain('<object id="o"')
|
||||
})
|
||||
|
||||
it("leaves one-cell-per-line XML alone while fixing another cell", () => {
|
||||
const xml = [
|
||||
"<mxGraphModel>",
|
||||
"<root>",
|
||||
'<mxCell id="0"/>',
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
BROKEN_CELL,
|
||||
'<mxCell id="3" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>',
|
||||
"</root>",
|
||||
"</mxGraphModel>",
|
||||
].join("\n")
|
||||
const r = validateAndFixXml(xml)
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixes).toEqual(["Escaped unescaped & characters"])
|
||||
})
|
||||
|
||||
it("leaves cells split over two lines alone while fixing another cell", () => {
|
||||
const xml = [
|
||||
"<mxGraphModel><root>",
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>',
|
||||
'<mxCell id="2" value="A" vertex="1" parent="1">',
|
||||
' <mxGeometry as="geometry"/></mxCell>',
|
||||
'<mxCell id="3" value="B" vertex="1" parent="1">',
|
||||
' <mxGeometry as="geometry"/></mxCell>',
|
||||
BROKEN_CELL,
|
||||
"</root></mxGraphModel>",
|
||||
].join("\n")
|
||||
const r = validateAndFixXml(xml)
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixes).toEqual(["Escaped unescaped & characters"])
|
||||
})
|
||||
|
||||
it("renames duplicate short ids without touching the attribute name", () => {
|
||||
const r = validateAndFixXml(
|
||||
model(
|
||||
`<mxCell id="d" vertex="1" parent="1"/><mxCell id="d" vertex="1" parent="1"/><mxCell id="i" vertex="1" parent="1"/><mxCell id="i" vertex="1" parent="1"/>`,
|
||||
),
|
||||
)
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain('id="d_dup1"')
|
||||
expect(r.fixed).toContain('id="i_dup1"')
|
||||
})
|
||||
|
||||
it("adds a missing space between attributes", () => {
|
||||
const r = validateAndFixXml(
|
||||
model(
|
||||
`<mxCell id="2"value="a" style="x=1;" vertex="1" parent="1"/>`,
|
||||
),
|
||||
)
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain('<mxCell id="2" value="a" style="x=1;"')
|
||||
})
|
||||
|
||||
it("fixes attribute values quoted with "", () => {
|
||||
const r = validateAndFixXml(
|
||||
model(
|
||||
`<mxCell id="2" value="Hello" vertex="1" parent="1"/>`,
|
||||
),
|
||||
)
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain('value="Hello"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("XML serializer and strict parsing in page helpers", () => {
|
||||
it("keeps line breaks and tabs in attribute values", () => {
|
||||
const xml = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="2" value="Multi-Head
Attention	x" vertex="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const out = serializeMxfile(parseMxfile(xml) as Document)
|
||||
expect(out).toContain('value="Multi-Head
Attention	x"')
|
||||
expect(out).not.toMatch(/value="[^"]*\n/)
|
||||
})
|
||||
|
||||
it("escapes special characters in attributes and text", () => {
|
||||
const xml = `<mxfile><diagram id="p" name="R&D">a < b<mxGraphModel><root><mxCell id="0" value="<b> & ""/></root></mxGraphModel></diagram></mxfile>`
|
||||
const out = serializeMxfile(parseMxfile(xml) as Document)
|
||||
expect(out).toBe(xml)
|
||||
})
|
||||
|
||||
it("parseMxfile returns null for malformed XML", () => {
|
||||
expect(
|
||||
parseMxfile(
|
||||
`<mxfile><diagram id="p" name="a" name="b"></diagram></mxfile>`,
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("addPageToDoc rejects malformed page XML", () => {
|
||||
const doc = parseMxfile(
|
||||
`<mxfile><diagram id="p" name="Page-1">${model("")}</diagram></mxfile>`,
|
||||
) as Document
|
||||
expect(() =>
|
||||
addPageToDoc(doc, {
|
||||
xml: model(`<mxCell id=2 vertex="1" parent="1"/>`),
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
environment: "node",
|
||||
// The package source uses Node16 module resolution with explicit .js
|
||||
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
|
||||
// transparently, so no extra alias config is needed.
|
||||
},
|
||||
})
|
||||
@@ -48,13 +48,12 @@ export function proxy(request: NextRequest) {
|
||||
if (pathnameIsMissingLocale) {
|
||||
const locale = getLocale(request)
|
||||
|
||||
// Redirect to localized path
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
|
||||
request.url,
|
||||
),
|
||||
)
|
||||
// Redirect to localized path. Cloning nextUrl keeps the basePath
|
||||
// (NEXT_PUBLIC_BASE_PATH) and query string, which
|
||||
// new URL("/...", request.url) would drop.
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = `/${locale}${pathname}`
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.9477 25.7973C30.9997 25.7973 29.4796 25.2568 28.3986 24.1871C27.3176 23.1174 26.7771 21.6085 26.7771 19.6492V12.0148H23.8157V8.28764H24.131C24.9755 8.28764 25.6286 8.06243 26.0902 7.62328C26.5407 7.17287 26.7771 6.53104 26.7771 5.68652V4.34655H30.9772V8.28764H34.9521V12.0148H30.9772V19.4353C30.9772 20.0095 31.0785 20.4937 31.2812 20.8991C31.4839 21.3045 31.7992 21.6085 32.2383 21.8224C32.6775 22.0364 33.2292 22.1377 33.9049 22.1377C34.0512 22.1377 34.2314 22.1377 34.4341 22.104C34.6368 22.0814 34.8282 22.0589 35.0196 22.0364V25.6059C34.7269 25.6509 34.3778 25.696 34.0062 25.7297C33.6233 25.7748 33.2743 25.7973 32.959 25.7973H32.9477Z" fill="#FAF7F8"/>
|
||||
<path d="M36.5734 25.6059V1.52026H40.7734V25.6059H36.5734Z" fill="#FAF7F8"/>
|
||||
<path d="M48.284 25.9887C47.0792 25.9887 46.0207 25.7861 45.1199 25.3807C44.219 24.9753 43.5209 24.401 43.0367 23.6466C42.5525 22.8922 42.3048 22.0139 42.3048 21.023C42.3048 20.0321 42.5188 19.2101 42.9579 18.4556C43.3971 17.7012 44.0501 17.0706 44.951 16.5639C45.8405 16.0572 46.9665 15.6969 48.329 15.4829L53.9592 14.5596V17.7462L49.1173 18.602C48.2953 18.7484 47.6759 19.0074 47.2706 19.379C46.8652 19.7506 46.6625 20.246 46.6625 20.8541C46.6625 21.4621 46.8877 21.9238 47.3494 22.2729C47.7998 22.6219 48.3741 22.8021 49.0497 22.8021C49.9167 22.8021 50.6937 22.6219 51.358 22.2503C52.0224 21.8787 52.5404 21.3608 52.9007 20.7077C53.261 20.0546 53.4412 19.3339 53.4412 18.5795V14.0867C53.4412 13.3435 53.1597 12.7242 52.5854 12.2287C52.0111 11.7333 51.2454 11.4855 50.2996 11.4855C49.41 11.4855 48.6218 11.7333 47.9237 12.2175C47.2368 12.7017 46.7301 13.3322 46.4148 14.0979L43.0142 12.4427C43.352 11.5306 43.8925 10.7424 44.6244 10.0668C45.3563 9.39114 46.2234 8.87317 47.2143 8.49032C48.2164 8.10748 49.2974 7.91605 50.4572 7.91605C51.876 7.91605 53.1259 8.17504 54.2069 8.69301C55.2879 9.21098 56.1324 9.94289 56.7404 10.8775C57.3485 11.8121 57.6525 12.8818 57.6525 14.0867V25.6059H53.7114V22.6444L54.601 22.6107C54.1506 23.3313 53.6214 23.9506 52.9908 24.4573C52.3602 24.9641 51.6621 25.3469 50.8851 25.6059C50.1082 25.8649 49.2411 25.9887 48.2953 25.9887H48.284Z" fill="#FAF7F8"/>
|
||||
<path d="M66.4804 25.9887C64.6337 25.9887 63.0235 25.5496 61.661 24.6713C60.2872 23.793 59.3414 22.5994 58.8121 21.0905L61.965 19.5929C62.4154 20.5726 63.0347 21.3383 63.8229 21.89C64.6224 22.4418 65.5007 22.712 66.4804 22.712C67.2235 22.712 67.8203 22.5431 68.2595 22.2053C68.7099 21.8675 68.9238 21.4171 68.9238 20.8653C68.9238 20.5275 68.8338 20.246 68.6536 20.0208C68.4734 19.7956 68.237 19.6042 67.9329 19.4465C67.6402 19.2889 67.2911 19.1538 66.9195 19.0524L64.0819 18.253C62.6406 17.8476 61.5371 17.2058 60.7827 16.3275C60.0282 15.4492 59.6566 14.4132 59.6566 13.2196C59.6566 12.1612 59.9269 11.2266 60.4674 10.4383C61.0079 9.65013 61.7623 9.01955 62.7307 8.58041C63.6991 8.13 64.8026 7.91605 66.0525 7.91605C67.6852 7.91605 69.1265 8.31016 70.3764 9.09838C71.6263 9.88659 72.5159 10.9901 73.0451 12.4089L69.8584 13.9065C69.5657 13.1183 69.059 12.499 68.3608 12.0486C67.6627 11.5982 66.8745 11.3617 66.0074 11.3617C65.3093 11.3617 64.7575 11.5193 64.3522 11.8234C63.9468 12.1274 63.7441 12.5553 63.7441 13.0845C63.7441 13.3773 63.8342 13.6475 64.0031 13.884C64.172 14.1204 64.4085 14.3119 64.7238 14.4583C65.0278 14.6046 65.3881 14.7398 65.7935 14.8749L68.5635 15.6969C69.9823 16.1248 71.0858 16.7553 71.8628 17.6111C72.6397 18.4556 73.0226 19.5028 73.0226 20.7302C73.0226 21.7661 72.7411 22.6895 72.2006 23.4777C71.6488 24.2772 70.8831 24.8965 69.9147 25.3356C68.9351 25.7861 67.7978 26 66.4804 26V25.9887Z" fill="#FAF7F8"/>
|
||||
<path d="M90.2283 25.9889C88.528 25.9889 86.9628 25.6849 85.5215 25.0656C84.0802 24.4463 82.8303 23.5905 81.7718 22.487C80.7134 21.3835 79.8801 20.0886 79.2721 18.6022C78.664 17.1159 78.36 15.4944 78.36 13.7378C78.36 11.9812 78.6527 10.3484 79.2495 8.85083C79.8463 7.35322 80.6796 6.05829 81.7493 4.96605C82.819 3.8738 84.0689 3.02929 85.499 2.42123C86.929 1.81318 88.5055 1.50915 90.2283 1.50915C91.9511 1.50915 93.4487 1.79066 94.8 2.36493C96.1512 2.9392 97.2885 3.69364 98.2231 4.6395C99.1577 5.58536 99.822 6.6213 100.227 7.74733L96.3426 9.59401C95.8922 8.38916 95.149 7.39826 94.0793 6.6213C93.0208 5.84435 91.7372 5.4615 90.2283 5.4615C88.7194 5.4615 87.4357 5.81057 86.2985 6.5087C85.1612 7.20684 84.2829 8.17522 83.6523 9.40258C83.0217 10.63 82.7177 12.0713 82.7177 13.7265C82.7177 15.3818 83.033 16.8343 83.6523 18.073C84.2829 19.3116 85.1612 20.28 86.2985 20.9894C87.4357 21.6875 88.7419 22.0366 90.2283 22.0366C91.7146 22.0366 93.0208 21.6537 94.0793 20.8768C95.1378 20.0998 95.8922 19.1202 96.3426 17.9378L100.227 19.7507C99.822 20.8768 99.1577 21.9127 98.2231 22.8586C97.2885 23.8044 96.1512 24.5589 94.8 25.1331C93.4487 25.7074 91.9286 25.9889 90.2283 25.9889Z" fill="#FAF7F8"/>
|
||||
<path d="M101.748 25.6059V1.52026H105.948V25.6059H101.748Z" fill="#FAF7F8"/>
|
||||
<path d="M116.645 25.9884C114.967 25.9884 113.436 25.5943 112.051 24.806C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.49 18.6917 107.49 16.9464C107.49 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86369 112.04 9.08674C113.413 8.29852 114.956 7.90441 116.656 7.90441C118.357 7.90441 119.922 8.29852 121.307 9.08674C122.681 9.87495 123.784 10.9334 124.606 12.2846C125.417 13.6359 125.833 15.1898 125.833 16.9464C125.833 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.669 24.0291 121.284 24.806C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1711C117.602 22.1711 118.435 21.9459 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6488C121.25 18.8494 121.453 17.9598 121.453 16.9576C121.453 15.9555 121.25 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.435 11.9919 117.602 11.7779 116.645 11.7779C115.688 11.7779 114.888 12.0031 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.467 14.289C112.051 15.0772 111.848 15.9667 111.848 16.9576C111.848 17.9485 112.051 18.8494 112.467 19.6488C112.884 20.4483 113.447 21.0676 114.168 21.5068C114.888 21.9572 115.722 22.1711 116.645 22.1711Z" fill="#FAF7F8"/>
|
||||
<path d="M133.535 25.9883C132.173 25.9883 131.013 25.6956 130.033 25.0988C129.054 24.502 128.299 23.68 127.77 22.6215C127.241 21.5631 126.97 20.3244 126.97 18.8944V8.2985H131.171V18.5453C131.171 19.266 131.317 19.8965 131.598 20.437C131.88 20.9775 132.297 21.4054 132.837 21.7094C133.378 22.0135 133.986 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7094C137.015 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.2209 138.174 18.4552V8.2985H142.341V25.6055H138.4V22.2049L138.715 22.8129C138.31 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.763 25.9996 133.535 25.9996V25.9883Z" fill="#FAF7F8"/>
|
||||
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.9204 148.207 9.12093C149.525 8.31019 151.011 7.91608 152.666 7.91608C153.984 7.91608 155.155 8.17506 156.179 8.69304C157.204 9.21101 158.015 9.94292 158.612 10.8775L157.97 11.7333V1.52026H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0407 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1715C154.074 22.1715 154.907 21.9463 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9446 157.778 15.0663 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7783 153.139 11.7783C152.205 11.7783 151.371 12.0035 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1715 153.139 22.1715Z" fill="#FAF7F8"/>
|
||||
<path d="M13.4447 0L0 25.9886C6.22692 23.5226 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06449C13.4447 9.06449 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1103 26.8895 25.9886L13.4447 0Z" fill="#FAF7F8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="163" height="26" viewBox="0 0 163 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.9475 25.7973C30.9995 25.7973 29.4793 25.2568 28.3984 24.1871C27.3174 23.1174 26.7769 21.6085 26.7769 19.6492V12.0148H23.8154V8.28766H24.1307C24.9752 8.28766 25.6283 8.06245 26.09 7.6233C26.5404 7.17289 26.7769 6.53106 26.7769 5.68654V4.34657H30.9769V8.28766H34.9518V12.0148H30.9769V19.4353C30.9769 20.0096 31.0783 20.4938 31.281 20.8991C31.4837 21.3045 31.7989 21.6085 32.2381 21.8225C32.6772 22.0364 33.229 22.1378 33.9046 22.1378C34.051 22.1378 34.2312 22.1378 34.4338 22.104C34.6365 22.0814 34.828 22.0589 35.0194 22.0364V25.6059C34.7266 25.6509 34.3775 25.696 34.006 25.7298C33.6231 25.7748 33.274 25.7973 32.9588 25.7973H32.9475Z" fill="#0F1111"/>
|
||||
<path d="M36.5732 25.6059V1.52028H40.7733V25.6059H36.5732Z" fill="#0F1111"/>
|
||||
<path d="M48.2839 25.9888C47.079 25.9888 46.0206 25.7861 45.1197 25.3807C44.2189 24.9753 43.5208 24.4011 43.0366 23.6466C42.5524 22.8922 42.3047 22.0139 42.3047 21.023C42.3047 20.0321 42.5186 19.2101 42.9578 18.4557C43.3969 17.7012 44.05 17.0706 44.9508 16.5639C45.8404 16.0572 46.9664 15.6969 48.3289 15.483L53.959 14.5596V17.7463L49.1171 18.602C48.2951 18.7484 47.6758 19.0074 47.2705 19.379C46.8651 19.7506 46.6624 20.246 46.6624 20.8541C46.6624 21.4621 46.8876 21.9238 47.3493 22.2729C47.7997 22.6219 48.374 22.8021 49.0496 22.8021C49.9166 22.8021 50.6936 22.6219 51.3579 22.2504C52.0223 21.8788 52.5403 21.3608 52.9006 20.7077C53.2609 20.0546 53.4411 19.334 53.4411 18.5795V14.0867C53.4411 13.3435 53.1596 12.7242 52.5853 12.2287C52.011 11.7333 51.2453 11.4856 50.2995 11.4856C49.4099 11.4856 48.6217 11.7333 47.9236 12.2175C47.2367 12.7017 46.73 13.3322 46.4147 14.0979L43.0141 12.4427C43.3519 11.5306 43.8924 10.7424 44.6243 10.0668C45.3562 9.39116 46.2233 8.87319 47.2142 8.49034C48.2163 8.10749 49.2973 7.91607 50.4571 7.91607C51.8759 7.91607 53.1258 8.17505 54.2068 8.69303C55.2878 9.211 56.1323 9.94291 56.7403 10.8775C57.3484 11.8121 57.6524 12.8818 57.6524 14.0867V25.6059H53.7113V22.6445L54.6009 22.6107C54.1505 23.3313 53.6212 23.9507 52.9907 24.4574C52.3601 24.9641 51.662 25.3469 50.885 25.6059C50.108 25.8649 49.241 25.9888 48.2951 25.9888H48.2839Z" fill="#0F1111"/>
|
||||
<path d="M66.4802 25.9888C64.6336 25.9888 63.0233 25.5496 61.6609 24.6713C60.2871 23.793 59.3412 22.5994 58.812 21.0906L61.9649 19.5929C62.4153 20.5726 63.0346 21.3383 63.8228 21.89C64.6223 22.4418 65.5006 22.712 66.4802 22.712C67.2234 22.712 67.8202 22.5431 68.2594 22.2053C68.7098 21.8675 68.9237 21.4171 68.9237 20.8653C68.9237 20.5275 68.8336 20.246 68.6535 20.0208C68.4733 19.7956 68.2368 19.6042 67.9328 19.4466C67.64 19.2889 67.291 19.1538 66.9194 19.0524L64.0818 18.253C62.6405 17.8476 61.537 17.2058 60.7826 16.3275C60.0281 15.4492 59.6565 14.4132 59.6565 13.2196C59.6565 12.1612 59.9268 11.2266 60.4673 10.4384C61.0078 9.65015 61.7622 9.01957 62.7306 8.58042C63.699 8.13001 64.8025 7.91607 66.0524 7.91607C67.6851 7.91607 69.1264 8.31018 70.3763 9.09839C71.6262 9.88661 72.5157 10.9901 73.045 12.4089L69.8583 13.9065C69.5656 13.1183 69.0588 12.499 68.3607 12.0486C67.6626 11.5982 66.8743 11.3617 66.0073 11.3617C65.3092 11.3617 64.7574 11.5193 64.3521 11.8234C63.9467 12.1274 63.744 12.5553 63.744 13.0845C63.744 13.3773 63.8341 13.6475 64.003 13.884C64.1719 14.1205 64.4084 14.3119 64.7236 14.4583C65.0277 14.6047 65.388 14.7398 65.7934 14.8749L68.5634 15.6969C69.9822 16.1248 71.0857 16.7554 71.8626 17.6111C72.6396 18.4557 73.0224 19.5029 73.0224 20.7302C73.0224 21.7662 72.7409 22.6895 72.2005 23.4777C71.6487 24.2772 70.883 24.8965 69.9146 25.3357C68.935 25.7861 67.7977 26 66.4802 26V25.9888Z" fill="#0F1111"/>
|
||||
<path d="M90.2282 25.9889C88.5279 25.9889 86.9627 25.6849 85.5214 25.0656C84.0801 24.4463 82.8302 23.5905 81.7717 22.487C80.7133 21.3835 79.88 20.0886 79.2719 18.6022C78.6639 17.1159 78.3599 15.4944 78.3599 13.7378C78.3599 11.9812 78.6526 10.3485 79.2494 8.85085C79.8462 7.35324 80.6795 6.05831 81.7492 4.96606C82.8189 3.87382 84.0688 3.0293 85.4989 2.42125C86.9289 1.8132 88.5053 1.50917 90.2282 1.50917C91.951 1.50917 93.4486 1.79068 94.7998 2.36495C96.1511 2.93922 97.2884 3.69366 98.223 4.63952C99.1576 5.58538 99.8219 6.62132 100.227 7.74735L96.3425 9.59403C95.8921 8.38918 95.1489 7.39828 94.0792 6.62132C93.0207 5.84436 91.737 5.46152 90.2282 5.46152C88.7193 5.46152 87.4356 5.81058 86.2983 6.50872C85.1611 7.20685 84.2828 8.17523 83.6522 9.4026C83.0216 10.63 82.7176 12.0713 82.7176 13.7265C82.7176 15.3818 83.0329 16.8344 83.6522 18.073C84.2828 19.3116 85.1611 20.28 86.2983 20.9894C87.4356 21.6875 88.7418 22.0366 90.2282 22.0366C91.7145 22.0366 93.0207 21.6537 94.0792 20.8768C95.1376 20.0998 95.8921 19.1202 96.3425 17.9379L100.227 19.7508C99.8219 20.8768 99.1576 21.9127 98.223 22.8586C97.2884 23.8045 96.1511 24.5589 94.7998 25.1332C93.4486 25.7074 91.9285 25.9889 90.2282 25.9889Z" fill="#0F1111"/>
|
||||
<path d="M101.748 25.6059V1.52028H105.948V25.6059H101.748Z" fill="#0F1111"/>
|
||||
<path d="M116.645 25.9884C114.968 25.9884 113.436 25.5943 112.051 24.8061C110.666 24.0178 109.551 22.9481 108.729 21.5969C107.907 20.2344 107.491 18.6917 107.491 16.9464C107.491 15.2011 107.907 13.6584 108.729 12.2959C109.551 10.9334 110.655 9.86371 112.04 9.08676C113.414 8.29854 114.956 7.90443 116.657 7.90443C118.357 7.90443 119.922 8.29854 121.307 9.08676C122.681 9.87497 123.784 10.9334 124.606 12.2847C125.417 13.6359 125.834 15.1898 125.834 16.9464C125.834 18.703 125.417 20.2344 124.595 21.5969C123.773 22.9594 122.67 24.0291 121.285 24.8061C119.911 25.5943 118.368 25.9884 116.668 25.9884H116.645ZM116.645 22.1712C117.602 22.1712 118.436 21.946 119.145 21.5068C119.854 21.0564 120.417 20.4371 120.834 19.6489C121.251 18.8494 121.453 17.9598 121.453 16.9577C121.453 15.9555 121.251 15.0659 120.834 14.289C120.417 13.5008 119.854 12.8927 119.145 12.4423C118.436 11.9919 117.602 11.778 116.645 11.778C115.688 11.778 114.889 12.0032 114.168 12.4423C113.447 12.8927 112.884 13.5008 112.468 14.289C112.051 15.0772 111.848 15.9668 111.848 16.9577C111.848 17.9486 112.051 18.8494 112.468 19.6489C112.884 20.4483 113.447 21.0677 114.168 21.5068C114.889 21.9572 115.722 22.1712 116.645 22.1712Z" fill="#0F1111"/>
|
||||
<path d="M133.535 25.9884C132.172 25.9884 131.013 25.6956 130.033 25.0988C129.053 24.502 128.299 23.68 127.77 22.6215C127.24 21.5631 126.97 20.3245 126.97 18.8944V8.29852H131.17V18.5453C131.17 19.266 131.317 19.8966 131.598 20.4371C131.88 20.9775 132.296 21.4054 132.837 21.7095C133.377 22.0135 133.985 22.1711 134.672 22.1711C135.359 22.1711 135.956 22.0135 136.485 21.7095C137.014 21.4054 137.431 20.9775 137.724 20.4258C138.017 19.874 138.174 19.221 138.174 18.4553V8.29852H142.34V25.6055H138.399V22.2049L138.715 22.813C138.309 23.8714 137.656 24.6709 136.744 25.2001C135.832 25.7294 134.762 25.9996 133.535 25.9996V25.9884Z" fill="#0F1111"/>
|
||||
<path d="M152.7 25.9888C151.022 25.9888 149.525 25.5947 148.196 24.7952C146.867 23.9957 145.82 22.9147 145.066 21.5297C144.3 20.156 143.917 18.6246 143.917 16.9468C143.917 15.269 144.3 13.7264 145.077 12.3639C145.854 11.0014 146.901 9.92042 148.207 9.12094C149.525 8.31021 151.011 7.9161 152.666 7.9161C153.984 7.9161 155.155 8.17508 156.179 8.69305C157.204 9.21103 158.015 9.94294 158.612 10.8775L157.97 11.7333V1.52028H162.136V25.6059H158.195V22.2616L158.645 23.0836C158.049 24.0408 157.227 24.7614 156.168 25.2456C155.11 25.7298 153.95 25.9775 152.7 25.9775V25.9888ZM153.139 22.1716C154.074 22.1716 154.907 21.9464 155.639 21.5072C156.371 21.0568 156.945 20.4487 157.362 19.6605C157.778 18.8723 157.981 17.9715 157.981 16.9581C157.981 15.9447 157.778 15.0664 157.362 14.2894C156.945 13.5012 156.371 12.8931 155.639 12.4427C154.907 11.9923 154.074 11.7784 153.139 11.7784C152.205 11.7784 151.371 12.0036 150.628 12.4427C149.885 12.8931 149.311 13.5012 148.894 14.2894C148.477 15.0776 148.275 15.9672 148.275 16.9581C148.275 17.949 148.477 18.8836 148.894 19.6605C149.311 20.4487 149.885 21.0568 150.628 21.5072C151.371 21.9576 152.205 22.1716 153.139 22.1716Z" fill="#0F1111"/>
|
||||
<path d="M13.4447 1.71661e-05L0 25.9887C6.22692 23.5227 11.249 23.1623 15.7643 23.3763L13.7037 18.8159C12.8029 18.7258 10.1905 18.7258 8.9519 19.0523L13.4447 9.06451C13.4447 9.06451 20.2009 23.7366 20.2121 23.7366C21.5183 23.9393 24.9977 25.1104 26.8895 25.9887L13.4447 1.71661e-05Z" fill="#0F1111"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
+89
-68
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* Development script for running Electron with Next.js
|
||||
* 1. Reads preset configuration (if exists)
|
||||
* 1. Reads the active preset's env vars (if any)
|
||||
* 2. Starts Next.js dev server with preset env vars
|
||||
* 3. Waits for it to be ready
|
||||
* 4. Compiles Electron TypeScript
|
||||
@@ -47,39 +47,41 @@ function getUserDataPath() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preset configuration from config file
|
||||
* File where the Electron main process (in development) writes the active
|
||||
* preset's env vars, already decrypted and mapped to provider-specific keys
|
||||
* (see writeDevPresetEnv in electron/main/config-manager.ts)
|
||||
*/
|
||||
function loadPresetConfig() {
|
||||
const configPath = path.join(getUserDataPath(), "config-presets.json")
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
console.log("📋 No preset configuration found, using .env.local")
|
||||
return null
|
||||
}
|
||||
const PRESET_ENV_FILE = "dev-preset-env.json"
|
||||
|
||||
/**
|
||||
* Read the active preset's env vars as JSON text (null if not available)
|
||||
*/
|
||||
function readPresetEnvFile() {
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
|
||||
if (!data.currentPresetId) {
|
||||
console.log("📋 No active preset, using .env.local")
|
||||
return null
|
||||
}
|
||||
|
||||
const preset = data.presets.find((p) => p.id === data.currentPresetId)
|
||||
if (!preset) {
|
||||
console.log("📋 Active preset not found, using .env.local")
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`📋 Using preset: "${preset.name}"`)
|
||||
return preset.config
|
||||
} catch (error) {
|
||||
console.error("Failed to load preset config:", error.message)
|
||||
const content = readFileSync(
|
||||
path.join(getUserDataPath(), PRESET_ENV_FILE),
|
||||
"utf-8",
|
||||
)
|
||||
JSON.parse(content) // Ignore a half-written file
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the active preset's env vars
|
||||
*/
|
||||
function loadPresetEnv(content) {
|
||||
const env = content ? JSON.parse(content) : {}
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log("📋 No active preset, using .env.local")
|
||||
return null
|
||||
}
|
||||
console.log(`📋 Using preset env: ${Object.keys(env).join(", ")}`)
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the Next.js server to be ready
|
||||
*/
|
||||
@@ -128,6 +130,18 @@ function runCommand(command, args, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process started with shell: true. On Windows, kill() only ends the
|
||||
* cmd.exe wrapper and leaves next dev running, so kill the whole tree.
|
||||
*/
|
||||
function killProcess(proc) {
|
||||
if (process.platform === "win32" && proc.pid) {
|
||||
spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
|
||||
} else {
|
||||
proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Next.js dev server with preset environment
|
||||
*/
|
||||
@@ -164,7 +178,8 @@ async function main() {
|
||||
console.log("🚀 Starting Electron development environment...\n")
|
||||
|
||||
// Load preset configuration
|
||||
const presetEnv = loadPresetConfig()
|
||||
let presetEnvContent = readPresetEnvFile()
|
||||
const presetEnv = loadPresetEnv(presetEnvContent)
|
||||
|
||||
// Start Next.js dev server with preset env
|
||||
console.log("1. Starting Next.js development server...")
|
||||
@@ -176,7 +191,7 @@ async function main() {
|
||||
console.log("")
|
||||
} catch (err) {
|
||||
console.error("\n❌ Next.js server failed to start:", err.message)
|
||||
nextProcess.kill()
|
||||
killProcess(nextProcess)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -186,7 +201,7 @@ async function main() {
|
||||
await runCommand("npm", ["run", "electron:compile"])
|
||||
} catch (err) {
|
||||
console.error("❌ Electron compilation failed:", err.message)
|
||||
nextProcess.kill()
|
||||
killProcess(nextProcess)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -203,76 +218,82 @@ async function main() {
|
||||
},
|
||||
})
|
||||
|
||||
// Watch for preset config changes
|
||||
const configPath = path.join(getUserDataPath(), "config-presets.json")
|
||||
// Watch for preset env changes
|
||||
const userDataPath = getUserDataPath()
|
||||
let configWatcher = null
|
||||
let restartPending = false
|
||||
|
||||
function setupConfigWatcher() {
|
||||
if (!existsSync(path.dirname(configPath))) {
|
||||
if (!existsSync(userDataPath)) {
|
||||
// Directory doesn't exist yet, check again later
|
||||
setTimeout(setupConfigWatcher, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Watch the directory, since the file may not exist yet
|
||||
configWatcher = watch(
|
||||
configPath,
|
||||
userDataPath,
|
||||
{ persistent: false },
|
||||
async (eventType) => {
|
||||
if (eventType === "change" && !restartPending) {
|
||||
restartPending = true
|
||||
async (_eventType, filename) => {
|
||||
if (filename !== PRESET_ENV_FILE || restartPending) return
|
||||
|
||||
// Only restart when the preset env vars really changed
|
||||
const newContent = readPresetEnvFile()
|
||||
if (newContent === null || newContent === presetEnvContent)
|
||||
return
|
||||
|
||||
restartPending = true
|
||||
presetEnvContent = newContent
|
||||
console.log(
|
||||
"\n🔄 Preset configuration changed, restarting Next.js server...",
|
||||
)
|
||||
|
||||
// Kill current Next.js process
|
||||
killProcess(nextProcess)
|
||||
|
||||
// Wait a bit for process to die
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
|
||||
// Reload preset and restart
|
||||
nextProcess = startNextServer(loadPresetEnv(newContent))
|
||||
|
||||
try {
|
||||
await waitForServer(NEXT_URL)
|
||||
console.log(
|
||||
"\n🔄 Preset configuration changed, restarting Next.js server...",
|
||||
"✅ Next.js server restarted with new configuration\n",
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"❌ Failed to restart Next.js:",
|
||||
err.message,
|
||||
)
|
||||
|
||||
// Kill current Next.js process
|
||||
nextProcess.kill()
|
||||
|
||||
// Wait a bit for process to die
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
|
||||
// Reload preset and restart
|
||||
const newPresetEnv = loadPresetConfig()
|
||||
nextProcess = startNextServer(newPresetEnv)
|
||||
|
||||
try {
|
||||
await waitForServer(NEXT_URL)
|
||||
console.log(
|
||||
"✅ Next.js server restarted with new configuration\n",
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"❌ Failed to restart Next.js:",
|
||||
err.message,
|
||||
)
|
||||
}
|
||||
|
||||
restartPending = false
|
||||
}
|
||||
|
||||
restartPending = false
|
||||
},
|
||||
)
|
||||
console.log("👀 Watching for preset configuration changes...")
|
||||
} catch (_err) {
|
||||
// File might not exist yet, that's ok
|
||||
// Directory might not be ready yet, try again later
|
||||
setTimeout(setupConfigWatcher, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
// Start watching after a delay (config file might not exist yet)
|
||||
// Start watching after a delay (user data directory might not exist yet)
|
||||
setTimeout(setupConfigWatcher, 2000)
|
||||
|
||||
electronProcess.on("close", (code) => {
|
||||
console.log(`\nElectron exited with code ${code}`)
|
||||
if (configWatcher) configWatcher.close()
|
||||
nextProcess.kill()
|
||||
killProcess(nextProcess)
|
||||
process.exit(code || 0)
|
||||
})
|
||||
|
||||
electronProcess.on("error", (err) => {
|
||||
console.error("Electron error:", err)
|
||||
if (configWatcher) configWatcher.close()
|
||||
nextProcess.kill()
|
||||
killProcess(nextProcess)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -280,8 +301,8 @@ async function main() {
|
||||
const cleanup = () => {
|
||||
console.log("\n🛑 Shutting down...")
|
||||
if (configWatcher) configWatcher.close()
|
||||
electronProcess.kill()
|
||||
nextProcess.kill()
|
||||
killProcess(electronProcess)
|
||||
killProcess(nextProcess)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,15 @@ mkdirSync(targetDir, { recursive: true })
|
||||
console.log("Copying standalone directory...")
|
||||
copyDereferenced(standaloneDir, targetDir)
|
||||
|
||||
// Next.js copies the build machine's .env files into standalone; don't ship
|
||||
// them, they can hold the builder's API keys
|
||||
for (const entry of readdirSync(targetDir)) {
|
||||
if (entry.startsWith(".env")) {
|
||||
console.warn(`Removing ${entry} so it is not packaged into the app`)
|
||||
rmSync(join(targetDir, entry))
|
||||
}
|
||||
}
|
||||
|
||||
// Copy static files
|
||||
console.log("Copying static files...")
|
||||
const targetStaticDir = join(targetDir, ".next", "static")
|
||||
|
||||
@@ -18,6 +18,26 @@ test.describe("Settings", () => {
|
||||
await expect(dialog.locator('text="English"')).toBeVisible()
|
||||
})
|
||||
|
||||
test("max output tokens is editable and persists", async ({ page }) => {
|
||||
await openSettings(page)
|
||||
|
||||
const input = page.locator("#max-output-tokens")
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.fill("48000")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
localStorage.getItem("next-ai-draw-io-max-output-tokens"),
|
||||
),
|
||||
)
|
||||
.toBe("48000")
|
||||
|
||||
// Non-digits are dropped so the header always carries a plain number
|
||||
await input.fill("12k000")
|
||||
await expect(input).toHaveValue("12000")
|
||||
})
|
||||
|
||||
test("draw.io theme toggle exists", async ({ page }) => {
|
||||
await openSettings(page)
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment node
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
import { GET, PUT } from "@/app/api/admin/providers/route"
|
||||
import { _resetForTests } from "@/lib/admin/settings"
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-route-"))
|
||||
process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json")
|
||||
process.env.ADMIN_PASSWORD = "pw"
|
||||
process.env.AI_MODELS_CONFIG_PATH = path.join(tmpDir, "none.json")
|
||||
_resetForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetForTests()
|
||||
delete process.env.SETTINGS_FILE
|
||||
delete process.env.ADMIN_PASSWORD
|
||||
delete process.env.AI_MODELS_CONFIG_PATH
|
||||
delete process.env.AI_MODEL
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const headers = { "x-admin-password": "pw" }
|
||||
|
||||
async function saveDefaultPanelProvider() {
|
||||
const res = await PUT(
|
||||
new Request("http://localhost/api/admin/providers", {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providers: [
|
||||
{
|
||||
id: "p1",
|
||||
provider: "openai",
|
||||
apiKey: "sk-test",
|
||||
models: ["gpt-panel"],
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
|
||||
async function envHasDefaultModel(): Promise<boolean> {
|
||||
const res = await GET(
|
||||
new Request("http://localhost/api/admin/providers", { headers }),
|
||||
)
|
||||
return (await res.json()).envHasDefaultModel
|
||||
}
|
||||
|
||||
describe("envHasDefaultModel", () => {
|
||||
it("is true when .env sets AI_MODEL, even after a panel default", async () => {
|
||||
process.env.AI_MODEL = "gpt-env"
|
||||
expect(await envHasDefaultModel()).toBe(true)
|
||||
await saveDefaultPanelProvider()
|
||||
expect(await envHasDefaultModel()).toBe(true)
|
||||
})
|
||||
|
||||
it("ignores the AI_MODEL the panel default writes", async () => {
|
||||
await saveDefaultPanelProvider()
|
||||
expect(process.env.AI_MODEL).toBe("gpt-panel")
|
||||
expect(await envHasDefaultModel()).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user