mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-04 00:37:48 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46787c1b8 | ||
|
|
95f4b4b92b | ||
|
|
3193d20e00 | ||
|
|
87edf2e19d | ||
|
|
5c7613ea09 | ||
|
|
79b4c52741 | ||
|
|
528b6e54c8 | ||
|
|
366480426d | ||
|
|
a45e5b6796 | ||
|
|
027cd88c90 | ||
|
|
155ef4f7ac | ||
|
|
12903cd516 | ||
|
|
96bca2b37b |
@@ -79,5 +79,3 @@ ai-models.json
|
||||
|
||||
# admin panel settings (contains secrets)
|
||||
data/
|
||||
# ...but the diagram engine's stencil catalog is generated source, not settings
|
||||
!lib/diagram-engine/data/
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
next-ai-draw-io
|
||||
Copyright the next-ai-draw-io contributors
|
||||
|
||||
This product includes software developed by third parties, as set out below.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
lib/diagram-engine/ — layout and rendering
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
The declarative layout algorithm (bottom-up measure, top-down place, sibling
|
||||
size equalisation), the swimlane-pool geometry, and the mxCell/style emission in
|
||||
`lib/diagram-engine/layout.ts` and `lib/diagram-engine/render.ts` are derived
|
||||
from drawio-ai-kit:
|
||||
|
||||
https://github.com/sparklabx/drawio-ai-kit
|
||||
Copyright (c) sparklabx
|
||||
Licensed under the MIT License
|
||||
|
||||
Original to this repository:
|
||||
|
||||
- the XML→tree reverse parser (`parse.ts`), which that project does not have
|
||||
- the style-marker scheme (`markers.ts`) that lets structure survive a
|
||||
round-trip through the draw.io editor
|
||||
- the structural-operations layer (`operations.ts`)
|
||||
- the invisible-container approach that replaces that project's "phantom" nodes
|
||||
- the edge router (`route.ts`)
|
||||
- the graph→layers pass (`graph.ts`): layer assignment, cycle breaking and
|
||||
barycentre crossing reduction, which turn a flat node/arrow list into a
|
||||
flowchart
|
||||
- the sequence-diagram and radial (mind map / org chart) layouts
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
lib/diagram-engine/data/aws-stencils.json — stencil catalog
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
A name→style map for the mxgraph.aws4 stencil family, generated from
|
||||
drawio-ai-kit's `catalog/aws.json`, which in turn was generated from the draw.io
|
||||
shape index published by jgraph:
|
||||
|
||||
https://github.com/jgraph/drawio-mcp
|
||||
Copyright (c) JGraph Ltd
|
||||
Licensed under the Apache License, Version 2.0
|
||||
|
||||
The style strings are reproduced verbatim from that index. They reference the
|
||||
official AWS Architecture Icons, which are trademarks of Amazon Web Services and
|
||||
are NOT covered by this repository's licence. Their use is governed by the AWS
|
||||
Architecture Icons terms:
|
||||
|
||||
https://aws.amazon.com/architecture/icons/
|
||||
|
||||
The catalog contains stencil *names and style strings* only — it does not embed
|
||||
any AWS icon artwork. draw.io supplies the artwork at render time.
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +110,6 @@ Here are some example prompts and their generated diagrams:
|
||||
- **Diagram History**: Comprehensive version control that tracks all changes, allowing you to view and restore previous versions of your diagrams before the AI editing.
|
||||
- **Interactive Chat Interface**: Communicate with AI to refine your diagrams in real-time
|
||||
- **Cloud Architecture Diagram Support**: Specialized support for generating cloud architecture diagrams (AWS, GCP, Azure)
|
||||
- **Computed Layout**: For architecture diagrams, flowcharts, swimlane/BPMN diagrams, sequence diagrams, mind maps and org charts, the AI declares only the structure — what contains what, or what points at what — and the app computes every coordinate, size and arrow route. Containers always fit their contents, siblings never overlap, and arrows are routed around the shapes they would otherwise cross. Anything you then move or recolour by hand is read back as part of the diagram, so a later edit does not undo it.
|
||||
- **Animated Connectors**: Create dynamic and animated connectors between diagram elements for better visualization
|
||||
|
||||
## MCP Server
|
||||
@@ -218,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.
|
||||
@@ -254,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:
|
||||
|
||||
@@ -160,11 +160,6 @@ export default function AboutCN() {
|
||||
<strong>AWS架构图支持</strong>
|
||||
:专门支持生成AWS架构图
|
||||
</li>
|
||||
<li>
|
||||
<strong>自动计算布局</strong>
|
||||
:画架构图、流程图、泳道图、时序图、思维导图和组织架构图时,AI
|
||||
只描述结构,所有坐标、尺寸和连线路径都由程序计算——容器一定装得下里面的内容,图形不会重叠,连线会绕开本来会穿过的图形
|
||||
</li>
|
||||
<li>
|
||||
<strong>动画连接器</strong>
|
||||
:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
|
||||
@@ -153,11 +153,6 @@ export default function AboutJA() {
|
||||
</strong>
|
||||
:AWSアーキテクチャダイアグラムの生成を専門的にサポート
|
||||
</li>
|
||||
<li>
|
||||
<strong>レイアウトの自動計算</strong>
|
||||
:アーキテクチャ図、フローチャート、スイムレーン図、シーケンス図、マインドマップ、組織図では、AI
|
||||
は構造だけを指定し、座標・サイズ・矢印の経路はすべてアプリが計算します——コンテナは必ず中身が収まり、図形は重ならず、矢印は本来通り抜けてしまう図形を避けて引かれます
|
||||
</li>
|
||||
<li>
|
||||
<strong>アニメーションコネクタ</strong>
|
||||
:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
|
||||
@@ -165,15 +165,6 @@ export default function About() {
|
||||
Specialized support for generating AWS architecture
|
||||
diagrams
|
||||
</li>
|
||||
<li>
|
||||
<strong>Computed Layout</strong>: For architecture
|
||||
diagrams, flowcharts, swimlane diagrams, sequence
|
||||
diagrams, mind maps and org charts, the AI declares
|
||||
only the structure and the app computes every
|
||||
coordinate, size and arrow route — so containers fit
|
||||
their contents, shapes never overlap, and arrows are
|
||||
routed around what they would otherwise cross
|
||||
</li>
|
||||
<li>
|
||||
<strong>Animated Connectors</strong>: Create dynamic
|
||||
and animated connectors between diagram elements for
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+220
-252
@@ -8,42 +8,54 @@ import {
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import { checkAccessCode } from "@/lib/access-code"
|
||||
import {
|
||||
getAIModel,
|
||||
SINGLE_SYSTEM_PROVIDERS,
|
||||
supportsPromptCaching,
|
||||
usesServerCredentials,
|
||||
} from "@/lib/ai-providers"
|
||||
import { findCachedResponse } from "@/lib/cached-responses"
|
||||
import {
|
||||
dropInvalidToolCalls,
|
||||
fixToolInputJson,
|
||||
isMinimalDiagram,
|
||||
replaceHistoricalToolInputs,
|
||||
validateFileParts,
|
||||
} from "@/lib/chat-helpers"
|
||||
import { OperationSchema, searchStencils } from "@/lib/diagram-engine"
|
||||
import {
|
||||
checkAndIncrementRequest,
|
||||
isQuotaEnabled,
|
||||
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.
|
||||
//
|
||||
// This replays a stored XML answer straight to the canvas, so it still speaks the
|
||||
// `display_diagram` wire format even though the model can no longer call that tool: the client
|
||||
// handler for it is what puts XML on the canvas. Nothing here goes through the model.
|
||||
// Helper function to create cached stream response
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
|
||||
@@ -73,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
|
||||
@@ -189,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")
|
||||
|
||||
@@ -198,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}`,
|
||||
)
|
||||
@@ -218,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"),
|
||||
@@ -228,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
|
||||
@@ -244,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(
|
||||
@@ -328,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)
|
||||
@@ -398,7 +420,7 @@ ${userInputText}
|
||||
contentParts.push({
|
||||
type: "image",
|
||||
image: filePart.url,
|
||||
mimeType: filePart.mediaType,
|
||||
mediaType: filePart.mediaType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -459,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
|
||||
? [
|
||||
@@ -496,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 }) => {
|
||||
@@ -516,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}`,
|
||||
)
|
||||
@@ -542,17 +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",
|
||||
},
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -575,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 &&
|
||||
@@ -584,14 +585,56 @@ 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: {
|
||||
description: `Display a diagram on draw.io. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. All mxCell elements must be siblings - never nested
|
||||
4. Every mxCell needs a unique id (start from "2")
|
||||
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
|
||||
6. Escape special chars in values: < > & "
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe("XML string to be displayed on draw.io"),
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||
|
||||
@@ -634,169 +677,87 @@ Example - Delete container (children & edges auto-deleted):
|
||||
.describe("Array of operations to apply"),
|
||||
}),
|
||||
},
|
||||
restructure_diagram: {
|
||||
description: `Build or edit a diagram by declaring STRUCTURE. The engine computes every coordinate.
|
||||
append_diagram: {
|
||||
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||
|
||||
PREFER THIS over edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
||||
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||
|
||||
The layout model is FLEXBOX. row/col containers nest freely; a box with internal structure is an invisible col container (pad 10-14) holding smaller boxes. Three knobs: grow (columns split leftover WIDTH by weight — grow 3 / grow 2 gives a 3:2 page; for containers in a row, not for leaf boxes), align "stretch" (child fills its column's width; content keeps natural height and packs to the top — the engine leaves leftover vertical space at the bottom, never inflates boxes to fill it, so balance columns by moving content between them), pad (8-14 tight card, default 24 roomy section). Labels take inline HTML — <b>, <i>, <font color="#...">, <br> — so one box carries a bold keyword, a second paragraph, a coloured verdict line. Paragraphs set themselves flush-left automatically; short labels centre. Emoji in headings (💡 Core Idea) read instantly.
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
DECLARE THE PAGE SHAPE FIRST, with set_page. aspect is width:height — 1 square, 1.4 a landscape slide, 0.75 a portrait poster, 1.6 a wide architecture diagram. This is the one thing that has to come before everything else: it gives the top level a definite width, and until there is one there is no spare room to share out, so grow weights and column fractions have no effect at all. A row that then cannot fit wraps onto a second line rather than running off to the right.
|
||||
|
||||
LAYOUT, TYPE AND SURFACE — Tailwind classes. Every add_container and add_box takes class, and it is the preferred way to say these things. Colour is the one thing a class never carries: that comes from role and group.
|
||||
proportion grow-3 / flex-3 / w-2/3 — a column's share of the row. Add min-w-0 to BOTH columns when you want the ratio exactly: without it a column will not shrink below the width of its own text, so a declared 3:1 lands wherever the text allows (this is how flexbox behaves in a browser too).
|
||||
direction flex-row, flex-col (or the dir field, which a class cannot override)
|
||||
cross axis items-stretch on the container (cards all span the same width — this is what makes a column line up), or self-start / self-center / self-end / self-stretch on one child
|
||||
main axis justify-start (default: packed, spare room at the far end) / justify-center / justify-end / justify-between / justify-around / justify-evenly. Reach for justify-between when a short column would otherwise leave a hole at the bottom.
|
||||
spacing gap-4 between children, p-6 inside. Tailwind's 4px scale, so gap-4 is 16px and p-6 is 24px. Use the scale; there is no gap-7.5.
|
||||
width cap max-w-md (448) or max-w-96, up to max-w-4xl. A capped box rewraps its text instead of stretching, which is what stops one long sentence flattening the page. A cap beats grow.
|
||||
type font-bold / font-normal, italic, underline, line-through, text-xs..text-4xl (12/14/16/18/20/24/30/36px), text-left|center|right, align-top|middle|bottom, whitespace-nowrap. An explicit alignment beats the engine's own "this looks like a paragraph" rule, so use text-center when you want a long label centred. line-through is for a superseded or cancelled step.
|
||||
border border or border-N for thickness, border-dashed / border-dotted / border-solid. A dashed frame is the conventional way to draw something planned, optional or purely logical. border-none removes the outline entirely, which is how you draw a plain colour field.
|
||||
corners rounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-3xl, rounded-4xl (4/4/6/8/12/16/24/32px), rounded-full for a capsule, rounded-none for square. Real pixels, so the same class is the same corner on every box. Overrides the corner of a shape that has one, which is what you want on round and terminator.
|
||||
elevation shadow-sm / shadow-md / shadow-lg / shadow-xl, shadow-none. Use it to lift a card off a panel; one level on one group of cards, not on everything.
|
||||
NOT supported, and dropped with a note telling you which: EVERY colour class (bg-*, text-red-*, border-blue-*) and gradients — colour comes from role and group; the seven font weights between font-thin and font-black, because draw.io has one bold bit rather than a weight ladder; opacity-* (Tailwind's is any number, not a scale); truncate (draw.io cannot draw the ellipsis, so text would just be cut); PER-SIDE borders (border-l, border-t-4) — draw.io draws these with a shape called partialRectangle, which would take the place of the node's own shape, and what a node IS matters more than which of its edges show; PER-SIDE padding (pt-4, px-2) — the engine has one padding value, and draw.io's per-side keys pad the LABEL rather than making room for children; per-corner radius (rounded-tl-lg); text-shadow-*; tracking-* and uppercase/lowercase/capitalize and leading-* (draw.io has no letter-spacing, no text-transform and no per-node line height); outline-*, hover:*, responsive prefixes, and all transforms.
|
||||
|
||||
PLAN THE COLUMNS BEFORE THE FIRST OPERATION. The engine places exactly what you declare; a column that runs out of content early leaves a hole at the bottom of the page and nothing later can fill it. So: list each section with a rough character count (heading ~20, paragraph ~its length, comparison card ~the sum of its parts, add_graph ~400); a column twice as wide runs about half as tall, so a column's SHARE OF THE TOTAL CONTENT must match its grow weight — grow 3 beside grow 1 holds about three times the characters, never fewer; add the columns up and check the ratio before emitting anything (1200 vs 1100 chars is grow 1 / grow 1, and wanting grow 3 / grow 1 for 900 vs 1100 means the plan is wrong — move sections across or equalise the weights); a full-width element (masthead, footnote, wide diagram) is its own row above or below the row of columns, never inside one, because a 900-wide diagram in one column forces that column wide and strands the others. State the numbers in your preamble ("left ~N chars / right ~M, so grow X / Y") — writing them down is what catches the mismatch.
|
||||
|
||||
For a POSTER (paper summary, cheat sheet): set_page with aspect 0.75 (portrait) or 1.4 (landscape); one col container as the page with class "gap-4"; a banner box as the masthead with class "self-stretch" (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container class "gap-4" holding 2-4 col containers as columns, each class "grow-N min-w-0 items-stretch"; each section a heading-role box + content boxes. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print. A comparison card: add_container dir=col class="gap-2 p-3 grow-1 items-stretch" role=bad, then a bold title box, the body text, a role=bad answer bar, and a coloured "<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" verdict with class "self-start".
|
||||
{"operations":[
|
||||
{"op":"set_page","aspect":0.8},
|
||||
{"op":"add_container","id":"page","label":"","dir":"col","class":"gap-4"},
|
||||
{"op":"add_box","id":"mast","parent":"page","label":"Chain-of-Thought Prompting","role":"banner","class":"self-stretch"},
|
||||
{"op":"add_container","id":"cols","parent":"page","label":"","dir":"row","class":"gap-4"},
|
||||
{"op":"add_container","id":"left","parent":"cols","label":"","dir":"col","class":"grow-2 min-w-0 gap-3 items-stretch"},
|
||||
{"op":"add_container","id":"right","parent":"cols","label":"","dir":"col","class":"grow-1 min-w-0 gap-3 items-stretch"},
|
||||
{"op":"add_box","id":"h1","parent":"left","label":"What it is","role":"heading","group":"idea"},
|
||||
{"op":"add_box","id":"p1","parent":"left","label":"Ask the model to show its steps...","group":"idea"}
|
||||
]}
|
||||
(Two thirds of the characters go in the grow-2 column, one third in the grow-1 column.)
|
||||
|
||||
Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.
|
||||
|
||||
Operations are applied in order, so you can add a container and fill it in the same call:
|
||||
{"operations":[
|
||||
{"op":"add_container","id":"vpc","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
||||
{"op":"add_icon","id":"ec2","parent":"vpc","name":"ec2","label":"EC2"},
|
||||
{"op":"link","source":"alb","target":"ec2","label":"route","step":1}
|
||||
]}
|
||||
|
||||
Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram.
|
||||
|
||||
CLOUD ARCHITECTURE (AWS/Azure/GCP/Kubernetes) — every zone is a container, and each one's dir is what makes the diagram readable: dir follows the traffic. Nesting is Region -> VPC -> Availability Zone -> Subnet, and managed/global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS, WAF, CloudWatch) sit OUTSIDE the VPC — a regional service inside a subnet states something false about the network. Use dir "row" wherever things are PEERS (availability zones side by side, replicas, a set of regional services) and dir "col" wherever traffic FLOWS THROUGH (the tiers inside one zone: public -> app -> data, top to bottom). Label every zone with its scope ("Availability Zone A", "Private Subnet (App)", "VPC 10.0.0.0/16") — an unlabelled frame makes the reader guess what the boundary means. Put the actor (Users / Internet) OUTSIDE the region as a plain box with shape "person" or "cloud" and link it inwards; it is not infrastructure. Two availability zones is the right default for "a sample architecture" — one reads as a single point of failure, three repeats the same information a third time. Number the request path on the links ("1. HTTPS", "2. forward", "3. route", "4. query") so the reader has an entry point, and make cross-cutting links (replication, telemetry) dashed and unnumbered. Keep each zone to 1-4 icons: one is fine when the boundary itself is the point (a subnet holding one NAT gateway), ten is a wall of icons — split it or use add_grid.
|
||||
{"operations":[
|
||||
{"op":"add_box","id":"users","label":"Users / Internet","shape":"person"},
|
||||
{"op":"add_container","id":"region","label":"Region (ap-southeast-1)","dir":"row","gname":"group_region"},
|
||||
{"op":"add_container","id":"vpc","parent":"region","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
||||
{"op":"add_icon","id":"igw","parent":"vpc","name":"internet_gateway","label":"Internet Gateway"},
|
||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
||||
{"op":"add_container","id":"azs","parent":"vpc","dir":"row"},
|
||||
{"op":"add_container","id":"az_a","parent":"azs","label":"Availability Zone A","dir":"col","gname":"group_availability_zone"},
|
||||
{"op":"add_container","id":"pub_a","parent":"az_a","label":"Public Subnet","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"nat_a","parent":"pub_a","name":"nat_gateway","label":"NAT Gateway"},
|
||||
{"op":"add_container","id":"app_a","parent":"az_a","label":"Private Subnet (App)","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"ec2_a","parent":"app_a","name":"ec2","label":"EC2 / ECS"},
|
||||
{"op":"add_container","id":"db_a","parent":"az_a","label":"Private Subnet (Data)","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"rds_a","parent":"db_a","name":"rds","label":"RDS (Primary)"},
|
||||
{"op":"add_container","id":"reg_svc","parent":"region","label":"Regional / Edge services","dir":"col"},
|
||||
{"op":"add_icon","id":"waf","parent":"reg_svc","name":"waf","label":"AWS WAF"},
|
||||
{"op":"link","source":"users","target":"igw","label":"1. HTTPS"},
|
||||
{"op":"link","source":"igw","target":"alb","label":"2. forward"},
|
||||
{"op":"link","source":"alb","target":"ec2_a","label":"3. route"},
|
||||
{"op":"link","source":"ec2_a","target":"rds_a","label":"4. query"},
|
||||
{"op":"link","source":"rds_a","target":"rds_b","label":"Multi-AZ replication","dashed":true}
|
||||
]}
|
||||
(az_b mirrors az_a, with RDS labelled "(Standby)".)
|
||||
|
||||
CONTAINERS — pick by what the diagram means:
|
||||
|
||||
add_container: children stacked along one axis. dir "row" side by side, "col" one above the next. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame.
|
||||
|
||||
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
|
||||
|
||||
add_graph: an ARROW-ORDERED block. Give it nodes and edges, NO positions and NO nesting: the engine reads the arrows to work out how many rows the diagram has, which nodes share a row, and who goes left of whom — chosen to keep arrows from crossing each other or running through unrelated boxes. Loops and arrows that skip ahead are fine.
|
||||
THIS IS THE ONLY WAY TO DRAW A FLOWCHART. Use it for flowcharts, decision trees, process and approval flows, CI/CD pipelines, state machines, git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams, and any "illustrate how X works" where X is a sequence of steps or states. Never build one out of add_container/add_box by hand: declaring a flowchart as nesting puts every step in one column, so each branch has to jump over the step beside it.
|
||||
Omit parent for a whole-page flowchart; set parent to put a flow inside one zone of a bigger diagram (a pipeline in an architecture diagram, a small flowchart in a poster column), where the block then joins the outer layout like any node. dir: "col" (default) flows down, "row" flows right.
|
||||
Redrawing a whole-page flowchart: send clear first. One new arrow can change which row several nodes belong in, so a flowchart is rebuilt rather than patched.
|
||||
{"operations":[
|
||||
{"op":"clear"},
|
||||
{"op":"add_graph","id":"flow","nodes":[
|
||||
{"id":"start","label":"Order received","shape":"terminator"},
|
||||
{"id":"check","label":"Amount > $1000?","shape":"decision"},
|
||||
{"id":"mgr","label":"Manager approval"},
|
||||
{"id":"auto","label":"Auto-approve"},
|
||||
{"id":"ship","label":"Ship order"}
|
||||
],"edges":[
|
||||
{"source":"start","target":"check"},
|
||||
{"source":"check","target":"mgr","label":"yes"},
|
||||
{"source":"check","target":"auto","label":"no"},
|
||||
{"source":"mgr","target":"ship"},
|
||||
{"source":"auto","target":"ship"}
|
||||
]},
|
||||
{"op":"set_title","title":"Order Approval"}
|
||||
]}
|
||||
Grouping: when the nodes fall into natural zones (remote vs local, frontend vs backend, roles, phases), set the same group name on each zone's nodes and the engine colours each zone consistently. Set icon instead of shape to draw a node as a catalog icon.
|
||||
|
||||
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
|
||||
{"operations":[
|
||||
{"op":"add_pool","id":"p","label":"Expense claim","lanes":["Employee","Manager","Finance"],"phases":["Submit","Review","Pay"]},
|
||||
{"op":"add_box","id":"fill","parent":"p","label":"Fill form","lane":0,"col":0,"shape":"terminator"},
|
||||
{"op":"add_box","id":"rev","parent":"p","label":"Review","lane":1,"col":1},
|
||||
{"op":"add_box","id":"ok","parent":"p","label":"Approved?","lane":1,"col":2,"shape":"decision"},
|
||||
{"op":"add_box","id":"pay","parent":"p","label":"Pay out","lane":2,"col":3},
|
||||
{"op":"link","source":"fill","target":"rev"},{"op":"link","source":"rev","target":"ok"},
|
||||
{"op":"link","source":"ok","target":"pay","label":"yes"}
|
||||
]}
|
||||
|
||||
add_sequence: a SEQUENCE diagram. One add_box per participant, left to right in the order they first act; the engine draws each one's lifeline. Every message is a link with a step number giving its order — number them 1, 2, 3… as they happen, and make a reply its own link back. A participant calling itself is a link from a node to itself.
|
||||
{"operations":[
|
||||
{"op":"add_sequence","id":"s","label":"Login flow"},
|
||||
{"op":"add_box","id":"u","parent":"s","label":"User"},
|
||||
{"op":"add_box","id":"api","parent":"s","label":"API"},
|
||||
{"op":"add_box","id":"db","parent":"s","label":"Database"},
|
||||
{"op":"link","source":"u","target":"api","label":"POST /login","step":1},
|
||||
{"op":"link","source":"api","target":"db","label":"find user","step":2},
|
||||
{"op":"link","source":"db","target":"api","label":"user record","step":3},
|
||||
{"op":"link","source":"api","target":"u","label":"JWT","step":4}
|
||||
]}
|
||||
|
||||
add_radial: a MIND MAP or ORG CHART. Add every node with the radial container as its parent — a FLAT list, never nested inside another box — and let the links carry the hierarchy: link parent to child. The node nothing points at becomes the centre. spread "radial" fans branches out both sides (a mind map); "down" hangs everything below its parent (an org chart, where a reporting line only reads correctly downwards).
|
||||
{"operations":[
|
||||
{"op":"add_radial","id":"o","label":"","spread":"down"},
|
||||
{"op":"add_box","id":"ceo","parent":"o","label":"CEO"},
|
||||
{"op":"add_box","id":"cto","parent":"o","label":"CTO"},
|
||||
{"op":"add_box","id":"lead","parent":"o","label":"Platform Lead"},
|
||||
{"op":"link","source":"ceo","target":"cto"},{"op":"link","source":"cto","target":"lead"}
|
||||
]}
|
||||
|
||||
BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminator" for a start/end point, "data" for input or output, "document" for a report, "round" for a soft-edged step. Use them; a reader takes a diamond to mean a choice.`,
|
||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
|
||||
inputSchema: z.object({
|
||||
operations: z
|
||||
.array(OperationSchema)
|
||||
.describe("Structural operations, applied in order"),
|
||||
}),
|
||||
},
|
||||
search_stencils: {
|
||||
description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`,
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
xml: z
|
||||
.string()
|
||||
.describe(
|
||||
"Service name or keyword, e.g. 's3' or 'nat gateway'",
|
||||
"Continuation XML fragment to append (NO wrapper tags)",
|
||||
),
|
||||
kind: z
|
||||
.enum(["icon", "group"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Restrict to service icons or container frames",
|
||||
),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
execute: async ({ query, kind, limit }) => {
|
||||
const hits = searchStencils(query, { kind, limit })
|
||||
if (hits.length === 0)
|
||||
return `No stencil matches "${query}". Try a shorter or more general term.`
|
||||
return JSON.stringify(hits)
|
||||
},
|
||||
get_shape_library: {
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names.
|
||||
|
||||
Available libraries:
|
||||
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
inputSchema: z.object({
|
||||
library: z
|
||||
.string()
|
||||
.describe(
|
||||
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Sanitize input - prevent path traversal attacks
|
||||
const sanitizedLibrary = library
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
|
||||
if (sanitizedLibrary !== library.toLowerCase()) {
|
||||
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
||||
}
|
||||
|
||||
const baseDir = path.join(
|
||||
process.cwd(),
|
||||
"docs/shape-libraries",
|
||||
)
|
||||
const filePath = path.join(
|
||||
baseDir,
|
||||
`${sanitizedLibrary}.md`,
|
||||
)
|
||||
|
||||
// Verify path stays within expected directory
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
||||
return `Invalid library path.`
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||
}
|
||||
console.error(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
error,
|
||||
)
|
||||
return `Error loading library "${library}". Please try again.`
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -805,7 +766,7 @@ BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminat
|
||||
}),
|
||||
})
|
||||
|
||||
return result.toUIMessageStreamResponse({
|
||||
const response = result.toUIMessageStreamResponse({
|
||||
sendReasoning: true,
|
||||
messageMetadata: ({ part }) => {
|
||||
if (part.type === "finish") {
|
||||
@@ -819,6 +780,8 @@ BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminat
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
modelStreamResponses.add(response)
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper to categorize errors and return appropriate response
|
||||
@@ -885,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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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)"
|
||||
|
||||
@@ -32,7 +34,36 @@ function detectCharset(
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -97,7 +128,15 @@ export async function POST(req: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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,12 +402,14 @@ 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)
|
||||
}
|
||||
@@ -398,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 },
|
||||
)
|
||||
}
|
||||
|
||||
+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 +
|
||||
|
||||
+124
-50
@@ -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,27 +345,24 @@ 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()
|
||||
|
||||
// Diagram tool handlers (edit_diagram, restructure_diagram, cached replay)
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
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,9 +870,6 @@ export default function ChatPanel({
|
||||
}
|
||||
|
||||
try {
|
||||
let chartXml = await onFetchChart()
|
||||
chartXml = formatXML(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[] = []
|
||||
@@ -844,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("")
|
||||
@@ -866,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) => {
|
||||
@@ -973,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(
|
||||
@@ -986,7 +1039,7 @@ export default function ChatPanel({
|
||||
formElement.requestSubmit()
|
||||
}
|
||||
},
|
||||
[setInput, setFiles, setUrlData],
|
||||
[setInput],
|
||||
)
|
||||
|
||||
const handleInputChange = (
|
||||
@@ -1001,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]) || "",
|
||||
)
|
||||
: ""
|
||||
}
|
||||
|
||||
@@ -1059,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()
|
||||
@@ -1104,6 +1160,9 @@ export default function ChatPanel({
|
||||
...(minimalStyle && {
|
||||
"x-minimal-style": "true",
|
||||
}),
|
||||
...(maxOutputTokens && {
|
||||
"x-max-output-tokens": maxOutputTokens,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1204,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) => {
|
||||
@@ -1231,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 }]
|
||||
@@ -1247,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)
|
||||
@@ -1448,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 === " "
|
||||
|
||||
@@ -4,11 +4,7 @@ import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type {
|
||||
DiagramOperation,
|
||||
StructureOperation,
|
||||
ToolPartLike,
|
||||
} from "./types"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
@@ -23,137 +19,31 @@ interface ToolCallCardProps {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour an operation by what it does to the diagram: removes, adds, or changes.
|
||||
*
|
||||
* Takes an unknown rather than a string because this renders DURING streaming: the tool
|
||||
* input arrives character by character, so an operation is briefly `{}` or `{"op": "add_c`
|
||||
* before it is whole. A missing name is the normal mid-stream state, not an error.
|
||||
*/
|
||||
function opColour(op: unknown): string {
|
||||
if (typeof op !== "string") return "text-muted-foreground"
|
||||
if (op === "delete" || op === "remove" || op === "unlink" || op === "clear")
|
||||
return "text-red-600"
|
||||
if (op.startsWith("add") || op === "link") return "text-green-600"
|
||||
return "text-blue-600"
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments worth showing beside an operation's name.
|
||||
*
|
||||
* A whitelist rather than "everything except op and id", because some operations carry a
|
||||
* whole nested graph (add_graph's nodes and edges) and dumping that turns one line into a
|
||||
* screenful. The excluded keys are summarised instead.
|
||||
*/
|
||||
const SHOWN_KEYS = [
|
||||
"label",
|
||||
"name",
|
||||
"parent",
|
||||
"dir",
|
||||
"class",
|
||||
"role",
|
||||
"group",
|
||||
"shape",
|
||||
"cols",
|
||||
"lanes",
|
||||
"aspect",
|
||||
"source",
|
||||
"target",
|
||||
"title",
|
||||
] as const
|
||||
|
||||
function summarise(op: StructureOperation | undefined | null): string {
|
||||
if (!op || typeof op !== "object") return ""
|
||||
const parts: string[] = []
|
||||
for (const key of SHOWN_KEYS) {
|
||||
const v = op[key]
|
||||
if (v === undefined || v === null || v === "") continue
|
||||
parts.push(
|
||||
`${key}=${Array.isArray(v) ? v.join("/") : String(v).slice(0, 60)}`,
|
||||
)
|
||||
}
|
||||
// A graph carries its own nodes and edges; report the size, not the contents.
|
||||
const nodes = op.nodes
|
||||
const edges = op.edges
|
||||
if (Array.isArray(nodes))
|
||||
parts.push(
|
||||
`${nodes.length} node${nodes.length === 1 ? "" : "s"}${
|
||||
Array.isArray(edges)
|
||||
? `, ${edges.length} edge${edges.length === 1 ? "" : "s"}`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* `restructure_diagram`'s operations: structural steps, not XML patches.
|
||||
*
|
||||
* Written to survive PARTIAL data. This renders while the tool input is still streaming, so
|
||||
* an entry may be `{}`, or `{op: "add_contai"}`, or — because a JSON array is repaired as it
|
||||
* arrives — `undefined`. Every field is therefore treated as possibly absent rather than
|
||||
* validated up front: dropping incomplete entries would make rows appear and disappear as
|
||||
* the text arrives, and asserting on them crashes the whole message.
|
||||
*/
|
||||
function StructureOperationsDisplay({
|
||||
operations,
|
||||
}: {
|
||||
operations: StructureOperation[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op?.op ?? "pending"}-${op?.id ?? index}-${index}`}
|
||||
className="flex items-baseline gap-2 px-2 py-1 rounded bg-background/50 border border-border/40"
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide shrink-0 ${opColour(op?.op)}`}
|
||||
>
|
||||
{op?.op ?? "…"}
|
||||
</span>
|
||||
{op?.id && (
|
||||
<span className="text-xs font-mono text-foreground/80 shrink-0">
|
||||
{op.id}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground font-mono break-all">
|
||||
{summarise(op)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** `edit_diagram`'s operations. Also streamed, so also written for partial entries. */
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op?.operation ?? "pending"}-${op?.cell_id ?? index}-${index}`}
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op?.operation === "delete"
|
||||
op.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op?.operation === "add"
|
||||
: op.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op?.operation ?? "…"}
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
{op?.cell_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{op?.new_xml && (
|
||||
{op.new_xml && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
@@ -176,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-", "")
|
||||
@@ -191,21 +81,25 @@ export function ToolCallCard({
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "restructure_diagram":
|
||||
return "Build Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "search_stencils":
|
||||
return "Find Icons"
|
||||
// Only ever arrives from the server's cache-hit path now; the model cannot
|
||||
// call it. See createCachedStreamResponse in app/api/chat/route.ts.
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "get_shape_library":
|
||||
return "Get Shape Library"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = ""
|
||||
|
||||
@@ -219,6 +113,14 @@ export function ToolCallCard({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
output &&
|
||||
toolName === "get_shape_library" &&
|
||||
typeof output === "string"
|
||||
) {
|
||||
textToCopy = output
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
onCopy(callId, textToCopy, true)
|
||||
}
|
||||
@@ -267,23 +169,15 @@ export function ToolCallCard({
|
||||
</>
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Truncation only applies to a tool that streams raw XML, which
|
||||
// is now just the cached-answer replay. The engine tools send
|
||||
// structured operations, so a failure there is a real error.
|
||||
const isTruncated =
|
||||
toolName === "display_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"
|
||||
@@ -321,23 +215,7 @@ export function ToolCallCard({
|
||||
) : typeof input === "object" &&
|
||||
input.operations &&
|
||||
Array.isArray(input.operations) ? (
|
||||
// Dispatch by TOOL, not by whether an `operations` key exists: both
|
||||
// tools call their argument that, but the items have different shapes
|
||||
// (op/id versus operation/cell_id), and reading one as the other
|
||||
// printed a row of blank `cell_id:` labels.
|
||||
toolName === "restructure_diagram" ? (
|
||||
<StructureOperationsDisplay
|
||||
operations={
|
||||
input.operations as StructureOperation[]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<OperationsDisplay
|
||||
operations={
|
||||
input.operations as DiagramOperation[]
|
||||
}
|
||||
/>
|
||||
)
|
||||
<OperationsDisplay operations={input.operations} />
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
@@ -347,37 +225,35 @@ export function ToolCallCard({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{output &&
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
toolName === "display_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>
|
||||
)
|
||||
})()}
|
||||
{/* What the tool actually returned. Worth showing on success, not only on
|
||||
error: restructure_diagram answers with an outline of the structure it
|
||||
built plus any notes about classes it could not honour, and that is the
|
||||
same text the model reads to name ids in its next call. */}
|
||||
{output && state === "output-available" && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<pre className="text-[11px] font-mono text-muted-foreground bg-muted/40 rounded-md p-2 overflow-auto max-h-64 whitespace-pre-wrap break-all">
|
||||
{typeof output === "string"
|
||||
? output.length > 4000
|
||||
? `${output.slice(0, 4000)}\n…`
|
||||
: output
|
||||
: String(output)}
|
||||
</pre>
|
||||
{/* 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" &&
|
||||
state === "output-available" &&
|
||||
isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Library loaded (
|
||||
{typeof output === "string" ? output.length : 0}{" "}
|
||||
chars)
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{typeof output === "string"
|
||||
? output.substring(0, 800) +
|
||||
(output.length > 800 ? "\n..." : "")
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
/** An `edit_diagram` operation: a patch against one cell, addressed by its id. */
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A `restructure_diagram` operation.
|
||||
*
|
||||
* Deliberately loose. The engine owns the real schema (lib/diagram-engine/operations.ts)
|
||||
* and it has two dozen variants; the card only needs to say WHAT each step did, so it
|
||||
* reads the two fields every variant shares and picks a few recognisable extras out of
|
||||
* the rest. Mirroring the full union here would mean editing this file every time the
|
||||
* engine gains an operation.
|
||||
*
|
||||
* The field names matter: `op`/`id`, where edit_diagram has `operation`/`cell_id`. Both
|
||||
* tools happen to call their argument `operations`, which is what let the card render one
|
||||
* as the other and print six blank `cell_id:` lines.
|
||||
*/
|
||||
export interface StructureOperation {
|
||||
op: string
|
||||
id?: string
|
||||
label?: string
|
||||
parent?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[] | StructureOperation[]
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react"
|
||||
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"
|
||||
@@ -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,10 +84,12 @@ 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<{
|
||||
@@ -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({
|
||||
@@ -205,24 +203,17 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const handleDiagramExport = (data: EventExport) => {
|
||||
// Handle PNG export for VLM validation
|
||||
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
|
||||
pngResolverRef.current(data.data)
|
||||
pngResolverRef.current = null
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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, data.xml)
|
||||
if (tag === "save") {
|
||||
saveResolverRef.current.resolver?.(data.data, data.xml)
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Don't write chartXML here: exports don't change the diagram, and
|
||||
@@ -236,12 +227,15 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
// 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)
|
||||
@@ -256,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 = () => {
|
||||
@@ -365,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)
|
||||
|
||||
@@ -96,7 +96,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **图表历史记录**:全面的版本控制,跟踪所有更改,允许您查看和恢复AI编辑前的图表版本
|
||||
- **交互式聊天界面**:与AI实时对话来完善您的图表
|
||||
- **云架构图支持**:专门支持生成云架构图(AWS、GCP、Azure)
|
||||
- **自动计算布局**:画架构图、流程图、泳道图/BPMN、时序图、思维导图和组织架构图时,AI 只描述结构——谁包含谁,或者谁指向谁——所有坐标、尺寸和连线路径都由程序计算。容器一定装得下里面的内容,同层元素不会重叠,连线会绕开本来会穿过的图形。之后您手动移动或改色的部分会被当作图表的一部分读回来,所以后续修改不会覆盖掉您的调整。
|
||||
- **动画连接器**:在图表元素之间创建动态动画连接器,实现更好的可视化效果
|
||||
|
||||
## MCP服务器
|
||||
|
||||
@@ -94,7 +94,6 @@ https://github.com/user-attachments/assets/b2eef5f3-b335-4e71-a755-dc2e80931979
|
||||
- **ダイアグラム履歴**:すべての変更を追跡する包括的なバージョン管理。AI編集前のダイアグラムの以前のバージョンを表示・復元可能
|
||||
- **インタラクティブなチャットインターフェース**:AIとリアルタイムでコミュニケーションしてダイアグラムを改善
|
||||
- **クラウドアーキテクチャダイアグラムサポート**:クラウドアーキテクチャダイアグラムの生成を専門的にサポート(AWS、GCP、Azure)
|
||||
- **レイアウトの自動計算**:アーキテクチャ図、フローチャート、スイムレーン図/BPMN、シーケンス図、マインドマップ、組織図では、AI は構造だけ——何が何を含むか、何が何を指すか——を指定し、座標・サイズ・矢印の経路はすべてアプリが計算します。コンテナは必ず中身が収まるサイズになり、同じ階層の要素が重なることはなく、矢印は本来通り抜けてしまう図形を避けて引かれます。その後に手で動かしたり色を変えた部分は図の一部として読み戻されるため、次の編集で元に戻されることはありません。
|
||||
- **アニメーションコネクタ**:より良い可視化のためにダイアグラム要素間に動的でアニメーション化されたコネクタを作成
|
||||
|
||||
## MCPサーバー
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { MutableRefObject } from "react"
|
||||
import { useRef } from "react"
|
||||
import type { DiagramOperation } from "@/components/chat/types"
|
||||
import type {
|
||||
ValidationState,
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
@@ -50,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>
|
||||
@@ -66,8 +65,7 @@ interface UseDiagramToolHandlersParams {
|
||||
|
||||
/**
|
||||
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||
* Handles edit_diagram and restructure_diagram, plus the cached-XML replay that arrives
|
||||
* as display_diagram.
|
||||
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||
*
|
||||
* Note: addToolOutput is passed at call time (not hook init) because
|
||||
* it comes from useChat which creates a circular dependency.
|
||||
@@ -75,6 +73,7 @@ interface UseDiagramToolHandlersParams {
|
||||
export function useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
validationRetryCountRef,
|
||||
chartXMLRef,
|
||||
onDisplayChart,
|
||||
onFetchChart,
|
||||
@@ -85,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,
|
||||
@@ -121,36 +117,250 @@ export function useDiagramToolHandlers({
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "restructure_diagram") {
|
||||
await handleRestructureDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Replays a cached XML answer onto the canvas. The model can no longer call this tool —
|
||||
// it only arrives from the server's cache-hit path (see createCachedStreamResponse), which
|
||||
// speaks the same wire format. So there is no truncation to continue and no model to send
|
||||
// errors back to: load it, or report that it did not load.
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
const validationError = onDisplayChart(wrapWithMxFile(xml))
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
}
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
if (DEBUG) {
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
}
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: validationError,
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
})
|
||||
return
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
||||
)
|
||||
}
|
||||
|
||||
// VLM validation after successful display
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
|
||||
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
||||
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
capturedPngData = await captureValidationPng()
|
||||
if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Captured PNG for validation",
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
)
|
||||
|
||||
const result = await validateDiagram(
|
||||
capturedPngData,
|
||||
sessionId,
|
||||
)
|
||||
|
||||
if (!result.valid) {
|
||||
if (attempt < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current = attempt
|
||||
|
||||
const feedback =
|
||||
formatValidationFeedback(result)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[display_diagram] Validation failed (attempt ${attempt}/${MAX_VALIDATION_RETRIES}):`,
|
||||
result.issues,
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of validation failure (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"failed",
|
||||
{
|
||||
attempt,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
result,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `[Validation attempt ${attempt}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Last attempt - accept the diagram with warning
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current = 0
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"skipped",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram displayed (validation issues noted but max retries reached).",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - reset retry count
|
||||
validationRetryCountRef.current = 0
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of success (include the image)
|
||||
// Use "success_with_warnings" if valid but has issues
|
||||
const hasWarnings = result.issues.length > 0
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
hasWarnings
|
||||
? "success_with_warnings"
|
||||
: "success",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// PNG capture failed - skip validation
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
}
|
||||
} catch (error) {
|
||||
// VLM validation error - log but don't block the user
|
||||
console.warn(
|
||||
"[display_diagram] VLM validation error:",
|
||||
error,
|
||||
)
|
||||
updateValidationState(toolCall.toolCallId, "error", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Validation failed",
|
||||
imageData: capturedPngData || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditDiagram = async (
|
||||
@@ -162,12 +372,17 @@ export function useDiagramToolHandlers({
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -196,6 +411,7 @@ export function useDiagramToolHandlers({
|
||||
)
|
||||
.join("\n")
|
||||
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -221,6 +437,7 @@ Please check the cell IDs and retry.`,
|
||||
"[edit_diagram] Validation error:",
|
||||
validationError,
|
||||
)
|
||||
restoreOriginal()
|
||||
addToolOutput({
|
||||
tool: "edit_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
@@ -252,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,
|
||||
@@ -263,77 +481,110 @@ Current diagram XML:
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or rebuild with restructure_diagram.`,
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural editing. The model sends operations against the tree; the engine
|
||||
* re-derives that tree from whatever is on the canvas right now — including anything
|
||||
* the user moved or recoloured by hand — applies the operations, recomputes every
|
||||
* coordinate, and returns new XML.
|
||||
*
|
||||
* Nothing about the tree is stored between calls, so there is no second copy of the
|
||||
* state to drift out of sync with the canvas.
|
||||
*/
|
||||
const handleRestructureDiagram = async (
|
||||
const handleAppendDiagram = (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { operations } = toolCall.input as { operations: Operation[] }
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Read the live canvas, not the last thing we generated: the user may have
|
||||
// edited it since.
|
||||
let currentXml = ""
|
||||
try {
|
||||
currentXml = await onFetchChart(false)
|
||||
} catch {
|
||||
currentXml = chartXMLRef.current ?? ""
|
||||
}
|
||||
if (!isRealDiagram(currentXml)) currentXml = ""
|
||||
|
||||
const result = restructureDiagram(currentXml, operations)
|
||||
|
||||
if (result.errors.length > 0 || !result.xml) {
|
||||
// Nothing to continue: loading the fragment alone would replace the whole diagram
|
||||
if (!partialXmlRef.current) {
|
||||
addToolOutput({
|
||||
tool: "restructure_diagram",
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Could not apply the operations:
|
||||
${result.errors.map((e) => `- ${e}`).join("\n")}
|
||||
errorText: `ERROR: There is no truncated diagram to continue, so append_diagram cannot be used now.
|
||||
|
||||
Structure as it stands:
|
||||
${result.outline}
|
||||
|
||||
Fix the operations and call restructure_diagram again.`,
|
||||
Use display_diagram to create the complete diagram, or edit_diagram to change the current one.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const loadError = onDisplayChart(result.xml)
|
||||
if (loadError) {
|
||||
// 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()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
|
||||
if (isFreshStart) {
|
||||
addToolOutput({
|
||||
tool: "restructure_diagram",
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `The diagram was built but draw.io rejected it: ${loadError}`,
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Report the outline rather than the XML: it is what the model needs to name ids
|
||||
// in the next call, at a fraction of the tokens.
|
||||
const notes = result.warnings.length
|
||||
? `\n\nNotes:\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
||||
: ""
|
||||
addToolOutput({
|
||||
tool: "restructure_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Diagram updated.\n\n${result.outline}${notes}`,
|
||||
})
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { handleToolCall }
|
||||
|
||||
+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 = {}
|
||||
}
|
||||
|
||||
+85
-16
@@ -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 }
|
||||
@@ -824,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({
|
||||
@@ -836,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")) {
|
||||
@@ -872,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)
|
||||
@@ -928,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 }),
|
||||
@@ -941,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(
|
||||
@@ -951,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,
|
||||
@@ -1079,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 }),
|
||||
@@ -1241,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 }),
|
||||
@@ -1430,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.
|
||||
@@ -1464,6 +1522,17 @@ export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
|
||||
)
|
||||
}
|
||||
|
||||
const { model } = getAIModel({ modelId })
|
||||
// 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,
|
||||
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": "')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* The stencil catalog: a name → verbatim draw.io style map.
|
||||
*
|
||||
* This is the anti-hallucination layer. The model asks for `icon("s3")`; the engine
|
||||
* looks the name up here and gets the exact style draw.io ships, including the official
|
||||
* category colour, the connection points and `aspect=fixed`. A name that is not in the
|
||||
* catalog fails at build time with a suggestion, rather than becoming an empty square in
|
||||
* the rendered diagram — which is what happens when a model writes
|
||||
* `resIcon=mxgraph.aws4.s3_bucket_thing` by hand and nothing checks it.
|
||||
*
|
||||
* The styles are verbatim from draw.io's own shape index (via drawio-ai-kit, which
|
||||
* generated them from jgraph/drawio-mcp's index, Apache-2.0). Nothing here is
|
||||
* hand-assembled, so there is no chance of a plausible-looking but wrong colour.
|
||||
*/
|
||||
|
||||
import stencils from "./data/aws-stencils.json"
|
||||
|
||||
const ICONS = stencils.icons as Record<string, string>
|
||||
const GROUPS = stencils.groups as Record<string, string>
|
||||
|
||||
export interface CatalogEntry {
|
||||
name: string
|
||||
kind: "icon" | "group"
|
||||
style: string
|
||||
/** Official colour from the style, for showing the model what it is getting. */
|
||||
color: string | null
|
||||
}
|
||||
|
||||
function colorOf(style: string): string | null {
|
||||
return style.match(/(?:^|;)fillColor=([^;]+)/)?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Exact lookup. Returns null for an unknown name — never a guess. */
|
||||
export function lookupStencil(
|
||||
name: string,
|
||||
kind?: "icon" | "group",
|
||||
): CatalogEntry | null {
|
||||
if (kind !== "group" && ICONS[name])
|
||||
return {
|
||||
name,
|
||||
kind: "icon",
|
||||
style: ICONS[name],
|
||||
color: colorOf(ICONS[name]),
|
||||
}
|
||||
if (kind !== "icon" && GROUPS[name])
|
||||
return {
|
||||
name,
|
||||
kind: "group",
|
||||
style: GROUPS[name],
|
||||
color: colorOf(GROUPS[name]),
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The resolver the renderer takes, so the engine itself does not depend on the catalog. */
|
||||
export function resolveStyle(
|
||||
name: string,
|
||||
kind: "icon" | "group",
|
||||
): string | null {
|
||||
return lookupStencil(name, kind)?.style ?? null
|
||||
}
|
||||
|
||||
/** Normalise for matching: lowercase, and non-alphanumerics collapsed to single spaces. */
|
||||
function norm(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand people type, mapped to words that actually appear in a catalog name.
|
||||
*
|
||||
* The direction matters: the target has to exist in the catalog. AWS's own stencil names
|
||||
* are already abbreviated — EKS is `eks`, not `elastic_kubernetes_service`, and nothing
|
||||
* in the catalog contains the word "kubernetes" at all — so expanding an abbreviation
|
||||
* into its full product name finds nothing. These entries go the other way, from a
|
||||
* spoken-out name or a nickname to the token the catalog uses.
|
||||
*/
|
||||
const ALIASES: Record<string, string> = {
|
||||
k8s: "eks",
|
||||
kubernetes: "eks",
|
||||
kube: "eks",
|
||||
alb: "application load balancer",
|
||||
nlb: "network load balancer",
|
||||
elb: "elastic load balancing",
|
||||
asg: "auto scaling",
|
||||
apigw: "api gateway",
|
||||
cf: "cloudfront",
|
||||
cw: "cloudwatch",
|
||||
ddb: "dynamodb",
|
||||
tgw: "transit gateway",
|
||||
igw: "internet gateway",
|
||||
r53: "route 53",
|
||||
iam: "identity and access management",
|
||||
kms: "key management service",
|
||||
postgres: "rds",
|
||||
postgresql: "rds",
|
||||
mysql: "rds",
|
||||
aurora: "aurora",
|
||||
bucket: "s3",
|
||||
}
|
||||
|
||||
/**
|
||||
* Score one entry against the query tokens. Higher is better; 0 means no match.
|
||||
*
|
||||
* The extra-words penalty is what makes "s3" return `s3` rather than
|
||||
* `backup_aws_backup_support_for_amazon_s3` — both contain the token, so without it the
|
||||
* winner comes down to iteration order. It counts only the words the query did NOT ask
|
||||
* for, so a deliberately multi-word query like "nat gateway" is not punished for being
|
||||
* specific.
|
||||
*/
|
||||
function score(name: string, qTokens: string[], qJoined: string): number {
|
||||
const n = norm(name)
|
||||
const words = n.split(" ")
|
||||
let s = 0
|
||||
if (n === qJoined) s += 100
|
||||
if (n.replace(/ /g, "") === qJoined.replace(/ /g, "")) s += 60
|
||||
for (const t of qTokens) {
|
||||
if (words.includes(t)) s += 25
|
||||
else if (n.includes(t)) s += 12
|
||||
}
|
||||
if (s === 0) return 0
|
||||
const extra = words.filter((w) => !qTokens.includes(w)).length
|
||||
return s - Math.min(24, extra * 4)
|
||||
}
|
||||
|
||||
export interface SearchHit {
|
||||
name: string
|
||||
kind: "icon" | "group"
|
||||
color: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stencils by keyword.
|
||||
*
|
||||
* Returns names and colours only, not styles. The model builds with `icon("<name>")` and
|
||||
* the engine resolves the style itself, so sending the style — around 600 characters per
|
||||
* AWS entry, and 20KB+ for an Azure one with an embedded image — would be pure context
|
||||
* burn.
|
||||
*/
|
||||
export function searchStencils(
|
||||
query: string,
|
||||
opts: { limit?: number; kind?: "icon" | "group" } = {},
|
||||
): SearchHit[] {
|
||||
const limit = opts.limit ?? 8
|
||||
const tokens = norm(query)
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((t) => ALIASES[t] ?? t)
|
||||
.flatMap((t) => t.split(" "))
|
||||
if (tokens.length === 0) return []
|
||||
const joined = tokens.join(" ")
|
||||
|
||||
const pool: [string, string, "icon" | "group"][] = []
|
||||
if (opts.kind !== "group")
|
||||
for (const [n, st] of Object.entries(ICONS)) pool.push([n, st, "icon"])
|
||||
if (opts.kind !== "icon")
|
||||
for (const [n, st] of Object.entries(GROUPS))
|
||||
pool.push([n, st, "group"])
|
||||
|
||||
return pool
|
||||
.map(([name, style, kind]) => ({
|
||||
name,
|
||||
kind,
|
||||
color: colorOf(style),
|
||||
s: score(name, tokens, joined),
|
||||
}))
|
||||
.filter((r) => r.s > 0)
|
||||
.sort((a, b) => b.s - a.s || a.name.length - b.name.length)
|
||||
.slice(0, limit)
|
||||
.map(({ name, kind, color }) => ({ name, kind, color }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest real names for one that does not exist.
|
||||
*
|
||||
* Plain search is not quite the right tool here. A model that writes
|
||||
* `s3_bucket_storage` most likely meant `s3`, but searching that whole phrase ranks
|
||||
* `s3_storage_lens` first — it matches more of the query. So we also search the
|
||||
* leading token on its own and put those hits first: an invented name is usually a
|
||||
* real service name with extra words stuck on the end.
|
||||
*/
|
||||
function suggestFor(name: string, kind: "icon" | "group"): string[] {
|
||||
const words = norm(name.replace(/_/g, " ")).split(" ").filter(Boolean)
|
||||
const out: string[] = []
|
||||
const add = (hits: SearchHit[]) => {
|
||||
for (const h of hits) if (!out.includes(h.name)) out.push(h.name)
|
||||
}
|
||||
if (words.length > 1) add(searchStencils(words[0], { limit: 2, kind }))
|
||||
add(searchStencils(words.join(" "), { limit: 3, kind }))
|
||||
return out.slice(0, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the icon names in a tree before laying it out, so a bad name is reported as
|
||||
* a correctable error with suggestions instead of rendering as a blank square — which is
|
||||
* what an unchecked invented name becomes in draw.io.
|
||||
*/
|
||||
export function checkNames(
|
||||
names: { id: string; name: string; kind: "icon" | "group" }[],
|
||||
): { id: string; name: string; suggestions: string[] }[] {
|
||||
const bad: { id: string; name: string; suggestions: string[] }[] = []
|
||||
for (const n of names) {
|
||||
if (!n.name || lookupStencil(n.name, n.kind)) continue
|
||||
bad.push({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
suggestions: suggestFor(n.name, n.kind),
|
||||
})
|
||||
}
|
||||
return bad
|
||||
}
|
||||
|
||||
/** Total catalog size, for the tool description. */
|
||||
export const CATALOG_SIZE = {
|
||||
icons: Object.keys(ICONS).length,
|
||||
groups: Object.keys(GROUPS).length,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,393 +0,0 @@
|
||||
/**
|
||||
* Graph → layers. What turns a flat list of nodes and arrows into a diagram.
|
||||
*
|
||||
* The engine's layout can only arrange what nesting tells it to: a container stacks its
|
||||
* children in one direction, so six boxes declared in a row become six boxes in a row. For
|
||||
* a flowchart that is the wrong answer, and measurably so — an order-approval flow declared
|
||||
* in its natural order comes out as one column, which forces the arrow from the decision to
|
||||
* its second branch to jump over the first branch, and the arrow to the merge point to jump
|
||||
* back over that. The layout never looked at the arrows.
|
||||
*
|
||||
* This computes what it should have looked at. Three steps, the standard shape of a layered
|
||||
* graph drawing (Sugiyama's algorithm):
|
||||
*
|
||||
* 1. LAYER — how far along the flow each node sits. Longest path from a source, so an
|
||||
* arrow always points forwards and no arrow skips backwards through a layer.
|
||||
* 2. ORDER — who goes left and who goes right within a layer. Chosen to reduce the number
|
||||
* of arrows that cross, which is what makes a flowchart readable.
|
||||
* 3. EMIT — one invisible row container per layer, which the existing layout then places.
|
||||
*
|
||||
* Step 3 is why this file is small: the coordinate work already exists, and it is the same
|
||||
* code that lays out an AWS diagram. What was missing was only the decision of what goes in
|
||||
* which row.
|
||||
*/
|
||||
|
||||
import type { Operation } from "./operations"
|
||||
import type { BoxShape, Role } from "./types"
|
||||
|
||||
/** A node in the graph the caller wants drawn. */
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
label: string
|
||||
/** Flowchart outline. `decision` for a branch, `terminator` for a start or end point. */
|
||||
shape?: BoxShape
|
||||
/** Catalog stencil name. When set the node renders as an icon rather than a box. */
|
||||
icon?: string
|
||||
/**
|
||||
* Semantic group name, e.g. "remote" or "local". Nodes sharing a group get the same
|
||||
* fill colour from the engine's palette, assigned in order of first appearance — the
|
||||
* caller names the grouping and never touches a colour.
|
||||
*/
|
||||
group?: string
|
||||
/** Information role (heading, callout, metric…); the theme decides how it looks. */
|
||||
role?: Role
|
||||
}
|
||||
|
||||
/** An arrow. Direction matters: it is what determines the layering. */
|
||||
export interface GraphEdge {
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
dashed?: boolean
|
||||
/** Thick coloured arrow for THE key relationship. */
|
||||
bold?: boolean
|
||||
/** Arrowhead tokens, passed through — see LinkSpec. */
|
||||
head?: string
|
||||
tail?: string
|
||||
headFill?: boolean
|
||||
tailFill?: boolean
|
||||
}
|
||||
|
||||
export interface GraphOptions {
|
||||
/** "col" (default): layers stack downwards. "row": layers run left to right. */
|
||||
flow?: "col" | "row"
|
||||
/** Container to embed the graph in; absent means the page. */
|
||||
parent?: string
|
||||
/**
|
||||
* Namespace for the synthetic layer-container ids. Without one, two graphs on one
|
||||
* page would both emit `__layers`/`__layer0` and the second would be rejected as a
|
||||
* duplicate id.
|
||||
*/
|
||||
prefix?: string
|
||||
/** Id for the outer container itself; defaults to `${prefix}__layers`. */
|
||||
rootId?: string
|
||||
}
|
||||
|
||||
/** Distance between layers. */
|
||||
const LAYER_GAP = 48
|
||||
/** Distance between nodes within a layer. */
|
||||
const NODE_GAP = 60
|
||||
/** Prefix for the generated layer container ids. */
|
||||
const LAYER_ID = "__layer"
|
||||
|
||||
export interface GraphResult {
|
||||
operations: Operation[]
|
||||
/** The nodes of each layer, in the order they were placed. */
|
||||
layers: string[][]
|
||||
/** Edges dropped because an endpoint is not in the node list. */
|
||||
unknownEndpoints: string[]
|
||||
/** Edges that had to be treated as loops rather than as layering constraints. */
|
||||
backEdges: { source: string; target: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Break every cycle, so the graph can be layered at all.
|
||||
*
|
||||
* A depth-first walk; any arrow pointing at a node still on the current path is a way back
|
||||
* to where we came from, and cannot be a "this comes after that" constraint. Those arrows
|
||||
* are still DRAWN — a review loop is the point of the diagram — they just do not get a say
|
||||
* in which layer anything lands in.
|
||||
*/
|
||||
function breakCycles(
|
||||
nodes: string[],
|
||||
edges: GraphEdge[],
|
||||
): { forward: GraphEdge[]; back: GraphEdge[] } {
|
||||
const out = new Map<string, GraphEdge[]>(nodes.map((n) => [n, []]))
|
||||
for (const e of edges) out.get(e.source)?.push(e)
|
||||
|
||||
const forward: GraphEdge[] = []
|
||||
const back: GraphEdge[] = []
|
||||
const onPath = new Set<string>()
|
||||
const done = new Set<string>()
|
||||
|
||||
// An explicit stack, not recursion: a 500-node dependency graph is a plausible input and
|
||||
// a recursive walk over one would overflow.
|
||||
for (const root of nodes) {
|
||||
if (done.has(root)) continue
|
||||
const stack: { id: string; next: number }[] = [{ id: root, next: 0 }]
|
||||
onPath.add(root)
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]
|
||||
const list = out.get(top.id) ?? []
|
||||
if (top.next >= list.length) {
|
||||
onPath.delete(top.id)
|
||||
done.add(top.id)
|
||||
stack.pop()
|
||||
continue
|
||||
}
|
||||
const e = list[top.next++]
|
||||
if (onPath.has(e.target)) {
|
||||
back.push(e)
|
||||
continue
|
||||
}
|
||||
forward.push(e)
|
||||
if (!done.has(e.target)) {
|
||||
onPath.add(e.target)
|
||||
stack.push({ id: e.target, next: 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { forward, back }
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each node to a layer: the longest path to it from any node with no predecessor.
|
||||
*
|
||||
* Longest path rather than shortest, because a node has to come after EVERYTHING that feeds
|
||||
* it. Take the shortest and an arrow ends up pointing backwards: with `a→b`, `a→c`, `c→b`,
|
||||
* the shortest path puts b in layer 1 alongside c, and then `c→b` points sideways.
|
||||
*/
|
||||
function assignLayers(nodes: string[], forward: GraphEdge[]): string[][] {
|
||||
const layer = new Map<string, number>(nodes.map((n) => [n, 0]))
|
||||
// Relaxation, bounded by the node count: the longest possible chain visits every node
|
||||
// once, so after that many rounds nothing can still be moving.
|
||||
for (let round = 0; round < nodes.length; round++) {
|
||||
let moved = false
|
||||
for (const e of forward) {
|
||||
const want = (layer.get(e.source) ?? 0) + 1
|
||||
if (want > (layer.get(e.target) ?? 0)) {
|
||||
layer.set(e.target, want)
|
||||
moved = true
|
||||
}
|
||||
}
|
||||
if (!moved) break
|
||||
}
|
||||
const depth = Math.max(0, ...layer.values()) + 1
|
||||
const layers: string[][] = Array.from({ length: depth }, () => [])
|
||||
// Declaration order within a layer, so the ordering pass starts somewhere predictable.
|
||||
for (const n of nodes) layers[layer.get(n) ?? 0].push(n)
|
||||
return layers
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder each layer to reduce the number of arrows that cross.
|
||||
*
|
||||
* Barycentre sweeping: a node is placed at the average position of the nodes it connects to
|
||||
* in the neighbouring layer, and the whole diagram is swept downwards then upwards
|
||||
* repeatedly. Each sweep can only be judged against the previous layer's order, so a node
|
||||
* pulled into a better place drags its own neighbours in the next sweep.
|
||||
*
|
||||
* The heuristic, not an exact minimum: finding the true minimum number of crossings is
|
||||
* NP-hard even for two layers. In practice this reaches zero crossings on the flowcharts the
|
||||
* model actually produces — verified on a 14-node pipeline with two diamonds and a rollback
|
||||
* loop, and on a bipartite graph whose declared order forces three crossings.
|
||||
*/
|
||||
function reduceCrossings(layers: string[][], edges: GraphEdge[]): void {
|
||||
if (layers.length < 2) return
|
||||
const PASSES = 8
|
||||
const into = new Map<string, string[]>()
|
||||
const outOf = new Map<string, string[]>()
|
||||
for (const e of edges) {
|
||||
if (e.source === e.target) continue
|
||||
;(into.get(e.target) ?? into.set(e.target, []).get(e.target))?.push(
|
||||
e.source,
|
||||
)
|
||||
;(outOf.get(e.source) ?? outOf.set(e.source, []).get(e.source))?.push(
|
||||
e.target,
|
||||
)
|
||||
}
|
||||
|
||||
let best = layers.map((l) => [...l])
|
||||
let bestScore = countCrossings(layers, edges)
|
||||
|
||||
for (let pass = 0; pass < PASSES && bestScore > 0; pass++) {
|
||||
const pos = new Map<string, number>()
|
||||
for (const l of layers)
|
||||
l.forEach((n, i) => {
|
||||
pos.set(n, i)
|
||||
})
|
||||
const down = pass % 2 === 0
|
||||
const order = down
|
||||
? layers.map((_, i) => i).slice(1)
|
||||
: layers
|
||||
.map((_, i) => i)
|
||||
.slice(0, -1)
|
||||
.reverse()
|
||||
|
||||
for (const i of order) {
|
||||
const neighbours = down ? into : outOf
|
||||
const key = new Map<string, number>()
|
||||
layers[i].forEach((n, idx) => {
|
||||
const nb = (neighbours.get(n) ?? [])
|
||||
.map((m) => pos.get(m))
|
||||
.filter((v): v is number => v !== undefined)
|
||||
// A node with no neighbour in that direction keeps its place, rather than
|
||||
// being pushed to one end by a default of zero.
|
||||
key.set(
|
||||
n,
|
||||
nb.length ? nb.reduce((a, b) => a + b, 0) / nb.length : idx,
|
||||
)
|
||||
})
|
||||
layers[i] = [...layers[i]].sort(
|
||||
(a, b) => (key.get(a) ?? 0) - (key.get(b) ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the best arrangement seen: sweeping is not monotonic, and a later pass can be
|
||||
// worse than an earlier one.
|
||||
const score = countCrossings(layers, edges)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
best = layers.map((l) => [...l])
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < layers.length; i++) layers[i] = best[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* How many pairs of arrows cross between adjacent layers.
|
||||
*
|
||||
* Two arrows between the same pair of layers cross exactly when their endpoints are in the
|
||||
* opposite order on the two sides. That is all this counts — arrows spanning more than one
|
||||
* layer are ignored here, because their crossings depend on routing rather than ordering.
|
||||
*/
|
||||
function countCrossings(layers: string[][], edges: GraphEdge[]): number {
|
||||
const layerOf = new Map<string, number>()
|
||||
const posOf = new Map<string, number>()
|
||||
layers.forEach((l, i) => {
|
||||
l.forEach((n, j) => {
|
||||
layerOf.set(n, i)
|
||||
posOf.set(n, j)
|
||||
})
|
||||
})
|
||||
let total = 0
|
||||
for (let i = 0; i + 1 < layers.length; i++) {
|
||||
const span = edges.filter(
|
||||
(e) =>
|
||||
layerOf.get(e.source) === i && layerOf.get(e.target) === i + 1,
|
||||
)
|
||||
for (let a = 0; a < span.length; a++)
|
||||
for (let b = a + 1; b < span.length; b++) {
|
||||
const s1 = posOf.get(span[a].source) ?? 0
|
||||
const t1 = posOf.get(span[a].target) ?? 0
|
||||
const s2 = posOf.get(span[b].source) ?? 0
|
||||
const t2 = posOf.get(span[b].target) ?? 0
|
||||
if ((s1 - s2) * (t1 - t2) < 0) total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a graph into the operations that draw it.
|
||||
*
|
||||
* The output is ordinary operations — nothing here is a new kind of thing the rest of the
|
||||
* engine has to know about. A layer of one node is emitted directly rather than wrapped,
|
||||
* because a single-child row container would just add a level of nesting with nothing to
|
||||
* arrange.
|
||||
*/
|
||||
export function graphToOperations(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
opts: GraphOptions = {},
|
||||
): GraphResult {
|
||||
const flow = opts.flow ?? "col"
|
||||
const ids = nodes.map((n) => n.id)
|
||||
const known = new Set(ids)
|
||||
|
||||
const unknownEndpoints: string[] = []
|
||||
const usable: GraphEdge[] = []
|
||||
for (const e of edges) {
|
||||
if (!known.has(e.source)) unknownEndpoints.push(e.source)
|
||||
if (!known.has(e.target)) unknownEndpoints.push(e.target)
|
||||
if (known.has(e.source) && known.has(e.target)) usable.push(e)
|
||||
}
|
||||
|
||||
// A self-loop tells us nothing about layering and would make the cycle break drop a real
|
||||
// arrow, so it is set aside and drawn as-is.
|
||||
const loops = usable.filter((e) => e.source === e.target)
|
||||
const between = usable.filter((e) => e.source !== e.target)
|
||||
|
||||
const { forward, back } = breakCycles(ids, between)
|
||||
const layers = assignLayers(ids, forward)
|
||||
reduceCrossings(layers, forward)
|
||||
|
||||
// The flow axis is the OUTER container's direction; a layer runs across it.
|
||||
const outerDir = flow
|
||||
const layerDir = flow === "col" ? "row" : "col"
|
||||
const ns = opts.prefix ?? ""
|
||||
const root = opts.rootId ?? `${ns}${LAYER_ID}s`
|
||||
|
||||
const operations: Operation[] = [
|
||||
{
|
||||
op: "add_container",
|
||||
id: root,
|
||||
...(opts.parent ? { parent: opts.parent } : {}),
|
||||
label: "",
|
||||
dir: outerDir,
|
||||
gap: LAYER_GAP,
|
||||
},
|
||||
]
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]))
|
||||
const add = (id: string, parent: string): Operation => {
|
||||
const n = byId.get(id) as GraphNode
|
||||
return n.icon
|
||||
? {
|
||||
op: "add_icon",
|
||||
id: n.id,
|
||||
parent,
|
||||
name: n.icon,
|
||||
label: n.label,
|
||||
}
|
||||
: {
|
||||
op: "add_box",
|
||||
id: n.id,
|
||||
parent,
|
||||
label: n.label,
|
||||
...(n.shape && n.shape !== "box" ? { shape: n.shape } : {}),
|
||||
...(n.role && n.role !== "body" ? { role: n.role } : {}),
|
||||
...(n.group ? { group: n.group } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
layers.forEach((members, i) => {
|
||||
if (members.length === 0) return
|
||||
if (members.length === 1) {
|
||||
operations.push(add(members[0], root))
|
||||
return
|
||||
}
|
||||
const band = `${ns}${LAYER_ID}${i}`
|
||||
operations.push({
|
||||
op: "add_container",
|
||||
id: band,
|
||||
parent: root,
|
||||
label: "",
|
||||
dir: layerDir,
|
||||
gap: NODE_GAP,
|
||||
})
|
||||
for (const m of members) operations.push(add(m, band))
|
||||
})
|
||||
|
||||
for (const e of [...between, ...loops])
|
||||
operations.push({
|
||||
op: "link",
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
...(e.label ? { label: e.label } : {}),
|
||||
...(e.dashed ? { dashed: true } : {}),
|
||||
...(e.bold ? { bold: true } : {}),
|
||||
...(e.head !== undefined
|
||||
? { head: e.head, headFill: e.headFill ?? false }
|
||||
: {}),
|
||||
...(e.tail !== undefined
|
||||
? { tail: e.tail, tailFill: e.tailFill ?? false }
|
||||
: {}),
|
||||
})
|
||||
|
||||
return {
|
||||
operations,
|
||||
layers: layers.filter((l) => l.length > 0),
|
||||
unknownEndpoints: [...new Set(unknownEndpoints)],
|
||||
backEdges: back.map((e) => ({ source: e.source, target: e.target })),
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* The engine's entry point: one call takes the current canvas XML plus a list of
|
||||
* structural operations and returns new canvas XML.
|
||||
*
|
||||
* current XML → parse → apply operations → check names → layout → render → new XML
|
||||
*
|
||||
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
|
||||
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
|
||||
* box, adding an annotation — are simply part of the input to the next layout. There is
|
||||
* no second copy of the state, and therefore nothing to reconcile.
|
||||
*/
|
||||
|
||||
import { checkNames, resolveStyle } from "./catalog"
|
||||
import {
|
||||
applyOperations,
|
||||
collectNames,
|
||||
type Operation,
|
||||
outline,
|
||||
} from "./operations"
|
||||
import { parseDiagram } from "./parse"
|
||||
import { renderDiagram } from "./render"
|
||||
import { nearestShape, resolveShape } from "./shapes"
|
||||
import { type DiagramTree, walkTree } from "./types"
|
||||
|
||||
export interface RestructureResult {
|
||||
/** New canvas XML, or null when the request could not be carried out. */
|
||||
xml: string | null
|
||||
/** Compact outline of the resulting structure, for the model to read back. */
|
||||
outline: string
|
||||
/** Operations that could not be applied, and invented stencil names. */
|
||||
errors: string[]
|
||||
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface RestructureOptions {
|
||||
/** Which page of a multi-page document to work on. */
|
||||
pageIndex?: number
|
||||
/** Diagram-wide icon glyph size. */
|
||||
iconSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply structural operations to whatever is on the canvas.
|
||||
*
|
||||
* `currentXml` may be empty — that is how a diagram gets built from scratch.
|
||||
*
|
||||
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
|
||||
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
|
||||
* missing icons. The error carries suggestions from the catalog so the model can fix it
|
||||
* in one more turn.
|
||||
*/
|
||||
export function restructureDiagram(
|
||||
currentXml: string,
|
||||
ops: Operation[],
|
||||
opts: RestructureOptions = {},
|
||||
): RestructureResult {
|
||||
const warnings: string[] = []
|
||||
|
||||
let tree: DiagramTree
|
||||
if (currentXml.trim()) {
|
||||
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
|
||||
tree = parsed.tree
|
||||
warnings.push(...parsed.warnings)
|
||||
} else {
|
||||
tree = { roots: [], links: [], foreign: [] }
|
||||
}
|
||||
|
||||
const applied = applyOperations(tree, ops)
|
||||
const errors = [...applied.errors]
|
||||
warnings.push(...applied.warnings)
|
||||
|
||||
// Catch invented names before rendering, so the model gets a correctable error
|
||||
// instead of a diagram with blank squares in it.
|
||||
for (const bad of checkNames(collectNames(applied.tree))) {
|
||||
const hint = bad.suggestions.length
|
||||
? ` Did you mean: ${bad.suggestions.join(", ")}?`
|
||||
: ""
|
||||
errors.push(
|
||||
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Shape tokens: an injection-capable token is an error; an unknown-but-safe one
|
||||
// passes through (draw.io degrades it to a rectangle) but gets a warning, so a typo
|
||||
// is a one-turn fix instead of a silently rectangular "cyclinder" forever.
|
||||
for (const n of walkTree(applied.tree)) {
|
||||
if (n.kind !== "box" || !n.shape || n.shape === "box") continue
|
||||
const resolved = resolveShape(n.shape)
|
||||
if (!resolved) {
|
||||
errors.push(
|
||||
`shape "${n.shape}" (node ${n.id}) contains characters that are not allowed in a shape token.`,
|
||||
)
|
||||
} else if (resolved.passthrough) {
|
||||
const near = nearestShape(n.shape)
|
||||
warnings.push(
|
||||
`shape "${n.shape}" (node ${n.id}) is not in the engine's catalog — passed through to draw.io, which renders unknown shapes as rectangles.${near ? ` Did you mean "${near}"?` : ""}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0)
|
||||
return { xml: null, outline: outline(applied.tree), errors, warnings }
|
||||
|
||||
const rendered = renderDiagram(applied.tree, {
|
||||
resolveStyle,
|
||||
iconSize: opts.iconSize,
|
||||
})
|
||||
if (rendered.danglingLinks.length)
|
||||
warnings.push(
|
||||
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
|
||||
)
|
||||
|
||||
return {
|
||||
xml: rendered.xml,
|
||||
outline: outline(applied.tree),
|
||||
errors: [],
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the current canvas structure without changing it. */
|
||||
export function describeDiagram(
|
||||
currentXml: string,
|
||||
pageIndex = 0,
|
||||
): { outline: string; warnings: string[]; needsAdoption: boolean } {
|
||||
if (!currentXml.trim())
|
||||
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
|
||||
const { tree, warnings, needsAdoption } = parseDiagram(
|
||||
currentXml,
|
||||
pageIndex,
|
||||
)
|
||||
return { outline: outline(tree), warnings, needsAdoption }
|
||||
}
|
||||
|
||||
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
|
||||
export {
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type GraphOptions,
|
||||
graphToOperations,
|
||||
} from "./graph"
|
||||
export { type Operation, OperationSchema } from "./operations"
|
||||
export { parseDiagram } from "./parse"
|
||||
export { renderDiagram } from "./render"
|
||||
export type { DiagramNode, DiagramTree } from "./types"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,585 +0,0 @@
|
||||
/**
|
||||
* Style markers — how layout structure survives a round-trip through draw.io.
|
||||
*
|
||||
* The layout engine's tree carries information plain draw.io XML does not: which
|
||||
* direction a container stacks its children, the gap between them, and whether the
|
||||
* user has pinned a node's position. We encode that as extra `key=value` tokens in
|
||||
* the cell's style string.
|
||||
*
|
||||
* Two behaviours this relies on, both verified in a real browser (Playwright drag
|
||||
* against the embedded editor, reading the editor's own autosave payload):
|
||||
*
|
||||
* 1. draw.io PRESERVES style keys it does not understand. After a user drags a
|
||||
* shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came
|
||||
* back byte-identical.
|
||||
* 2. On a DUPLICATE key, the LAST value wins. A style ending in
|
||||
* `container=0;pointerEvents=0;container=1;` behaved as a container: a shape
|
||||
* dragged into it was reparented. So we can append a normalising token without
|
||||
* first parsing out the old one.
|
||||
*
|
||||
* (2) matters because the AWS catalog is inconsistent: group_region, group_vpc,
|
||||
* group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship
|
||||
* WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2,
|
||||
* group_security_group and group_corporate_data_center ship WITH it. Appending
|
||||
* unconditionally normalises all of them.
|
||||
*/
|
||||
|
||||
/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */
|
||||
export const MARKER = {
|
||||
/** Node kind, so the parser does not have to re-guess it from the shape. */
|
||||
kind: "dai_kind",
|
||||
/** Child stacking direction of a container: "row" | "col" | "grid". */
|
||||
dir: "dai_dir",
|
||||
/** Gap between children, in px. */
|
||||
gap: "dai_gap",
|
||||
/** Column count, for grid containers. */
|
||||
cols: "dai_cols",
|
||||
/** Set by the user to freeze a node's position across re-layouts. */
|
||||
pin: "dai_pin",
|
||||
/**
|
||||
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
|
||||
* base64 image with no name anywhere in it, so the style alone cannot identify it.
|
||||
*/
|
||||
name: "dai_name",
|
||||
/**
|
||||
* Which (lane, column) cell of a swimlane pool a node occupies, as "lane,col".
|
||||
*
|
||||
* Position alone cannot recover this once the user drags a node: the cell it lands in
|
||||
* is a guess, whereas the marker records which lane the model assigned it to. It is
|
||||
* also the only way an empty cell stays empty — geometry can only tell us where things
|
||||
* ARE, never that a role deliberately does nothing at a given step.
|
||||
*/
|
||||
cell: "dai_cell",
|
||||
/** A pool's lane names, tab-separated (a tab cannot appear in a draw.io style value). */
|
||||
lanes: "dai_lanes",
|
||||
/** A pool's milestone labels, tab-separated. */
|
||||
phases: "dai_phases",
|
||||
/** A pool's orientation: "h" or "v". */
|
||||
orient: "dai_orient",
|
||||
/** Vertical distance between consecutive messages in a sequence diagram. */
|
||||
step: "dai_step",
|
||||
/** How a radial container fans its branches out: "radial" or "down". */
|
||||
spread: "dai_spread",
|
||||
/** The node's information role (banner, heading, callout…), for the round trip. */
|
||||
role: "dai_role",
|
||||
/** The node's semantic zone, whose hue ramp colours it. */
|
||||
group: "dai_group",
|
||||
/**
|
||||
* The declared shape token, verbatim. Appearance-based reverse mapping is ambiguous
|
||||
* (aliases, rotated variants, styles with no unique shape= token), so the round trip
|
||||
* carries the declaration itself.
|
||||
*/
|
||||
shape: "dai_shape",
|
||||
/**
|
||||
* Marks a node's size as engine-computed rather than user-fixed. Without it, the
|
||||
* w/h read back from the canvas would freeze the first layout's measurement: change
|
||||
* the label and the box would keep the old size instead of re-measuring.
|
||||
*/
|
||||
auto: "dai_auto",
|
||||
/** Share of the parent's leftover flow-axis space — flex-grow. */
|
||||
grow: "dai_grow",
|
||||
/** Cross-axis position within the parent: "start" | "center" | "end". */
|
||||
align: "dai_align",
|
||||
/** How a container spreads children along its own axis — justify-content. */
|
||||
justify: "dai_justify",
|
||||
/** A container's cross-axis default for children that declare no align of their own. */
|
||||
alignItems: "dai_aitems",
|
||||
/** Opted out of the content-width floor when weights divide a row — CSS's min-width:0. */
|
||||
minw0: "dai_minw0",
|
||||
/**
|
||||
* Declared width cap, px.
|
||||
*
|
||||
* Has to be a marker rather than inferred from the drawn width: the two are only equal
|
||||
* when the cap actually bit. A box capped at 400 that happens to be 260 wide would come
|
||||
* back with a 260 cap, and the next re-layout could never let it grow again.
|
||||
*/
|
||||
maxw: "dai_maxw",
|
||||
/** A container's interior padding, px. */
|
||||
pad: "dai_pad",
|
||||
/**
|
||||
* The page's declared width:height, on the default layer's cell.
|
||||
*
|
||||
* Page-level rather than per-node, so it goes on layer "1" — the one cell every
|
||||
* diagram has and draw.io never discards. It cannot be inferred from pageWidth and
|
||||
* pageHeight: those are what the last layout produced, so reading them back would
|
||||
* turn whatever shape a diagram happened to come out as into a standing request to
|
||||
* keep it.
|
||||
*/
|
||||
aspect: "dai_aspect",
|
||||
/**
|
||||
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
|
||||
* columns, its milestone strip. The parser must not read these back as nodes — they are
|
||||
* re-derived from the pool's own parameters on every layout — and the edge router must
|
||||
* not treat them as obstacles, since a sequence flow crossing lanes is the norm.
|
||||
*/
|
||||
lane: "dai_lane",
|
||||
} as const
|
||||
|
||||
export type NodeKind =
|
||||
| "group"
|
||||
| "grid"
|
||||
| "pool"
|
||||
| "sequence"
|
||||
| "radial"
|
||||
| "icon"
|
||||
| "box"
|
||||
| "title"
|
||||
export type Direction = "row" | "col" | "grid"
|
||||
|
||||
/**
|
||||
* Tokens that make a shape behave as a container in draw.io: it accepts a shape
|
||||
* dragged into it and reparents that shape (setting `parent` and switching the
|
||||
* child's geometry to parent-relative).
|
||||
*
|
||||
* `pointerEvents=0` keeps clicks falling through to the children — without it the
|
||||
* frame swallows them and the user cannot select what is inside. `collapsible=0`
|
||||
* hides the fold arrow. `recursiveResize=0` stops children from being scaled when
|
||||
* the frame is resized, which would fight the layout engine.
|
||||
*/
|
||||
const CONTAINER_TOKENS =
|
||||
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
|
||||
|
||||
/**
|
||||
* A container that groups children for layout but should not be visible.
|
||||
*
|
||||
* The reference project solves this with a "phantom": a wrapper that participates in
|
||||
* layout and then emits NO cell, reparenting its children onto the nearest visible
|
||||
* ancestor. That makes the round-trip lossy by construction — the wrapper's direction
|
||||
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
|
||||
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
|
||||
* container's "col" direction, leaving children in a 2-D arrangement that can only be
|
||||
* read back as a grid.
|
||||
*
|
||||
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
|
||||
* in exchange for structure that survives being read back.
|
||||
*/
|
||||
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
|
||||
|
||||
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
|
||||
export function readMarker(style: string, key: string): string | null {
|
||||
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
|
||||
const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g")
|
||||
let last: string | null = null
|
||||
let m = re.exec(style)
|
||||
while (m !== null) {
|
||||
last = m[1]
|
||||
m = re.exec(style)
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
const KINDS: readonly NodeKind[] = [
|
||||
"group",
|
||||
"grid",
|
||||
"pool",
|
||||
"sequence",
|
||||
"radial",
|
||||
"icon",
|
||||
"box",
|
||||
"title",
|
||||
]
|
||||
|
||||
export function readKind(style: string): NodeKind | null {
|
||||
const v = readMarker(style, MARKER.kind)
|
||||
return KINDS.includes(v as NodeKind) ? (v as NodeKind) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The (lane, column) cell a node occupies in a swimlane pool, or null.
|
||||
*
|
||||
* Both must be non-negative integers: a malformed value is safer read as "no cell
|
||||
* declared" (which puts the node in lane 0 column 0) than as a negative index, which would
|
||||
* place it outside the pool's frame.
|
||||
*/
|
||||
export function readCell(style: string): { lane: number; col: number } | null {
|
||||
const v = readMarker(style, MARKER.cell)
|
||||
if (!v) return null
|
||||
const m = v.match(/^(\d+),(\d+)$/)
|
||||
return m ? { lane: Number(m[1]), col: Number(m[2]) } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab-separated marker list, as written by `joinList`.
|
||||
*
|
||||
* A tab cannot appear in a draw.io style value — the editor writes styles as a single
|
||||
* semicolon-separated line — so it is safe as a separator inside one value, where a comma
|
||||
* would collide with the label text it has to carry.
|
||||
*/
|
||||
export function readList(style: string, key: string): string[] | null {
|
||||
const v = readMarker(style, key)
|
||||
if (v === null) return null
|
||||
if (v === "") return []
|
||||
return v.split("\t").map(decodeURIComponent)
|
||||
}
|
||||
|
||||
/** Encode a list of labels into one marker value. */
|
||||
export function joinList(items: string[]): string {
|
||||
// Percent-encoding keeps a label containing ";" or "=" from breaking the style string.
|
||||
return items.map((s) => encodeURIComponent(s)).join("\t")
|
||||
}
|
||||
|
||||
/** Is this cell pool chrome the engine draws and owns, rather than a node? */
|
||||
export function isLaneChrome(style: string): boolean {
|
||||
return readMarker(style, MARKER.lane) !== null
|
||||
}
|
||||
|
||||
export function readDir(style: string): Direction | null {
|
||||
const v = readMarker(style, MARKER.dir)
|
||||
if (v === "row" || v === "col" || v === "grid") return v
|
||||
return null
|
||||
}
|
||||
|
||||
/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */
|
||||
export function readIntMarker(style: string, key: string): number | null {
|
||||
const v = readMarker(style, key)
|
||||
if (v === null) return null
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the user pinned this node? Any value other than "0"/""/"false" counts as
|
||||
* pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's
|
||||
* "Edit Style" dialog gets what they expect.
|
||||
*/
|
||||
export function isPinned(style: string): boolean {
|
||||
const v = readMarker(style, MARKER.pin)
|
||||
if (v === null) return false
|
||||
const s = v.trim().toLowerCase()
|
||||
return s !== "" && s !== "0" && s !== "false"
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `key=value;`, replacing any existing occurrence of the key first.
|
||||
*
|
||||
* Styles are re-stamped on every render, and a style recovered from the canvas already
|
||||
* carries last render's markers — blindly appending grew the string by one duplicate per
|
||||
* round-trip, unboundedly. Duplicates resolve last-wins in draw.io so nothing ever LOOKED
|
||||
* wrong, which is why it went unnoticed until a byte-identity test caught it.
|
||||
*
|
||||
* Only `dai_*` keys are cleaned. mxGraph keys are appended verbatim because last-wins is
|
||||
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
|
||||
* style that may say `container=0`.
|
||||
*/
|
||||
/**
|
||||
* Set each `key=value;` token of `tokens` on a style, replacing any value already there.
|
||||
*
|
||||
* Matching is per token, not on the whole run: a catalog style may already declare
|
||||
* `container=1` while saying nothing about `pointerEvents`, and re-adding the whole run
|
||||
* because one token was missing is what let these accumulate.
|
||||
*
|
||||
* Exported because the same defect appeared a second time, on EDGES: an edge's style starts
|
||||
* from whatever the canvas held, which already carried the previous pass's `exitX`/`entryX`
|
||||
* port keys, and the router appended a fresh set on top of them every render — 76 characters
|
||||
* per round-trip, without bound. Any code that re-stamps a computed mxGraph key onto a style
|
||||
* recovered from the canvas needs this rather than `+=`.
|
||||
*/
|
||||
export function appendOnce(style: string, tokens: string): string {
|
||||
let s = style
|
||||
for (const tok of tokens.split(";")) {
|
||||
if (!tok) continue
|
||||
const key = tok.slice(0, tok.indexOf("="))
|
||||
// The key must not be present with ANY value: `container=0` from a catalog stencil
|
||||
// has to be overwritten, which is what appending the correct value does.
|
||||
const has = new RegExp(`(?:^|;)${key}=[^;]*;`).test(s)
|
||||
if (has) {
|
||||
s = s.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
}
|
||||
s = s.endsWith(";") || s === "" ? s : `${s};`
|
||||
s += `${tok};`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function append(style: string, key: string, value: string | number): string {
|
||||
const cleaned = key.startsWith("dai_")
|
||||
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
: style
|
||||
const base =
|
||||
cleaned.endsWith(";") || cleaned === "" ? cleaned : `${cleaned};`
|
||||
return `${base}${key}=${value};`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a container's style: make it a real draw.io container and record its
|
||||
* layout parameters.
|
||||
*
|
||||
* Appends rather than rewrites. Duplicate keys are legal and the last one wins, so
|
||||
* a catalog style that already says `container=1` is unharmed, and one that says
|
||||
* nothing (or `container=0`) is corrected.
|
||||
*/
|
||||
export function stampContainer(
|
||||
style: string,
|
||||
opts: {
|
||||
kind: "group" | "grid"
|
||||
dir: Direction
|
||||
gap: number
|
||||
cols?: number
|
||||
/** Layout-only wrapper: emit a real cell, but draw nothing. */
|
||||
invisible?: boolean
|
||||
},
|
||||
): string {
|
||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
||||
// Appended only when not already there. These are plain mxGraph keys, so `append`'s
|
||||
// de-duplication (which is limited to `dai_*`) does not cover them — and a container
|
||||
// goes through here on EVERY re-layout, so a blind `+=` grew the style string by
|
||||
// another `container=1;pointerEvents=0;collapsible=0;recursiveResize=0;` per round
|
||||
// trip, without bound. Harmless to draw.io, which takes the last value, but the XML
|
||||
// never reached a fixed point and every edit shipped a longer style.
|
||||
s = appendOnce(s, CONTAINER_TOKENS)
|
||||
if (opts.invisible) s = appendOnce(s, INVISIBLE_TOKENS)
|
||||
s = append(s, MARKER.kind, opts.kind)
|
||||
s = append(s, MARKER.dir, opts.dir)
|
||||
s = append(s, MARKER.gap, Math.round(opts.gap))
|
||||
if (opts.kind === "grid" && opts.cols != null)
|
||||
s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols)))
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a swimlane pool: its lane names, milestone labels and orientation.
|
||||
*
|
||||
* Unlike a group, a pool is NOT stamped as a draw.io container. Its lane bands are separate
|
||||
* cells sitting inside it, and they are what a shape should reparent into when the user
|
||||
* drags it — that is how "the user moved this step to a different role" gets recorded. If
|
||||
* the pool itself claimed the drop, every node would come back in lane 0.
|
||||
*/
|
||||
export function stampPool(
|
||||
style: string,
|
||||
opts: {
|
||||
lanes: string[]
|
||||
phases: string[]
|
||||
orientation: "horizontal" | "vertical"
|
||||
gap: number
|
||||
},
|
||||
): string {
|
||||
let s = append(style, MARKER.kind, "pool")
|
||||
s = append(s, MARKER.lanes, joinList(opts.lanes))
|
||||
s = append(s, MARKER.phases, joinList(opts.phases))
|
||||
s = append(s, MARKER.orient, opts.orientation === "vertical" ? "v" : "h")
|
||||
return append(s, MARKER.gap, Math.round(opts.gap))
|
||||
}
|
||||
|
||||
/** Stamp a sequence container: participant spacing and message spacing. */
|
||||
export function stampSequence(
|
||||
style: string,
|
||||
opts: { gap: number; step: number },
|
||||
): string {
|
||||
const s = append(style, MARKER.kind, "sequence")
|
||||
return append(
|
||||
append(s, MARKER.gap, Math.round(opts.gap)),
|
||||
MARKER.step,
|
||||
Math.round(opts.step),
|
||||
)
|
||||
}
|
||||
|
||||
/** Stamp a radial container: how it fans branches out, and the ring spacing. */
|
||||
export function stampRadial(
|
||||
style: string,
|
||||
opts: { spread: "radial" | "down"; gap: number },
|
||||
): string {
|
||||
const s = append(style, MARKER.kind, "radial")
|
||||
return append(
|
||||
append(s, MARKER.spread, opts.spread),
|
||||
MARKER.gap,
|
||||
Math.round(opts.gap),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp one of a pool's lane bands.
|
||||
*
|
||||
* A band IS a draw.io container, so dragging a step onto another role's band reparents it
|
||||
* there and the marker on the band tells the parser which lane that is. The lane index is
|
||||
* the band's identity, not its position, so the assignment survives the pool being
|
||||
* re-measured to a different size.
|
||||
*/
|
||||
export function stampLane(style: string, lane: number): string {
|
||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
||||
s += CONTAINER_TOKENS
|
||||
return append(s, MARKER.lane, Math.max(0, Math.round(lane)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a pool's own decoration — a lane-name column or a milestone strip.
|
||||
*
|
||||
* `dai_lane=-1` marks it as chrome the renderer rebuilds, so the parser drops it rather
|
||||
* than reading it back as a node. Unlike a lane band it is deliberately NOT a draw.io
|
||||
* container: a step dropped on a label column belongs to no role, and letting it reparent
|
||||
* there would lose the step's lane.
|
||||
*/
|
||||
export function stampPoolDecoration(style: string): string {
|
||||
return append(style, MARKER.lane, -1)
|
||||
}
|
||||
|
||||
/** Record which pool cell a node occupies. */
|
||||
export function stampCell(
|
||||
style: string,
|
||||
cell: { lane: number; col: number },
|
||||
): string {
|
||||
return append(
|
||||
style,
|
||||
MARKER.cell,
|
||||
`${Math.max(0, Math.round(cell.lane))},${Math.max(0, Math.round(cell.col))}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this an invisible layout wrapper? Both colours set to `none` and no group
|
||||
* stencil — a visible frame always has a stroke or a stencil.
|
||||
*/
|
||||
export function isInvisible(style: string): boolean {
|
||||
if (/grIcon=/.test(style)) return false
|
||||
const fill = readMarker(style, "fillColor")
|
||||
const stroke = readMarker(style, "strokeColor")
|
||||
return fill === "none" && stroke === "none"
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a leaf with its kind, so the parser need not infer it.
|
||||
*
|
||||
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
|
||||
* base64 image with no name in it, so the style alone cannot identify which icon it is.
|
||||
*/
|
||||
export function stampLeaf(
|
||||
style: string,
|
||||
kind: "icon" | "box" | "title",
|
||||
opts: { name?: string } = {},
|
||||
): string {
|
||||
const s = append(style, MARKER.kind, kind)
|
||||
return opts.name ? append(s, MARKER.name, opts.name) : s
|
||||
}
|
||||
|
||||
/** Stamp the node's information role, replacing any previous one. */
|
||||
export function stampRole(style: string, role: string): string {
|
||||
return append(style, MARKER.role, role)
|
||||
}
|
||||
|
||||
/** Stamp the node's semantic zone, replacing any previous one. */
|
||||
export function stampGroup(style: string, group: string): string {
|
||||
return append(style, MARKER.group, encodeURIComponent(group))
|
||||
}
|
||||
|
||||
/** Stamp the declared shape token, so the round trip carries the declaration itself. */
|
||||
export function stampShape(style: string, shape: string): string {
|
||||
return append(style, MARKER.shape, encodeURIComponent(shape))
|
||||
}
|
||||
|
||||
/** Mark a node's size as engine-computed, so a re-layout re-measures it. */
|
||||
export function stampAuto(style: string): string {
|
||||
return append(style, MARKER.auto, 1)
|
||||
}
|
||||
|
||||
/** Was this node's size computed by the engine (vs fixed by the user or the model)? */
|
||||
export function isAutoSized(style: string): boolean {
|
||||
return readMarker(style, MARKER.auto) === "1"
|
||||
}
|
||||
|
||||
type FlexAlign = "start" | "center" | "end" | "stretch"
|
||||
type FlexJustify = "start" | "center" | "end" | "between" | "around" | "evenly"
|
||||
|
||||
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
|
||||
export function stampFlex(
|
||||
style: string,
|
||||
opts: {
|
||||
grow?: number
|
||||
align?: FlexAlign
|
||||
justify?: FlexJustify
|
||||
alignItems?: FlexAlign
|
||||
maxW?: number
|
||||
minW0?: boolean
|
||||
pad?: number
|
||||
},
|
||||
): string {
|
||||
let s = style
|
||||
if (opts.grow != null && opts.grow > 0)
|
||||
s = append(s, MARKER.grow, opts.grow)
|
||||
if (opts.align && opts.align !== "center")
|
||||
s = append(s, MARKER.align, opts.align)
|
||||
if (opts.justify && opts.justify !== "start")
|
||||
s = append(s, MARKER.justify, opts.justify)
|
||||
if (opts.alignItems) s = append(s, MARKER.alignItems, opts.alignItems)
|
||||
if (opts.maxW != null && opts.maxW > 0)
|
||||
s = append(s, MARKER.maxw, Math.round(opts.maxW))
|
||||
if (opts.minW0) s = append(s, MARKER.minw0, 1)
|
||||
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
|
||||
return s
|
||||
}
|
||||
|
||||
/** Read the align marker back. Anything unrecognised means the default (center). */
|
||||
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
|
||||
const v = readMarker(style, MARKER.align)
|
||||
return v === "start" || v === "end" || v === "stretch" ? v : null
|
||||
}
|
||||
|
||||
/** Read a container's cross-axis default. Null means it declared none. */
|
||||
export function readAlignItems(style: string): FlexAlign | null {
|
||||
const v = readMarker(style, MARKER.alignItems)
|
||||
return v === "start" || v === "end" || v === "stretch" || v === "center"
|
||||
? v
|
||||
: null
|
||||
}
|
||||
|
||||
/** Read the justify marker back. Anything unrecognised means the default (start). */
|
||||
export function readJustify(
|
||||
style: string,
|
||||
): Exclude<FlexJustify, "start"> | null {
|
||||
const v = readMarker(style, MARKER.justify)
|
||||
return v === "center" ||
|
||||
v === "end" ||
|
||||
v === "between" ||
|
||||
v === "around" ||
|
||||
v === "evenly"
|
||||
? v
|
||||
: null
|
||||
}
|
||||
|
||||
/** Read the declared width cap back, or null when there was none. */
|
||||
export function readMaxW(style: string): number | null {
|
||||
const v = Number(readMarker(style, MARKER.maxw))
|
||||
return Number.isFinite(v) && v > 0 ? v : null
|
||||
}
|
||||
|
||||
/** Did this node opt out of the content-width floor? */
|
||||
export function readMinW0(style: string): boolean {
|
||||
return readMarker(style, MARKER.minw0) === "1"
|
||||
}
|
||||
|
||||
/** The page's declared aspect ratio, stamped on the default layer. */
|
||||
export function stampAspect(layerXml: string, aspect: number): string {
|
||||
return layerXml.replace(
|
||||
/<mxCell id="1" parent="0"\/>/,
|
||||
`<mxCell id="1" parent="0" style="${MARKER.aspect}=${aspect};"/>`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the page's declared aspect back out of a model body.
|
||||
*
|
||||
* Scans for the marker anywhere in the page rather than parsing the layer cell: the
|
||||
* marker name is namespaced, so a match cannot be anything else, and this keeps working
|
||||
* if draw.io ever reorders or reformats that cell.
|
||||
*/
|
||||
export function readAspect(page: string): number | undefined {
|
||||
const m = new RegExp(`${MARKER.aspect}=([\\d.]+)`).exec(page)
|
||||
if (!m) return undefined
|
||||
const v = Number(m[1])
|
||||
return Number.isFinite(v) && v > 0
|
||||
? Math.min(4, Math.max(0.25, v))
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
||||
export function stripMarkers(style: string): string {
|
||||
return style
|
||||
.split(";")
|
||||
.filter((tok) => tok !== "" && !tok.startsWith("dai_"))
|
||||
.join(";")
|
||||
.concat(";")
|
||||
.replace(/^;$/, "")
|
||||
}
|
||||
|
||||
/** Does this style carry any engine marker? Used to tell engine output from imported files. */
|
||||
export function hasMarkers(style: string): boolean {
|
||||
return /(?:^|;)dai_[a-z]+=/.test(style)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* The shape vocabulary: what a box can BE, beyond a labelled rectangle.
|
||||
*
|
||||
* draw.io ships hundreds of shapes; the engine's declarative layer used to allow six.
|
||||
* That gap — not colours, not spacing — was why engine output looked flat next to
|
||||
* hand-written XML: a database drawn as a grey rectangle labelled "database" instead of
|
||||
* a cylinder. This module opens the vocabulary in two tiers:
|
||||
*
|
||||
* CATALOG — ~20 curated shapes the engine fully understands. Each entry carries the
|
||||
* complete style fragment (including the matching `perimeter=`, which draw.io's own
|
||||
* style reference warns is required or edges connect to the bounding box), how much
|
||||
* larger the box must be for its text to fit inside the non-rectangular outline
|
||||
* (verified empirically in the real editor: a rhombus needs ~1.5× the rectangle's
|
||||
* size for the same text), and whether the label renders below the glyph instead of
|
||||
* inside it.
|
||||
*
|
||||
* PASS-THROUGH — any other token that looks like a draw.io shape name is emitted
|
||||
* verbatim as `shape=<token>;`. Verified in the real editor: an unknown token
|
||||
* degrades to a rectangle, it does not break the page. A conservative text scale
|
||||
* covers the common case that the real shape is roughly convex. The tool response
|
||||
* carries a near-match hint ("cyclinder → cylinder?") so a typo is a one-turn fix,
|
||||
* not a silent permanent degradation.
|
||||
*
|
||||
* Style strings are merged structurally, not concatenated: each fragment is parsed to
|
||||
* key=value tokens and later fragments override earlier ones per key. This is what
|
||||
* makes shape and theme composable by rule — the shape fragment owns geometry keys
|
||||
* (shape, perimeter, rounded…), the theme owns colour and type keys, and an overlap
|
||||
* (a theme that says rounded=1 on a rhombus) resolves by order instead of emitting
|
||||
* two conflicting tokens.
|
||||
*/
|
||||
|
||||
/** How a known shape renders and measures. */
|
||||
export interface ShapeSpec {
|
||||
/** Geometry style tokens ONLY — no colours, no fonts; those belong to the theme. */
|
||||
style: string
|
||||
/**
|
||||
* How much larger than a rectangle the box must be for the same text to fit
|
||||
* inside the outline. 1.0 for the rectangle family; ~1.5 for a rhombus, whose
|
||||
* inscribed rectangle is half its bounding box.
|
||||
*/
|
||||
textScale: number
|
||||
/** The label renders below the glyph, not inside it (umlActor and friends). */
|
||||
labelOutside?: boolean
|
||||
/** Fixed glyph size for labelOutside shapes, which do not scale with text. */
|
||||
glyph?: { w: number; h: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated catalog. Keys are the vocabulary the model is taught; several are
|
||||
* semantic aliases for the same geometry (decision/diamond), because the model will
|
||||
* reach for both names.
|
||||
*/
|
||||
export const SHAPE_CATALOG: Record<string, ShapeSpec> = {
|
||||
// ---- the rectangle family (the original six) ----
|
||||
box: { style: "rounded=0;", textScale: 1 },
|
||||
round: { style: "rounded=1;arcSize=12;", textScale: 1 },
|
||||
terminator: { style: "rounded=1;arcSize=50;", textScale: 1.15 },
|
||||
decision: {
|
||||
style: "rhombus;perimeter=rhombusPerimeter;",
|
||||
textScale: 1.5,
|
||||
},
|
||||
diamond: {
|
||||
style: "rhombus;perimeter=rhombusPerimeter;",
|
||||
textScale: 1.5,
|
||||
},
|
||||
data: {
|
||||
style: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
|
||||
textScale: 1.2,
|
||||
},
|
||||
document: { style: "shape=document;boundedLbl=1;", textScale: 1.15 },
|
||||
|
||||
// ---- the semantic vocabulary (D2's tier: a node that IS a thing) ----
|
||||
/** A database or datastore. */
|
||||
cylinder: {
|
||||
style: "shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=12;",
|
||||
textScale: 1.3,
|
||||
},
|
||||
/** A message queue: a cylinder on its side. */
|
||||
queue: {
|
||||
style: "shape=cylinder3;direction=south;boundedLbl=1;backgroundOutline=1;size=12;",
|
||||
textScale: 1.3,
|
||||
},
|
||||
/** An actor or user. Label below the figure. */
|
||||
person: {
|
||||
style: "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;outlineConnect=0;",
|
||||
textScale: 1,
|
||||
labelOutside: true,
|
||||
glyph: { w: 40, h: 60 },
|
||||
},
|
||||
/** An external system, the internet. */
|
||||
cloud: { style: "ellipse;shape=cloud;", textScale: 1.6 },
|
||||
/** A service or process step. */
|
||||
hexagon: {
|
||||
style: "shape=hexagon;perimeter=hexagonPerimeter2;fixedSize=1;size=16;",
|
||||
textScale: 1.25,
|
||||
},
|
||||
/** A concept, state or category. */
|
||||
ellipse: { style: "ellipse;", textScale: 1.3 },
|
||||
/** A speech-bubble annotation. */
|
||||
callout: {
|
||||
style: "shape=callout;perimeter=calloutPerimeter;rounded=1;size=16;position=0.5;base=24;",
|
||||
textScale: 1.35,
|
||||
},
|
||||
/** A chevron stage in a pipeline. */
|
||||
step: {
|
||||
style: "shape=step;perimeter=stepPerimeter;fixedSize=1;size=16;",
|
||||
textScale: 1.2,
|
||||
},
|
||||
/** A sticky note. */
|
||||
note: { style: "shape=note;size=14;", textScale: 1.1 },
|
||||
/** A card with a cut corner. */
|
||||
card: { style: "shape=card;size=14;", textScale: 1.1 },
|
||||
/** A process box with side bars (predefined subroutine). */
|
||||
process: { style: "shape=process;size=0.1;", textScale: 1.2 },
|
||||
/** Punched tape — legacy data, files. */
|
||||
tape: { style: "shape=tape;size=0.2;", textScale: 1.3 },
|
||||
/** A double-walled cube. */
|
||||
cube: { style: "shape=cube;size=12;", textScale: 1.25 },
|
||||
}
|
||||
|
||||
/** A shape token that may pass through unrecognised: draw.io style-key charset only. */
|
||||
const SAFE_TOKEN = /^[a-zA-Z0-9._]+$/
|
||||
|
||||
export interface ResolvedShape {
|
||||
spec: ShapeSpec
|
||||
/** Set when the token was not in the catalog and passed through verbatim. */
|
||||
passthrough?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a shape token: catalog entry, safe pass-through, or null for a token that
|
||||
* could inject style keys (`;`/`=`/quotes) and must be rejected outright.
|
||||
*/
|
||||
export function resolveShape(token: string): ResolvedShape | null {
|
||||
const known = SHAPE_CATALOG[token]
|
||||
if (known) return { spec: known }
|
||||
if (!SAFE_TOKEN.test(token)) return null
|
||||
// Unknown but safe: emit verbatim. draw.io degrades an unregistered shape to a
|
||||
// rectangle, so the worst case is a plain box — same as before the vocabulary
|
||||
// existed. The conservative scale covers roughly-convex real shapes.
|
||||
return {
|
||||
spec: { style: `shape=${token};`, textScale: 1.25 },
|
||||
passthrough: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** The catalog key most similar to a token, for "did you mean" hints. */
|
||||
export function nearestShape(token: string): string | null {
|
||||
const t = token.toLowerCase()
|
||||
let best: string | null = null
|
||||
let bestD = 3 // more than 2 edits away is not a typo
|
||||
for (const key of Object.keys(SHAPE_CATALOG)) {
|
||||
const d = editDistance(t, key.toLowerCase(), bestD)
|
||||
if (d < bestD) {
|
||||
bestD = d
|
||||
best = key
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Bounded Levenshtein distance; returns limit when the strings are further apart. */
|
||||
function editDistance(a: string, b: string, limit: number): number {
|
||||
if (Math.abs(a.length - b.length) >= limit) return limit
|
||||
const prev = new Array(b.length + 1)
|
||||
for (let j = 0; j <= b.length; j++) prev[j] = j
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
let diag = prev[0]
|
||||
prev[0] = i
|
||||
let rowMin = prev[0]
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cur = Math.min(
|
||||
prev[j] + 1,
|
||||
prev[j - 1] + 1,
|
||||
diag + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
)
|
||||
diag = prev[j]
|
||||
prev[j] = cur
|
||||
if (cur < rowMin) rowMin = cur
|
||||
}
|
||||
if (rowMin >= limit) return limit
|
||||
}
|
||||
return Math.min(prev[b.length], limit)
|
||||
}
|
||||
|
||||
// ---- structured style merge ----
|
||||
|
||||
/**
|
||||
* Merge style fragments by key, later fragments winning.
|
||||
*
|
||||
* A draw.io style is `tok;key=value;key=value;` — bare class tokens (rhombus, ellipse,
|
||||
* text) come first and key=value pairs follow. String concatenation made every
|
||||
* conflict a duplicate key resolved by draw.io's last-wins rule, which worked until
|
||||
* shape fragments and theme fragments both owned geometry keys (a theme's rounded=1
|
||||
* against a shape's rhombus). Merging structurally keeps exactly one token per key and
|
||||
* one bare-token set, so the output is canonical and the ownership rule — theme owns
|
||||
* colour and type, shape owns geometry — is enforced by fragment ORDER, not by hoping
|
||||
* the keys never meet.
|
||||
*
|
||||
* Bare tokens are kept in first-appearance order, except that a later fragment's bare
|
||||
* SHAPE CLASS (rhombus/ellipse/triangle) replaces an earlier one — two shape classes
|
||||
* on one cell is a contradiction, not a union.
|
||||
*/
|
||||
export function mergeStyle(...fragments: (string | undefined)[]): string {
|
||||
const bare: string[] = []
|
||||
const kv = new Map<string, string>()
|
||||
const SHAPE_CLASSES = new Set(["rhombus", "ellipse", "triangle"])
|
||||
for (const f of fragments) {
|
||||
if (!f) continue
|
||||
for (const tok of f.split(";")) {
|
||||
if (tok === "") continue
|
||||
const eq = tok.indexOf("=")
|
||||
if (eq < 0) {
|
||||
if (SHAPE_CLASSES.has(tok)) {
|
||||
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
|
||||
if (i >= 0) bare.splice(i, 1)
|
||||
}
|
||||
if (!bare.includes(tok)) bare.push(tok)
|
||||
continue
|
||||
}
|
||||
const key = tok.slice(0, eq)
|
||||
kv.set(key, tok.slice(eq + 1))
|
||||
// An explicit shape= also displaces a bare shape class from an earlier
|
||||
// fragment — same contradiction as two bare classes.
|
||||
if (key === "shape") {
|
||||
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
|
||||
if (i >= 0) bare.splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = bare.join(";")
|
||||
if (out) out += ";"
|
||||
for (const [k, v] of kv) out += `${k}=${v};`
|
||||
return out
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* The theme: design tokens plus one composition rule, in place of style tables.
|
||||
*
|
||||
* The engine's original deal was: the model declares structure, the engine computes
|
||||
* geometry. But every box rendered identically — white, 11px, black border — so anything
|
||||
* whose meaning lives in visual hierarchy (a paper-summary poster, a cheat sheet, a
|
||||
* comparison panel) came out flat, and the only escape was hand-written XML with no layout
|
||||
* guarantees at all.
|
||||
*
|
||||
* Two ideas fix that generally, not per diagram type:
|
||||
*
|
||||
* ROLE — what a node IS in the information hierarchy: a masthead, a section heading, a
|
||||
* key number, fine print. The model judges this well. Each role maps to a type size and
|
||||
* an emphasis (filled / outlined / ghost), not to any colour.
|
||||
*
|
||||
* GROUP — which semantic zone a node belongs to: remote vs local, one poster section vs
|
||||
* another. Each distinct group name gets one HUE RAMP — a light tint, a mid stroke, a
|
||||
* dark text colour — assigned in order of first appearance.
|
||||
*
|
||||
* `themedStyle(role, hue, kind)` composes the two by rule. A heading container in group 2
|
||||
* gets that hue's tint as its panel and the dark step for its title; a metric in the same
|
||||
* group gets the mid step as a heavy border. Nothing is enumerated per combination, so a
|
||||
* new diagram kind gets full theming by tagging its nodes — there is no table to extend.
|
||||
* The model never sees a hex value; the same declaration always renders the same way.
|
||||
*/
|
||||
|
||||
/** What a node is, in the information hierarchy of the diagram. */
|
||||
export type Role =
|
||||
| "banner" // the masthead: large type on the theme's one dark field
|
||||
| "heading" // a section title / titled panel
|
||||
| "body" // ordinary content (the default look)
|
||||
| "callout" // something the reader must not miss
|
||||
| "good" // a positive verdict (always green, group or not)
|
||||
| "bad" // a negative verdict or warning (always red)
|
||||
| "metric" // the key number
|
||||
| "muted" // fine print
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
]
|
||||
|
||||
export function isRole(v: string | null | undefined): v is Role {
|
||||
return ROLES.includes(v as Role)
|
||||
}
|
||||
|
||||
// ---- tokens ----
|
||||
|
||||
/** One hue, three steps: a field to sit on, a line to draw with, a colour to write in. */
|
||||
export interface HueRamp {
|
||||
tint: string
|
||||
base: string
|
||||
dark: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The hue ramps groups draw from, in assignment order.
|
||||
*
|
||||
* Tint/base pairs are draw.io's classic palette, so themed output looks native to the
|
||||
* editor; the dark step is the same hue pulled down far enough for 4.5:1 text on white.
|
||||
*/
|
||||
export const HUES: readonly HueRamp[] = [
|
||||
{ tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }, // blue
|
||||
{ tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }, // green
|
||||
{ tint: "#FFE6CC", base: "#D79B00", dark: "#8A5A00" }, // orange
|
||||
{ tint: "#E1D5E7", base: "#9673A6", dark: "#4A2E5E" }, // purple
|
||||
{ tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }, // red
|
||||
{ tint: "#FFF2CC", base: "#D6B656", dark: "#7A5C00" }, // yellow
|
||||
]
|
||||
|
||||
/** The neutral ramp, for ungrouped nodes: today's grey-on-white look. */
|
||||
export const NEUTRAL: HueRamp = {
|
||||
tint: "#F5F8FB",
|
||||
base: "#5A6B7B",
|
||||
dark: "#1A1A1A",
|
||||
}
|
||||
|
||||
/** Semantic verdict hues: good is green and bad is red no matter what group says. */
|
||||
const GOOD: HueRamp = { tint: "#D5E8D4", base: "#82B366", dark: "#1B5E20" }
|
||||
const BAD: HueRamp = { tint: "#F8CECC", base: "#B85450", dark: "#7F1D1D" }
|
||||
/** The callout field: a warm highlight distinct from every group tint. */
|
||||
const CALLOUT: HueRamp = { tint: "#FFF9C4", base: "#B8860B", dark: "#6D4C00" }
|
||||
|
||||
/** Type scale, px. One scale for every diagram kind. */
|
||||
export const TYPE = { xs: 9, sm: 11, md: 13, lg: 15, xl: 20 } as const
|
||||
|
||||
/** The hue ramp for the n-th distinct group. Wraps: a 7th group reuses the 1st hue. */
|
||||
export function hueOf(index: number): HueRamp {
|
||||
return HUES[index % HUES.length]
|
||||
}
|
||||
|
||||
// ---- the composition rule ----
|
||||
|
||||
/** How a role renders, independent of colour. */
|
||||
interface RoleSpec {
|
||||
size: number
|
||||
bold: boolean
|
||||
/** filled: dark field, light text. tinted: hue field. outlined: white field, hue border.
|
||||
* ghost: no field, no border — pure text. */
|
||||
emphasis: "filled" | "tinted" | "outlined" | "ghost"
|
||||
/** Overrides the group hue; verdicts stay green/red whatever zone they sit in. */
|
||||
hue?: HueRamp
|
||||
/** Fill the container's cross axis, the way a masthead spans its page. */
|
||||
stretch?: boolean
|
||||
/** Minimum cell height. */
|
||||
minH: number
|
||||
/** Character width relative to 11px type, for the measure pass. */
|
||||
charScale: number
|
||||
}
|
||||
|
||||
/** The masthead field when no group says otherwise: the deep navy of the first hue. */
|
||||
const BANNER: HueRamp = { tint: "#DAE8FC", base: "#6C8EBF", dark: "#1A237E" }
|
||||
|
||||
const ROLE_SPECS: Record<Role, RoleSpec> = {
|
||||
banner: {
|
||||
size: TYPE.xl,
|
||||
bold: true,
|
||||
emphasis: "filled",
|
||||
hue: BANNER,
|
||||
stretch: true,
|
||||
minH: 64,
|
||||
charScale: 1.8,
|
||||
},
|
||||
heading: {
|
||||
size: TYPE.lg,
|
||||
bold: true,
|
||||
emphasis: "ghost",
|
||||
stretch: true,
|
||||
minH: 32,
|
||||
charScale: 1.35,
|
||||
},
|
||||
body: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "outlined",
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
callout: {
|
||||
size: TYPE.sm,
|
||||
bold: true,
|
||||
emphasis: "tinted",
|
||||
hue: CALLOUT,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
good: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "tinted",
|
||||
hue: GOOD,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
bad: {
|
||||
size: TYPE.sm,
|
||||
bold: false,
|
||||
emphasis: "tinted",
|
||||
hue: BAD,
|
||||
minH: 44,
|
||||
charScale: 1,
|
||||
},
|
||||
metric: {
|
||||
size: TYPE.xl,
|
||||
bold: true,
|
||||
emphasis: "outlined",
|
||||
minH: 56,
|
||||
charScale: 1.8,
|
||||
},
|
||||
muted: {
|
||||
size: TYPE.xs,
|
||||
bold: false,
|
||||
emphasis: "ghost",
|
||||
minH: 24,
|
||||
charScale: 0.82,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this role already draw itself with no border?
|
||||
*
|
||||
* The parser needs this to tell a THEME's `strokeColor=none` from a DECLARED one. A banner is
|
||||
* a dark filled slab and a heading is ghost text; both are borderless because of what they
|
||||
* are, not because anyone asked. Recording that as an explicit override would make it
|
||||
* outlive a later role change, since `set_role` clears a node's style but keeps its text
|
||||
* overrides.
|
||||
*
|
||||
* Leaf only: the container branch of `themedStyle` always draws a border, whatever the role.
|
||||
*/
|
||||
export function roleIsBorderless(
|
||||
role: Role | undefined,
|
||||
kind: "leaf" | "container",
|
||||
): boolean {
|
||||
if (kind === "container") return false
|
||||
const e = ROLE_SPECS[role ?? "body"].emphasis
|
||||
return e === "filled" || e === "ghost"
|
||||
}
|
||||
|
||||
/** Metrics the measure pass needs, so layout reserves what render will draw. */
|
||||
export function roleMetrics(role: Role | undefined): {
|
||||
fontSize: number
|
||||
minH: number
|
||||
charScale: number
|
||||
stretch: boolean
|
||||
} {
|
||||
const s = ROLE_SPECS[role ?? "body"]
|
||||
return {
|
||||
fontSize: s.size,
|
||||
minH: s.minH,
|
||||
charScale: s.charScale,
|
||||
stretch: s.stretch === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The style tokens for one node: the whole theme in a single rule.
|
||||
*
|
||||
* `hue` is the node's group ramp (or the neutral ramp); a role with a semantic hue
|
||||
* (good/bad/callout) overrides it. `kind` softens the treatment for containers — a
|
||||
* section panel is a field its children sit on, so it takes the tint at panel weight
|
||||
* rather than a leaf's full treatment.
|
||||
*/
|
||||
export function themedStyle(
|
||||
role: Role,
|
||||
hue: HueRamp,
|
||||
kind: "leaf" | "container",
|
||||
): string {
|
||||
const spec = ROLE_SPECS[role]
|
||||
const ramp = spec.hue ?? hue
|
||||
const size = kind === "container" && role === "banner" ? TYPE.lg : spec.size
|
||||
const font = `fontSize=${size};${spec.bold || kind === "container" ? "fontStyle=1;" : ""}`
|
||||
|
||||
if (spec.emphasis === "filled")
|
||||
return `fillColor=${ramp.dark};strokeColor=none;fontColor=#FFFFFF;${font}rounded=1;arcSize=6;`
|
||||
|
||||
if (kind === "container") {
|
||||
// A panel: the tint as a quiet field, the dark step for its title, the base for
|
||||
// its border. This is where "every section gets its own colour" comes from —
|
||||
// a heading container plus a group, no extra mechanism.
|
||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${font}verticalAlign=top;align=left;spacingLeft=10;spacingTop=6;`
|
||||
}
|
||||
|
||||
if (spec.emphasis === "ghost")
|
||||
return `fillColor=none;strokeColor=none;fontColor=${ramp.dark};${font}align=left;`
|
||||
|
||||
if (spec.emphasis === "tinted") {
|
||||
// A callout keeps a heavy left bar, the editor's convention for "note well".
|
||||
const bar = role === "callout" ? `strokeWidth=2;` : ""
|
||||
return `fillColor=${ramp.tint};strokeColor=${ramp.base};fontColor=${ramp.dark};${bar}${font}`
|
||||
}
|
||||
|
||||
// outlined: the hue carried by the border and text. A grouped ordinary node takes its
|
||||
// zone's tint as the field — colour-as-grouping is the whole point of naming a zone —
|
||||
// while an ungrouped one stays white. A metric stays white either way, so its number
|
||||
// sits on the page's calmest field with the hue in a heavy border.
|
||||
const weight = role === "metric" ? "strokeWidth=2;" : ""
|
||||
const field = role === "body" && ramp !== NEUTRAL ? ramp.tint : "#FFFFFF"
|
||||
return `fillColor=${field};strokeColor=${ramp.base};fontColor=${ramp.dark};${weight}${font}`
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
/**
|
||||
* Tailwind utility classes → the engine's layout fields.
|
||||
*
|
||||
* WHY a second way to say the same thing. The engine's own vocabulary (`dir`, `grow`,
|
||||
* `align`, `justify`, `pad`, `gap`, `maxW`) is words we invented, so a model has seen them
|
||||
* only in our tool description. It has seen `flex-col grow-3 items-stretch p-4` millions of
|
||||
* times. Microsoft's DSL study (arXiv 2407.02742) found models hallucinate custom function
|
||||
* names at a much higher rate than familiar ones, and arXiv 2311.09519 measured a large
|
||||
* improvement from swapping a rare DSL for a popular language, precisely because it puts the
|
||||
* output back in the distribution the model was trained on.
|
||||
*
|
||||
* The other half of why Tailwind and not free-form CSS: its values are a FIXED SCALE, not
|
||||
* arbitrary numbers. `p-4` is 16px because one spacing unit is 4px, and there is no `p-7.5`.
|
||||
* Tailwind's own docs make that the point of the thing — with inline styles "every value is a
|
||||
* magic number", with utilities you pick from a system. That is the property we want, because
|
||||
* an unconstrained number field is exactly where a model invents 13px here and 27px there.
|
||||
*
|
||||
* The supported set was picked by reading Tailwind's property index against the draw.io
|
||||
* renderer's ACTUAL SOURCE — public/drawio/mxgraph/src and public/drawio/js/grapheditor,
|
||||
* vendored in this repo — rather than against a prose style reference. That matters: three
|
||||
* properties were excluded on wrong grounds when the reference was a document, and reading
|
||||
* the code put them back (radius, strikethrough, shadow, all noted below).
|
||||
*
|
||||
* WHAT IS DELIBERATELY NOT HERE, and why:
|
||||
*
|
||||
* - COLOUR of any kind (`bg-*`, `text-red-500`, `border-blue-400`). draw.io has
|
||||
* `fillColor`/`fontColor`/`strokeColor`, so this is possible — but colour is derived from
|
||||
* `role` and `group` precisely so one palette stays coherent, and a colour class would be
|
||||
* a back door into the hex-picking that was removed. Gradients (`bg-linear-to-b from-X
|
||||
* to-Y`) are excluded for the same reason, even though `gradientColor` with a four-way
|
||||
* `gradientDirection` maps onto them exactly (mxShape.js:1392-1393, 1054-1060).
|
||||
*
|
||||
* - Per-SIDE borders (`border-t`, `border-l-4`, `border-x`). draw.io draws these properly:
|
||||
* `shape=partialRectangle` reads independent `top`/`right`/`bottom`/`left` booleans
|
||||
* (Shapes.js:3914-3917) and still fills the background first (3919-3920), so a single
|
||||
* heavy left edge would render correctly. The cost is the SHAPE SLOT: `partialRectangle`
|
||||
* is itself a shape name, so a node could not be both a diamond and left-edge-only. What
|
||||
* a node IS — a database, a decision, a person — outranks how its border looks, so the
|
||||
* shape vocabulary keeps the slot.
|
||||
*
|
||||
* - Per-SIDE padding (`pt-8`, `px-4`). draw.io's `spacingTop`/`spacingRight`/
|
||||
* `spacingBottom`/`spacingLeft` (mxText.js:422-425) look like an exact match and are not:
|
||||
* they pad the LABEL inside its own cell, while this engine's `pad` is the room a
|
||||
* container leaves for its CHILDREN. Accepting `pt-8` would suggest it pushes child nodes
|
||||
* down, which it cannot.
|
||||
*
|
||||
* - `outline-*` (width, colour, style, offset). draw.io has no concept: a shape carries one
|
||||
* border, and nothing draws a second ring outside it. In CSS an outline is a focus ring,
|
||||
* which a static diagram does not have.
|
||||
*
|
||||
* - `opacity-*`. draw.io's `opacity` is 0–100 and would map cleanly, but Tailwind's
|
||||
* `opacity-<number>` takes ANY number — `opacity-37` is valid — so it is not a scale.
|
||||
* Admitting it would give up the one property that makes this vocabulary worth having.
|
||||
*
|
||||
* - `truncate` / `text-ellipsis`. Sets `text-overflow: ellipsis`. draw.io's `overflow`
|
||||
* branches on exactly five values — visible, hidden, fill, width, block (mxText.js:
|
||||
* 1080-1095) — and a repo-wide grep for "ellipsis" finds no implementation, so the text
|
||||
* would be cut with no "…": a class named `truncate` that silently loses characters.
|
||||
*
|
||||
* - Seven of the nine `font-*` weights. See UNSUPPORTED_WEIGHTS below.
|
||||
*
|
||||
* - `text-shadow-*`. Unlike the box `shadow-*` family, draw.io's `textShadow`
|
||||
* (mxText.js:668) is a bare on/off flag with no offset or blur, so Tailwind's six sizes
|
||||
* would collapse into one picture.
|
||||
*
|
||||
* - Per-CORNER radius (`rounded-tl-lg`) and the decorative corner treatments beside it
|
||||
* (snip, fold, inverse round). draw.io does have these, but only on a separate template
|
||||
* shape, `mxgraph.basic.rect` (Shapes.js:4118), which would take the place of the node's
|
||||
* own `shape` — the same trade the per-side borders lose. Whole-shape `rounded-*` IS
|
||||
* supported and costs no slot; see RADIUS.
|
||||
*
|
||||
* - `tracking-*` (letter-spacing), `uppercase`/`lowercase`/`capitalize` (text-transform),
|
||||
* and per-node `leading-*` (line-height). Not merely coarse — absent. Grepping the whole
|
||||
* vendored renderer for letterSpacing/textTransform finds nothing, and line height is a
|
||||
* global constant (`mxConstants.LINE_HEIGHT`) with no per-cell style key.
|
||||
*
|
||||
* - `rotate-*`, `scale-*`, `skew-*`, `translate-*`. draw.io has `rotation`/`flipH`/`flipV`,
|
||||
* but a rotated box breaks the two things this engine guarantees: the layout no longer
|
||||
* knows what area it covers, and the edge router cannot route around it.
|
||||
*
|
||||
* - Document-flow properties (`float`, `clear`, `position`, `top/right/bottom/left`,
|
||||
* `z-index`, `visibility`, `columns`, `break-*`, `object-*`, `overscroll-*`) and the
|
||||
* table and list families. There is no document flow here — every coordinate is computed
|
||||
* — and draw.io has no z-index at all: later cells simply paint on top.
|
||||
*
|
||||
* - `filter`/`backdrop-filter`, `mask-*`, `mix-blend-mode`, `transition-*`, `animation`,
|
||||
* `perspective*`, `cursor`, `resize`, `appearance`, `caret-color`, `accent-color`:
|
||||
* no corresponding key anywhere in the vendored renderer.
|
||||
*
|
||||
* - Arbitrary values (`w-[137px]`, `p-[13px]`). The scale is the feature; a bracket escape
|
||||
* hatch removes it.
|
||||
*
|
||||
* Unknown classes are returned in `ignored` rather than rejected — D2's "warnings over
|
||||
* errors" rule: a diagram that renders with one class dropped beats an error that renders
|
||||
* nothing. The caller reports them, which is how a typo becomes a one-turn fix instead of a
|
||||
* silent no-op.
|
||||
*/
|
||||
|
||||
import type { Align, Justify } from "./types"
|
||||
|
||||
/** What a class string resolves to. Every field optional: a class string sets only what it names. */
|
||||
export interface TwLayout {
|
||||
dir?: "row" | "col"
|
||||
grow?: number
|
||||
align?: Align
|
||||
justify?: Justify
|
||||
alignItems?: Align
|
||||
gap?: number
|
||||
pad?: number
|
||||
maxW?: number
|
||||
/** `min-w-0`: let a weight shrink this below its content width. */
|
||||
minW0?: boolean
|
||||
|
||||
// ---- text, the part draw.io can actually render ----
|
||||
/** `font-bold` / `font-normal`. draw.io has one bold bit, not nine weights. */
|
||||
bold?: boolean
|
||||
/** `italic` / `not-italic`. */
|
||||
italic?: boolean
|
||||
/** `underline` / `no-underline`. */
|
||||
underline?: boolean
|
||||
/** `line-through`. draw.io's fontStyle carries a strikethrough bit beside the other three. */
|
||||
strike?: boolean
|
||||
/** `text-xs`…`text-4xl` → px, from Tailwind's own scale. */
|
||||
fontSize?: number
|
||||
/** `text-left` / `text-center` / `text-right`. */
|
||||
textAlign?: "left" | "center" | "right"
|
||||
/** `align-top` / `align-middle` / `align-bottom`. */
|
||||
verticalAlign?: "top" | "middle" | "bottom"
|
||||
/** `whitespace-nowrap` / `whitespace-normal`. */
|
||||
nowrap?: boolean
|
||||
|
||||
// ---- border ----
|
||||
/** `border` / `border-N` → strokeWidth in px. */
|
||||
borderWidth?: number
|
||||
/** `border-dashed` / `border-dotted` / `border-solid`. */
|
||||
borderStyle?: "solid" | "dashed" | "dotted"
|
||||
/** `rounded`, `rounded-lg`, `rounded-full` → corner radius in px. */
|
||||
radius?: number
|
||||
/** `border-none` / `border-0`. */
|
||||
borderless?: boolean
|
||||
/** `shadow-sm`…`shadow-xl` → 1–4; `shadow-none` → 0. See SHADOW. */
|
||||
shadow?: number
|
||||
|
||||
/** Classes that matched nothing, verbatim and in order. */
|
||||
ignored: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's spacing scale: one unit is 0.25rem, which is 4px at the default root size.
|
||||
*
|
||||
* Only whole steps are accepted. Tailwind itself has half-steps (`p-0.5`), but a diagram has
|
||||
* no use for 2px padding and allowing them widens the scale for nothing.
|
||||
*/
|
||||
const SPACING_UNIT = 4
|
||||
|
||||
/** `p-6` / `gap-3` → px, or null when the suffix is not a plain scale step. */
|
||||
function scaleToPx(suffix: string): number | null {
|
||||
if (!/^\d+$/.test(suffix)) return null
|
||||
return Number(suffix) * SPACING_UNIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's width fractions, as a share of the parent.
|
||||
*
|
||||
* Expressed as `grow` rather than an absolute width, because that is what the fraction means
|
||||
* inside a flex row: `w-1/3` beside `w-2/3` is the same layout as `grow-1` beside `grow-2`,
|
||||
* and going through grow means the existing proportional path applies — including the rule
|
||||
* that a declared cap outranks it.
|
||||
*/
|
||||
function fractionToGrow(suffix: string): number | null {
|
||||
const m = /^(\d+)\/(\d+)$/.exec(suffix)
|
||||
if (!m) return null
|
||||
const num = Number(m[1])
|
||||
const den = Number(m[2])
|
||||
if (den === 0 || num === 0 || num > den) return null
|
||||
return num
|
||||
}
|
||||
|
||||
const ALIGN_ITEMS: Record<string, Align> = {
|
||||
"items-start": "start",
|
||||
"items-center": "center",
|
||||
"items-end": "end",
|
||||
"items-stretch": "stretch",
|
||||
}
|
||||
|
||||
const ALIGN_SELF: Record<string, Align> = {
|
||||
"self-start": "start",
|
||||
"self-center": "center",
|
||||
"self-end": "end",
|
||||
"self-stretch": "stretch",
|
||||
}
|
||||
|
||||
const JUSTIFY: Record<string, Justify> = {
|
||||
"justify-start": "start",
|
||||
"justify-center": "center",
|
||||
"justify-end": "end",
|
||||
"justify-between": "between",
|
||||
"justify-around": "around",
|
||||
"justify-evenly": "evenly",
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's type scale in px, its own documented values.
|
||||
*
|
||||
* Stops at 4xl. The ladder goes on to 9xl (128px), but a 128px word is not a diagram
|
||||
* label, and offering the step invites a model to pick it.
|
||||
*/
|
||||
const FONT_SIZE: Record<string, number> = {
|
||||
"text-xs": 12,
|
||||
"text-sm": 14,
|
||||
"text-base": 16,
|
||||
"text-lg": 18,
|
||||
"text-xl": 20,
|
||||
"text-2xl": 24,
|
||||
"text-3xl": 30,
|
||||
"text-4xl": 36,
|
||||
}
|
||||
|
||||
/**
|
||||
* `text-left|center|right` — horizontal text alignment inside the shape.
|
||||
*
|
||||
* `text-justify`, `text-start` and `text-end` are absent because draw.io's `align` has
|
||||
* only the three physical values; justified text is not available at all.
|
||||
*/
|
||||
const TEXT_ALIGN: Record<string, "left" | "center" | "right"> = {
|
||||
"text-left": "left",
|
||||
"text-center": "center",
|
||||
"text-right": "right",
|
||||
}
|
||||
|
||||
/** `align-*` → draw.io's verticalAlign. */
|
||||
const VERTICAL_ALIGN: Record<string, "top" | "middle" | "bottom"> = {
|
||||
"align-top": "top",
|
||||
"align-middle": "middle",
|
||||
"align-bottom": "bottom",
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's border-radius scale in px, its own documented values.
|
||||
*
|
||||
* These are REAL pixels, which is only true because of `absoluteArcSize`: draw.io's `arcSize`
|
||||
* is a percentage of the shape by default, but that flag switches it to absolute units
|
||||
* (mxShape.js:1172-1189). Without it a radius class would mean something different on every
|
||||
* box, which is why this looked unimplementable at first glance.
|
||||
*
|
||||
* `rounded-full` is `calc(infinity * 1px)` in Tailwind v4 — "as round as it goes". The same
|
||||
* function clamps the radius to half the shorter side, so any number past half the box's
|
||||
* height gives a stadium. 200 is chosen rather than something enormous because the number
|
||||
* reaches the user: draw.io's Arrange panel shows `arcSize` in an editable field, and a
|
||||
* diagram box taller than 400px does not exist, so 200 is both always enough and readable.
|
||||
*/
|
||||
const RADIUS: Record<string, number> = {
|
||||
"rounded-none": 0,
|
||||
"rounded-xs": 2,
|
||||
"rounded-sm": 4,
|
||||
rounded: 4,
|
||||
"rounded-md": 6,
|
||||
"rounded-lg": 8,
|
||||
"rounded-xl": 12,
|
||||
"rounded-2xl": 16,
|
||||
"rounded-3xl": 24,
|
||||
"rounded-4xl": 32,
|
||||
"rounded-full": 200,
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's box-shadow steps, as a rung number the renderer turns into draw.io's five
|
||||
* shadow parameters. 0 means "explicitly no shadow".
|
||||
*
|
||||
* draw.io's shadow is not the on/off flag it looks like: `shadowOffsetX`, `shadowOffsetY`,
|
||||
* `shadowBlur`, `shadowColor` and `shadowOpacity` are read independently
|
||||
* (mxShape.js:505-535) and become a CSS `drop-shadow(dx dy blur colour)` (540-552). Since
|
||||
* Tailwind's own steps are also just offset-and-blur, they map one for one.
|
||||
*
|
||||
* Four rungs, not Tailwind's eight. `shadow-2xs` and `shadow-xs` are indistinguishable from
|
||||
* `shadow-sm` at a diagram's scale, and `shadow-2xl`'s 50px blur is noise on a page of
|
||||
* boxes — offering a step invites a model to pick it.
|
||||
*/
|
||||
const SHADOW: Record<string, number> = {
|
||||
"shadow-none": 0,
|
||||
"shadow-sm": 1,
|
||||
"shadow-md": 2,
|
||||
"shadow-lg": 3,
|
||||
"shadow-xl": 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Font-weight classes that are NOT accepted, and why.
|
||||
*
|
||||
* Tailwind has nine weights; draw.io's `fontStyle` is a bitmask whose bold flag is a single
|
||||
* bit. Accepting all nine would collapse five of them onto "bold" and four onto "normal",
|
||||
* which is the same defect that rules out `shadow-*` (six sizes, one on/off flag). So only
|
||||
* `font-bold` and `font-normal` are honoured and the rest are reported, rather than
|
||||
* pretending a distinction the renderer cannot draw.
|
||||
*/
|
||||
const UNSUPPORTED_WEIGHTS = new Set([
|
||||
"font-thin",
|
||||
"font-extralight",
|
||||
"font-light",
|
||||
"font-medium",
|
||||
"font-semibold",
|
||||
"font-extrabold",
|
||||
"font-black",
|
||||
])
|
||||
|
||||
/**
|
||||
* Parse a Tailwind class string into layout fields.
|
||||
*
|
||||
* Later classes win over earlier ones, the same as Tailwind's own last-one-wins behaviour
|
||||
* for conflicting utilities, so a caller can append an override without removing anything.
|
||||
*/
|
||||
export function parseTw(classes: string): TwLayout {
|
||||
const out: TwLayout = { ignored: [] }
|
||||
for (const raw of String(classes ?? "").split(/\s+/)) {
|
||||
const cls = raw.trim()
|
||||
if (!cls) continue
|
||||
|
||||
// Direction. `flex` on its own is the default and says nothing here — every engine
|
||||
// container is already a flex container — so it is accepted and ignored rather than
|
||||
// reported, since a model writing `flex flex-col` is not making a mistake.
|
||||
if (cls === "flex" || cls === "flex-row") {
|
||||
if (cls === "flex-row") out.dir = "row"
|
||||
continue
|
||||
}
|
||||
if (cls === "flex-col") {
|
||||
out.dir = "col"
|
||||
continue
|
||||
}
|
||||
|
||||
if (cls in ALIGN_ITEMS) {
|
||||
out.alignItems = ALIGN_ITEMS[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in ALIGN_SELF) {
|
||||
out.align = ALIGN_SELF[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in JUSTIFY) {
|
||||
out.justify = JUSTIFY[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
// `grow` alone is flex-grow: 1, `grow-N` is the weight. Tailwind writes the latter
|
||||
// as `grow-[3]`; the plain form is accepted because it is what a model reaches for
|
||||
// and the bracket form carries no extra meaning here.
|
||||
if (cls === "grow") {
|
||||
out.grow = 1
|
||||
continue
|
||||
}
|
||||
const growN = /^grow-(\d+)$/.exec(cls)
|
||||
if (growN) {
|
||||
out.grow = Number(growN[1])
|
||||
continue
|
||||
}
|
||||
// `flex-1` / `flex-3`: the shorthand whose whole point is proportional sizing.
|
||||
const flexN = /^flex-(\d+)$/.exec(cls)
|
||||
if (flexN) {
|
||||
out.grow = Number(flexN[1])
|
||||
continue
|
||||
}
|
||||
|
||||
// Fractional widths become weights — see fractionToGrow.
|
||||
const wFrac = /^w-(\d+\/\d+)$/.exec(cls)
|
||||
if (wFrac) {
|
||||
const g = fractionToGrow(wFrac[1])
|
||||
if (g !== null) {
|
||||
out.grow = g
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (cls === "w-full") {
|
||||
out.align = "stretch"
|
||||
continue
|
||||
}
|
||||
|
||||
// `min-w-0` is the standard CSS escape hatch for "let the weight win over my
|
||||
// content width". Without it a weighted child is floored by its own text — that is
|
||||
// real flexbox behaviour, since `min-width` defaults to `auto` — so a narrow column
|
||||
// beside a wide one settles at its text width and a declared 2:1 comes out 1.4:1.
|
||||
if (cls === "min-w-0") {
|
||||
out.minW0 = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Spacing. `p-*` is interior padding, `gap-*` the space between children. Tailwind's
|
||||
// per-side variants (`pt-*`, `px-*`) are not here: the engine has one padding value,
|
||||
// and quietly treating `pt-8` as padding on all four sides would be wrong in a way
|
||||
// the model could not see.
|
||||
const pad = /^p-(\d+)$/.exec(cls)
|
||||
if (pad) {
|
||||
const px = scaleToPx(pad[1])
|
||||
if (px !== null) {
|
||||
out.pad = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
const gap = /^gap-(\d+)$/.exec(cls)
|
||||
if (gap) {
|
||||
const px = scaleToPx(gap[1])
|
||||
if (px !== null) {
|
||||
out.gap = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// `max-w-*` uses the spacing scale too, so `max-w-96` is 384px. Tailwind's named
|
||||
// sizes are also accepted, because a model reaches for `max-w-md` more readily than
|
||||
// for a step number.
|
||||
const maxW = /^max-w-(\d+)$/.exec(cls)
|
||||
if (maxW) {
|
||||
const px = scaleToPx(maxW[1])
|
||||
if (px !== null) {
|
||||
out.maxW = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
const named = NAMED_MAX_W[cls]
|
||||
if (named) {
|
||||
out.maxW = named
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- text ----
|
||||
// The three flags draw.io's fontStyle bitmask actually carries. They combine by
|
||||
// adding bits, so bold + italic is legal and needs no special case here.
|
||||
if (cls === "font-bold" || cls === "font-normal") {
|
||||
out.bold = cls === "font-bold"
|
||||
continue
|
||||
}
|
||||
// The other seven weights fall through to `ignored` on purpose, so the model is
|
||||
// told the distinction was dropped instead of quietly getting plain bold.
|
||||
if (UNSUPPORTED_WEIGHTS.has(cls)) {
|
||||
out.ignored.push(cls)
|
||||
continue
|
||||
}
|
||||
if (cls === "italic" || cls === "not-italic") {
|
||||
out.italic = cls === "italic"
|
||||
continue
|
||||
}
|
||||
if (cls === "underline" || cls === "no-underline") {
|
||||
out.underline = cls === "underline"
|
||||
continue
|
||||
}
|
||||
// Strikethrough is its own bit (8) beside bold/italic/underline, so it combines with
|
||||
// them rather than replacing one. `no-underline` above deliberately does NOT clear
|
||||
// it: in CSS both are values of `text-decoration-line`, and Tailwind's `no-underline`
|
||||
// means "not underlined", not "undecorated".
|
||||
if (cls === "line-through") {
|
||||
out.strike = true
|
||||
continue
|
||||
}
|
||||
// `text-*` is three different Tailwind properties sharing one prefix: size
|
||||
// (text-lg), alignment (text-left) and COLOUR (text-red-500). The size and
|
||||
// alignment tables are exact-match, so a colour class falls through to `ignored`
|
||||
// rather than being mistaken for a size.
|
||||
if (cls in FONT_SIZE) {
|
||||
out.fontSize = FONT_SIZE[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in TEXT_ALIGN) {
|
||||
out.textAlign = TEXT_ALIGN[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in VERTICAL_ALIGN) {
|
||||
out.verticalAlign = VERTICAL_ALIGN[cls]
|
||||
continue
|
||||
}
|
||||
if (cls === "whitespace-nowrap" || cls === "whitespace-normal") {
|
||||
out.nowrap = cls === "whitespace-nowrap"
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- border ----
|
||||
// `border` alone is 1px, `border-N` is N px — Tailwind's border width is a plain
|
||||
// pixel count, not the 4px spacing scale.
|
||||
if (cls === "border") {
|
||||
out.borderWidth = 1
|
||||
continue
|
||||
}
|
||||
// `border-0` and `border-none` both mean no border, so they are handled before the
|
||||
// numeric case (which would otherwise read border-0 as a zero-width border and
|
||||
// leave draw.io drawing its default hairline).
|
||||
if (cls === "border-none" || cls === "border-0") {
|
||||
out.borderless = true
|
||||
continue
|
||||
}
|
||||
const bw = /^border-(\d+)$/.exec(cls)
|
||||
if (bw) {
|
||||
out.borderWidth = Number(bw[1])
|
||||
continue
|
||||
}
|
||||
if (
|
||||
cls === "border-solid" ||
|
||||
cls === "border-dashed" ||
|
||||
cls === "border-dotted"
|
||||
) {
|
||||
out.borderStyle = cls.slice("border-".length) as
|
||||
| "solid"
|
||||
| "dashed"
|
||||
| "dotted"
|
||||
continue
|
||||
}
|
||||
// Whole-shape corner radius. Per-corner classes (`rounded-tl-lg`) fall through to
|
||||
// `ignored`: draw.io only offers those on a separate template shape.
|
||||
if (cls in RADIUS) {
|
||||
out.radius = RADIUS[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop shadow. Per-side border classes (`border-l-4`) fall through to `ignored`, and
|
||||
// so does every colour form (`shadow-blue-500`) since these tables are exact-match.
|
||||
if (cls in SHADOW) {
|
||||
out.shadow = SHADOW[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
out.ignored.push(cls)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's named max-width steps, in px.
|
||||
*
|
||||
* Tailwind's own values, rounded to whole pixels. Stopping at `4xl` is deliberate: anything
|
||||
* wider than about a thousand pixels is not a cap a diagram needs, and offering the whole
|
||||
* ladder invites a model to pick one at random.
|
||||
*/
|
||||
const NAMED_MAX_W: Record<string, number> = {
|
||||
"max-w-xs": 320,
|
||||
"max-w-sm": 384,
|
||||
"max-w-md": 448,
|
||||
"max-w-lg": 512,
|
||||
"max-w-xl": 576,
|
||||
"max-w-2xl": 672,
|
||||
"max-w-3xl": 768,
|
||||
"max-w-4xl": 896,
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
/**
|
||||
* The declarative node tree the layout engine works on.
|
||||
*
|
||||
* The model never writes coordinates. It declares nesting and direction; the engine
|
||||
* computes every x/y/width/height. The tree is not persisted anywhere — it is
|
||||
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
|
||||
* stays the single source of truth and a user's manual edits are an input, never
|
||||
* something to be reconciled against a second copy of the state.
|
||||
*/
|
||||
|
||||
import type { Direction } from "./markers"
|
||||
import type { Role } from "./theme"
|
||||
|
||||
export type { Direction } from "./markers"
|
||||
export type { Role } from "./theme"
|
||||
|
||||
/**
|
||||
* Which cell of a swimlane pool a node sits in.
|
||||
*
|
||||
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
|
||||
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
|
||||
*/
|
||||
export interface PoolCell {
|
||||
lane: number
|
||||
col: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
|
||||
* pin to either edge, centre (the default), or stretch to fill the axis.
|
||||
*/
|
||||
export type Align = "start" | "center" | "end" | "stretch"
|
||||
|
||||
/**
|
||||
* Presentation a node may override, beyond what its `role` decides.
|
||||
*
|
||||
* The admission test is that draw.io can draw the distinction FAITHFULLY — see tw.ts for the
|
||||
* properties that failed it and why. Most fields here are one style key with one value; a few
|
||||
* (`shadow`, `borderStyle`, the radius trio) expand to a fixed group of keys, which is fine
|
||||
* because the field still names one visual decision. What is not allowed is a field whose
|
||||
* values collapse onto fewer pictures than it promises.
|
||||
*
|
||||
* Kept as one optional object rather than a dozen loose fields so the round-trip has one
|
||||
* thing to carry and the node type does not grow a field per CSS property.
|
||||
*
|
||||
* `role` remains the primary way to say what a node IS; this is for the cases where the
|
||||
* model needs to override one aspect of how it looks.
|
||||
*/
|
||||
export interface TextStyle {
|
||||
/** Bold. draw.io's fontStyle carries one bold bit, not a weight ladder. */
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
/** Strikethrough — a fourth bit in the same mask, so it combines with the others. */
|
||||
strike?: boolean
|
||||
/** Type size in px. */
|
||||
size?: number
|
||||
/** Horizontal text alignment inside the shape. */
|
||||
align?: "left" | "center" | "right"
|
||||
/** Vertical text alignment inside the shape. */
|
||||
valign?: "top" | "middle" | "bottom"
|
||||
/** Keep the label on one line instead of wrapping it. */
|
||||
nowrap?: boolean
|
||||
/** Border thickness in px. */
|
||||
borderWidth?: number
|
||||
/** Border line style. Dashed and dotted read as "planned", "optional", "logical". */
|
||||
borderStyle?: "solid" | "dashed" | "dotted"
|
||||
/**
|
||||
* Corner radius in px.
|
||||
*
|
||||
* Real pixels, not a percentage: draw.io's `arcSize` is a percentage of the shape by
|
||||
* default, but `absoluteArcSize=1` switches it to absolute units, and it halves the
|
||||
* value, so an 8px radius is emitted as `arcSize=16` (mxShape.getArcSize,
|
||||
* mxShape.js:1172-1189).
|
||||
*
|
||||
* Overrides the radius of a shape that has one of its own: `round` and `terminator` are
|
||||
* rounded rectangles already, and changing how round they are does not change what they
|
||||
* are, so a radius class is allowed to win.
|
||||
*/
|
||||
radius?: number
|
||||
/** No border at all — a plain colour field. */
|
||||
borderless?: boolean
|
||||
/**
|
||||
* Drop shadow, as a rung: 1–4 for Tailwind's sm/md/lg/xl, 0 for explicitly none.
|
||||
*
|
||||
* A rung rather than raw offsets because draw.io takes five separate numbers
|
||||
* (`shadowOffsetX/Y`, `shadowBlur`, `shadowColor`, `shadowOpacity` — mxShape.js:505-535)
|
||||
* and letting a caller set them individually is exactly the magic-number freedom this
|
||||
* vocabulary exists to remove.
|
||||
*/
|
||||
shadow?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How a container spreads its children along its own stacking axis — CSS's
|
||||
* justify-content, and Yoga's six values.
|
||||
*
|
||||
* Until this existed the policy was hard-coded and differed per axis: a row padded its
|
||||
* gaps and centred the result, a column packed to the top and left every spare pixel in
|
||||
* one slab at the bottom. That slab is the empty bottom-left corner of a poster, and
|
||||
* nothing the model could declare would move it.
|
||||
*/
|
||||
export type Justify =
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "between"
|
||||
| "around"
|
||||
| "evenly"
|
||||
|
||||
/**
|
||||
* What a box IS, drawn as its conventional outline.
|
||||
*
|
||||
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
|
||||
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
|
||||
* token passes through verbatim and degrades to a rectangle if the editor does not
|
||||
* know it. See shapes.ts.
|
||||
*/
|
||||
export type BoxShape = string
|
||||
|
||||
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
|
||||
export interface IconNode {
|
||||
kind: "icon"
|
||||
id: string
|
||||
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
|
||||
name: string
|
||||
label: string
|
||||
/** Glyph size in px. Defaults to the diagram's icon size. */
|
||||
size?: number
|
||||
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
|
||||
style?: string
|
||||
/** User froze this node's position — the engine must not move it. */
|
||||
pinned?: boolean
|
||||
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
|
||||
rect?: Rect
|
||||
/** Position within a `pool` parent. Ignored elsewhere. */
|
||||
cell?: PoolCell
|
||||
}
|
||||
|
||||
/** A plain labelled rectangle, for things the catalog has no icon for. */
|
||||
export interface BoxNode {
|
||||
kind: "box"
|
||||
id: string
|
||||
label: string
|
||||
w?: number
|
||||
h?: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
bold?: boolean
|
||||
/** What this node IS in the information hierarchy; the theme decides how that looks. */
|
||||
role?: Role
|
||||
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
|
||||
group?: string
|
||||
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
|
||||
grow?: number
|
||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
||||
align?: Align
|
||||
/**
|
||||
* Hard cap on width, px. Text rewraps to fit instead of running the box wider, so
|
||||
* this is what stops one long sentence stretching a whole page into a letterbox.
|
||||
* Higher priority than `grow`, matching Yoga's min/max rule.
|
||||
*/
|
||||
maxW?: number
|
||||
/** Let a `grow` weight shrink this below its own text width — CSS's `min-width: 0`. */
|
||||
minW0?: boolean
|
||||
/** Presentation overrides: type, alignment, border. Absent means the role decides. */
|
||||
text?: TextStyle
|
||||
/** Flowchart outline. Absent means a plain rectangle. */
|
||||
shape?: BoxShape
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
/** Position within a `pool` parent. Ignored elsewhere. */
|
||||
cell?: PoolCell
|
||||
}
|
||||
|
||||
/** A page title. At most one per diagram; laid out outside the tree flow. */
|
||||
export interface TitleNode {
|
||||
kind: "title"
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A container that stacks its children in one direction.
|
||||
*
|
||||
* `gname` is the catalog group stencil (group_vpc, group_region, …). When null the
|
||||
* container renders as a plain frame — a labelled rectangle with a border.
|
||||
*/
|
||||
export interface GroupNode {
|
||||
kind: "group"
|
||||
id: string
|
||||
gname: string | null
|
||||
label: string
|
||||
dir: Extract<Direction, "row" | "col">
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
fill?: string
|
||||
stroke?: string
|
||||
/** Section role; a themed panel for its children. */
|
||||
role?: Role
|
||||
/** Semantic zone name; the panel takes this hue's tint. */
|
||||
group?: string
|
||||
/** Share of the parent's leftover flow-axis space, like flex-grow. */
|
||||
grow?: number
|
||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
||||
align?: Align
|
||||
/** How the children spread along `dir`. Absent = start (packed, no extra spacing). */
|
||||
justify?: Justify
|
||||
/** Cross-axis default for every child that does not declare its own `align`. */
|
||||
alignItems?: Align
|
||||
/** Hard cap on width, px. Children wrap or shrink to fit rather than overflow it. */
|
||||
maxW?: number
|
||||
/**
|
||||
* Let a `grow` weight shrink this below its own content width — CSS's `min-width: 0`.
|
||||
*
|
||||
* Without it a weighted child is floored by its text, which is real flexbox behaviour
|
||||
* (`min-width` defaults to `auto`) but means a declared 2:1 quietly resolves to
|
||||
* whatever the two columns' text allows.
|
||||
*/
|
||||
minW0?: boolean
|
||||
/** Presentation overrides: title type, alignment, frame border. */
|
||||
text?: TextStyle
|
||||
/** Interior padding, px. Absent = the default (24). */
|
||||
pad?: number
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/** A container that packs its children into a fixed number of columns. */
|
||||
export interface GridNode {
|
||||
kind: "grid"
|
||||
id: string
|
||||
gname: string | null
|
||||
label: string
|
||||
cols: number
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
fill?: string
|
||||
stroke?: string
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/**
|
||||
* A swimlane pool: a sparse grid of (lane, column) cells.
|
||||
*
|
||||
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
|
||||
* cells stay empty — that is the whole point of a swimlane diagram, where a step belongs
|
||||
* to exactly one role and the columns show the order things happen in.
|
||||
*
|
||||
* `phases` is an optional band of milestone labels above the columns.
|
||||
*/
|
||||
export interface PoolNode {
|
||||
kind: "pool"
|
||||
id: string
|
||||
label: string
|
||||
/** Role names, one per band. */
|
||||
lanes: string[]
|
||||
/** Milestone labels spanning the columns. Empty means no milestone band. */
|
||||
phases: string[]
|
||||
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
|
||||
orientation: "horizontal" | "vertical"
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/**
|
||||
* A sequence diagram: participants across the top, lifelines hanging below them.
|
||||
*
|
||||
* Children are the participant heads, in left-to-right order. The messages are ordinary
|
||||
* links whose `step` gives the vertical order — so the same `link` operation that draws
|
||||
* an arrow in a flowchart draws a message here.
|
||||
*
|
||||
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
|
||||
* ever attaches to a lifeline directly.
|
||||
*/
|
||||
export interface SequenceNode {
|
||||
kind: "sequence"
|
||||
id: string
|
||||
label: string
|
||||
/** Horizontal distance between participant centres. */
|
||||
gap: number
|
||||
/** Vertical distance between consecutive messages. */
|
||||
step: number
|
||||
children: DiagramNode[]
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
/**
|
||||
* A mind map or org chart: a root with branches radiating from it.
|
||||
*
|
||||
* Children are a FLAT list of every node in the map. The hierarchy comes from the links —
|
||||
* an arrow from A to B means B is a branch of A — not from nesting.
|
||||
*
|
||||
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
|
||||
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
|
||||
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
|
||||
* org chart the arrows ARE the structure.
|
||||
*
|
||||
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
|
||||
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
|
||||
* chart wants: a reporting line only reads correctly downwards.
|
||||
*/
|
||||
export interface RadialNode {
|
||||
kind: "radial"
|
||||
id: string
|
||||
label: string
|
||||
spread: "radial" | "down"
|
||||
/** Distance from a parent's edge to its children. */
|
||||
gap: number
|
||||
children: DiagramNode[]
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
}
|
||||
|
||||
export type ContainerNode =
|
||||
| GroupNode
|
||||
| GridNode
|
||||
| PoolNode
|
||||
| SequenceNode
|
||||
| RadialNode
|
||||
export type LeafNode = IconNode | BoxNode | TitleNode
|
||||
export type DiagramNode = ContainerNode | LeafNode
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
|
||||
export interface LinkSpec {
|
||||
/** Cell id, so an existing edge can be addressed by later operations. */
|
||||
id?: string
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
/** Dashed line — replication, sync, policy, lineage. */
|
||||
dashed?: boolean
|
||||
/**
|
||||
* A bold arrow: the relationship IS the point — a transformation, the main flow.
|
||||
* Thick and coloured, a visual element rather than a hairline connector.
|
||||
*/
|
||||
bold?: boolean
|
||||
/**
|
||||
* Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens:
|
||||
* block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany,
|
||||
* ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target,
|
||||
* nothing at the source). `headFill`/`tailFill` distinguish UML composition
|
||||
* (filled diamond) from aggregation (hollow) — conventions where fill IS meaning.
|
||||
*/
|
||||
head?: string
|
||||
tail?: string
|
||||
headFill?: boolean
|
||||
tailFill?: boolean
|
||||
/** Step number, rendered as an "N. " prefix on the label. */
|
||||
step?: number
|
||||
/** Verbatim style, when recovered from XML. */
|
||||
style?: string
|
||||
}
|
||||
|
||||
/** A whole diagram page: the node forest plus its arrows. */
|
||||
export interface DiagramTree {
|
||||
/** Top-level nodes, in layout order. */
|
||||
roots: DiagramNode[]
|
||||
links: LinkSpec[]
|
||||
/** Page title, if the diagram has one. */
|
||||
title?: string
|
||||
/**
|
||||
* Target width : height of the whole page. 1 is square, 1.6 landscape, 0.7 portrait.
|
||||
*
|
||||
* This is the one number that decides whether a diagram reads as a poster or as a
|
||||
* letterbox, and it cannot be derived: the same content is a legitimate 1-column
|
||||
* portrait or 3-column landscape. So the model declares it, the engine gives the top
|
||||
* level a width to match, and every proportional rule below finally has a share of
|
||||
* something real to divide up.
|
||||
*/
|
||||
aspect?: number
|
||||
/**
|
||||
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
|
||||
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
|
||||
* a re-layout never destroys work the engine does not understand.
|
||||
*/
|
||||
foreign: ForeignCell[]
|
||||
}
|
||||
|
||||
/** A cell carried through the round-trip without interpretation. */
|
||||
export interface ForeignCell {
|
||||
id: string
|
||||
/** The cell's own serialised XML, verbatim. */
|
||||
xml: string
|
||||
/** Parent id at parse time, so it can be re-attached. */
|
||||
parent: string
|
||||
}
|
||||
|
||||
export function isContainer(n: DiagramNode): n is ContainerNode {
|
||||
return (
|
||||
n.kind === "group" ||
|
||||
n.kind === "grid" ||
|
||||
n.kind === "pool" ||
|
||||
n.kind === "sequence" ||
|
||||
n.kind === "radial"
|
||||
)
|
||||
}
|
||||
|
||||
export function isLeaf(n: DiagramNode): n is LeafNode {
|
||||
return !isContainer(n)
|
||||
}
|
||||
|
||||
/** Depth-first walk over a node and its descendants. */
|
||||
export function* walk(n: DiagramNode): Generator<DiagramNode> {
|
||||
yield n
|
||||
if (isContainer(n)) for (const c of n.children) yield* walk(c)
|
||||
}
|
||||
|
||||
/** Every node in a tree, in document order. */
|
||||
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
|
||||
for (const r of t.roots) yield* walk(r)
|
||||
}
|
||||
|
||||
/** Find a node by id, or null. */
|
||||
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
|
||||
for (const n of walkTree(t)) if (n.id === id) return n
|
||||
return null
|
||||
}
|
||||
|
||||
/** The container holding `id`, or null when it is a root or absent. */
|
||||
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
|
||||
for (const n of walkTree(t)) {
|
||||
if (!isContainer(n)) continue
|
||||
if (n.children.some((c) => c.id === id)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* Obstacle-avoiding orthogonal routing: the orthogonal visibility graph, and A* over it.
|
||||
*
|
||||
* The router beside this file works by trying a list of candidate shapes — straight, an L, a
|
||||
* Z with its trunk in some lane — and keeping the first that is clear. That can only ever be
|
||||
* as good as the list, and a fixed list is not enough: measured over 250 generated
|
||||
* flowcharts, 347 arrows were drawn through a box that had nothing to do with them. Adding
|
||||
* shapes to the list moves the failures around rather than removing them.
|
||||
*
|
||||
* This is the complete alternative, from Wybrow, Marriott & Stuckey, "Orthogonal Connector
|
||||
* Routing" (Graph Drawing 2009) — the algorithm behind libavoid. Two ideas make it work:
|
||||
*
|
||||
* 1. THE GRID IS FINITE AND SUFFICIENT. Take the "interesting points": every obstacle
|
||||
* corner and every connection point. Their x-coordinates and y-coordinates define a
|
||||
* grid. The paper's observation, with proof: for any valid orthogonal route there is a
|
||||
* route using only this grid that is no longer and has no more bends — shrink each
|
||||
* segment onto the nearest grid line. So searching the grid loses nothing, and there is
|
||||
* no resolution to tune. This is what a uniform pixel grid gets wrong in both
|
||||
* directions at once: too coarse and it cannot fit through a narrow gap, too fine and
|
||||
* the search explodes.
|
||||
*
|
||||
* 2. THE STATE INCLUDES THE DIRECTION OF ARRIVAL. Bends have to be paid for, and whether
|
||||
* the next step is a bend depends on which way this one came in. So a search state is
|
||||
* (point, incoming direction), not just (point). Without that the cost function cannot
|
||||
* see bends at all.
|
||||
*
|
||||
* The heuristic is the one libavoid uses: Manhattan distance to the target plus the minimum
|
||||
* number of bends still needed, times the bend cost. It never overestimates — the remaining
|
||||
* path is at least the straight-line Manhattan distance, and it must contain at least that
|
||||
* many bends — so A* returns a cheapest route, not merely a route.
|
||||
*
|
||||
* Written from the paper's description rather than ported: the reference implementation is a
|
||||
* C++ library built for interactive re-routing, with incremental scanline updates and pin
|
||||
* management that a one-shot XML generator has no use for.
|
||||
*/
|
||||
|
||||
import type { Rect } from "./types"
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** Which way a path segment travels. Indices are used as array offsets. */
|
||||
const DIRS = [
|
||||
{ dx: 0, dy: -1 }, // 0 north
|
||||
{ dx: 1, dy: 0 }, // 1 east
|
||||
{ dx: 0, dy: 1 }, // 2 south
|
||||
{ dx: -1, dy: 0 }, // 3 west
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Cost of one bend, in pixels of path length.
|
||||
*
|
||||
* libavoid's default is 10. It has to be positive or the search has no reason to prefer a
|
||||
* straight line to a staircase of the same length, and the two look nothing alike.
|
||||
*/
|
||||
const BEND_COST = 10
|
||||
|
||||
/** Clearance kept around an obstacle, matching the router's own margin. */
|
||||
const MARGIN = 7
|
||||
|
||||
/**
|
||||
* Does the segment from `p` to `q` pass through any obstacle?
|
||||
*
|
||||
* Obstacles are expanded by `MARGIN` first, so a route grazing a border counts as a hit —
|
||||
* an arrow drawn hard against a box reads as touching it.
|
||||
*/
|
||||
function blocked(p: Point, q: Point, obstacles: Rect[]): boolean {
|
||||
const lo = { x: Math.min(p.x, q.x), y: Math.min(p.y, q.y) }
|
||||
const hi = { x: Math.max(p.x, q.x), y: Math.max(p.y, q.y) }
|
||||
for (const r of obstacles) {
|
||||
if (
|
||||
lo.x < r.x + r.w + MARGIN &&
|
||||
hi.x > r.x - MARGIN &&
|
||||
lo.y < r.y + r.h + MARGIN &&
|
||||
hi.y > r.y - MARGIN
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum number of bends to get from `p`, travelling in direction `d`, to `t`.
|
||||
*
|
||||
* This is the table in the paper's Figure 2(a), as an arithmetic rule rather than sixteen
|
||||
* cases. Two independent questions: is the target ahead along the current axis, and is it
|
||||
* off to the side? Each answer costs bends, and they compose.
|
||||
*/
|
||||
function bendsToTarget(p: Point, d: number, t: Point): number {
|
||||
const { dx, dy } = DIRS[d]
|
||||
// How far the target lies along the direction of travel, and across it.
|
||||
const along = dx !== 0 ? (t.x - p.x) * dx : (t.y - p.y) * dy
|
||||
const across = dx !== 0 ? t.y - p.y : t.x - p.x
|
||||
if (across === 0) {
|
||||
// Dead ahead: no bend. Directly behind: out and back, two bends.
|
||||
return along >= 0 ? 0 : 2
|
||||
}
|
||||
// Off to the side: one bend if it is also ahead, two if it is behind.
|
||||
return along > 0 ? 1 : 2
|
||||
}
|
||||
|
||||
/**
|
||||
* A cheapest obstacle-free orthogonal path from `from` to `to`, or null if none exists.
|
||||
*
|
||||
* `startDir` and `endDir` are the directions the path must leave and arrive by — the side of
|
||||
* the shape each end attaches to. Constraining them is what stops an arrow leaving a box and
|
||||
* immediately turning back across it: a departure direction the search must honour on its
|
||||
* first step cannot double back.
|
||||
*
|
||||
* `extraLanes` lets the caller add grid lines the obstacles alone would not produce, which
|
||||
* matters when a port sits somewhere other than an obstacle corner.
|
||||
*/
|
||||
export function routeOrthogonal(
|
||||
from: Point,
|
||||
to: Point,
|
||||
startDir: number,
|
||||
endDir: number,
|
||||
obstacles: Rect[],
|
||||
extraLanes: { xs: number[]; ys: number[] } = { xs: [], ys: [] },
|
||||
): Point[] | null {
|
||||
// --- the interesting-points grid
|
||||
const xs = new Set<number>([from.x, to.x, ...extraLanes.xs])
|
||||
const ys = new Set<number>([from.y, to.y, ...extraLanes.ys])
|
||||
for (const r of obstacles) {
|
||||
// Just outside each edge, so a lane hugging an obstacle is still usable.
|
||||
xs.add(r.x - MARGIN - 1)
|
||||
xs.add(r.x + r.w + MARGIN + 1)
|
||||
ys.add(r.y - MARGIN - 1)
|
||||
ys.add(r.y + r.h + MARGIN + 1)
|
||||
}
|
||||
const X = [...xs].sort((a, b) => a - b)
|
||||
const Y = [...ys].sort((a, b) => a - b)
|
||||
const xi = new Map(X.map((v, i) => [v, i]))
|
||||
const yi = new Map(Y.map((v, i) => [v, i]))
|
||||
|
||||
const sx = xi.get(from.x)
|
||||
const sy = yi.get(from.y)
|
||||
const tx = xi.get(to.x)
|
||||
const ty = yi.get(to.y)
|
||||
if (sx == null || sy == null || tx == null || ty == null) return null
|
||||
|
||||
// --- A* over (grid point, incoming direction)
|
||||
const key = (ix: number, iy: number, d: number) =>
|
||||
(iy * X.length + ix) * 4 + d
|
||||
const best = new Map<number, number>()
|
||||
const parent = new Map<number, number>()
|
||||
// A binary heap would be tidier, but the frontier stays small on diagram-sized inputs and
|
||||
// a sorted insert keeps this readable.
|
||||
const open: { ix: number; iy: number; d: number; g: number; f: number }[] =
|
||||
[]
|
||||
const push = (ix: number, iy: number, d: number, g: number, f: number) => {
|
||||
let lo = 0
|
||||
let hi = open.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (open[mid].f > f) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
open.splice(lo, 0, { ix, iy, d, g, f })
|
||||
}
|
||||
|
||||
const h = (ix: number, iy: number, d: number) =>
|
||||
Math.abs(X[ix] - to.x) +
|
||||
Math.abs(Y[iy] - to.y) +
|
||||
bendsToTarget({ x: X[ix], y: Y[iy] }, d, to) * BEND_COST
|
||||
|
||||
const startKey = key(sx, sy, startDir)
|
||||
best.set(startKey, 0)
|
||||
push(sx, sy, startDir, 0, h(sx, sy, startDir))
|
||||
|
||||
// The path must ARRIVE travelling in `endDir`, so that is the only accepting state.
|
||||
const goalKey = key(tx, ty, endDir)
|
||||
let found = false
|
||||
|
||||
while (open.length > 0) {
|
||||
const cur = open.pop() as {
|
||||
ix: number
|
||||
iy: number
|
||||
d: number
|
||||
g: number
|
||||
f: number
|
||||
}
|
||||
const ck = key(cur.ix, cur.iy, cur.d)
|
||||
if (cur.g > (best.get(ck) ?? Number.POSITIVE_INFINITY)) continue
|
||||
if (ck === goalKey) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
const here = { x: X[cur.ix], y: Y[cur.iy] }
|
||||
for (let nd = 0; nd < 4; nd++) {
|
||||
// No reversing: it can never help, and it lets a path retrace itself.
|
||||
if (nd === (cur.d + 2) % 4) continue
|
||||
const { dx, dy } = DIRS[nd]
|
||||
// Step to the NEXT grid line in this direction — the grid's whole point is that
|
||||
// intermediate positions cannot change whether a route is clear.
|
||||
const nix = cur.ix + dx
|
||||
const niy = cur.iy + dy
|
||||
if (nix < 0 || nix >= X.length || niy < 0 || niy >= Y.length)
|
||||
continue
|
||||
const next = { x: X[nix], y: Y[niy] }
|
||||
if (blocked(here, next, obstacles)) continue
|
||||
|
||||
const step = Math.abs(next.x - here.x) + Math.abs(next.y - here.y)
|
||||
const g = cur.g + step + (nd === cur.d ? 0 : BEND_COST)
|
||||
const nk = key(nix, niy, nd)
|
||||
if (g >= (best.get(nk) ?? Number.POSITIVE_INFINITY)) continue
|
||||
best.set(nk, g)
|
||||
parent.set(nk, ck)
|
||||
push(nix, niy, nd, g, g + h(nix, niy, nd))
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return null
|
||||
|
||||
// --- rebuild, then drop the points that are not bends
|
||||
const path: Point[] = []
|
||||
let node: number | undefined = goalKey
|
||||
while (node !== undefined) {
|
||||
const d = node % 4
|
||||
const rest = (node - d) / 4
|
||||
path.unshift({
|
||||
x: X[rest % X.length],
|
||||
y: Y[(rest - (rest % X.length)) / X.length],
|
||||
})
|
||||
node = parent.get(node)
|
||||
}
|
||||
return simplify(path)
|
||||
}
|
||||
|
||||
/** Drop collinear and duplicate points: draw.io renders a redundant waypoint as a kink. */
|
||||
function simplify(pts: Point[]): Point[] {
|
||||
const out: Point[] = []
|
||||
for (const p of pts) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && Math.abs(last.x - p.x) < 1 && Math.abs(last.y - p.y) < 1)
|
||||
continue
|
||||
out.push(p)
|
||||
}
|
||||
const kept: Point[] = []
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (i === 0 || i === out.length - 1) {
|
||||
kept.push(out[i])
|
||||
continue
|
||||
}
|
||||
const prev = kept[kept.length - 1]
|
||||
const next = out[i + 1]
|
||||
const collinear =
|
||||
(Math.abs(prev.x - out[i].x) < 1 &&
|
||||
Math.abs(out[i].x - next.x) < 1) ||
|
||||
(Math.abs(prev.y - out[i].y) < 1 && Math.abs(out[i].y - next.y) < 1)
|
||||
if (!collinear) kept.push(out[i])
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/** The direction leaving a given side of a shape: away from it. */
|
||||
export const SIDE_DIR = { T: 0, R: 1, B: 2, L: 3 } as const
|
||||
@@ -132,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",
|
||||
@@ -186,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",
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
"customSystemMessage": "カスタムシステムメッセージ",
|
||||
"customSystemMessageDescription": "AIのシステムプロンプトに追加されるカスタム指示を入力します。",
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||
"maxOutputTokens": "最大出力トークン数",
|
||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||
"panelVisibility": "ロビーパネル",
|
||||
"panelVisibilityDescription": "チャットロビーに表示するパネルを選択します。",
|
||||
"showRecentChats": "最近のチャット",
|
||||
@@ -186,7 +188,8 @@
|
||||
"failedToExport": "チャートデータの取得エラー",
|
||||
"failedToLoadExample": "例の画像の読み込みエラー",
|
||||
"failedToRecordFeedback": "フィードバックの記録に失敗しました。もう一度お試しください。",
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした"
|
||||
"storageUpdateFailed": "チャットはクリアされましたが、ブラウザストレージを更新できませんでした",
|
||||
"sessionSaveFailed": "このチャットを保存できませんでした。ブラウザのストレージがいっぱいの可能性があります。履歴から古いチャットを削除して、もう一度お試しください。"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "1日の割当量に達しました",
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
"customSystemMessage": "自訂系統訊息",
|
||||
"customSystemMessageDescription": "新增自訂指示,將附加到 AI 的系統提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||
"maxOutputTokens": "最大輸出 token 數",
|
||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||
"panelVisibility": "大廳面板",
|
||||
"panelVisibilityDescription": "選擇在聊天大廳顯示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
@@ -186,7 +188,8 @@
|
||||
"failedToExport": "取得圖表資料時出錯",
|
||||
"failedToLoadExample": "載入範例圖片時出錯",
|
||||
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
|
||||
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間",
|
||||
"sessionSaveFailed": "無法儲存這個對話。瀏覽器儲存空間可能已滿,請在歷史紀錄裡刪除舊對話後重試。"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已達每日配額",
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
"customSystemMessage": "自定义系统消息",
|
||||
"customSystemMessageDescription": "添加自定义指令,将附加到 AI 的系统提示末尾。",
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||
"maxOutputTokens": "最大输出 token 数",
|
||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||
"panelVisibility": "大厅面板",
|
||||
"panelVisibilityDescription": "选择在聊天大厅显示哪些面板。",
|
||||
"showRecentChats": "最近聊天",
|
||||
@@ -186,7 +188,8 @@
|
||||
"failedToExport": "获取图表数据时出错",
|
||||
"failedToLoadExample": "加载示例图片时出错",
|
||||
"failedToRecordFeedback": "记录您的反馈失败。请重试。",
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储"
|
||||
"storageUpdateFailed": "聊天已清除,但无法更新浏览器存储",
|
||||
"sessionSaveFailed": "无法保存这个对话。浏览器存储空间可能已满,请在历史记录里删除旧对话后重试。"
|
||||
},
|
||||
"quota": {
|
||||
"dailyLimit": "已达每日配额",
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+275
-109
@@ -6,17 +6,14 @@
|
||||
* WebAssembly issues with Next.js server-side rendering.
|
||||
*/
|
||||
|
||||
// Default system prompt - works with all models. Keep it to the things that are true no
|
||||
// matter which tool gets picked: how to choose, and the shape vocabulary the tools share.
|
||||
// Anything specific to one tool belongs in THAT tool's description (app/api/chat/route.ts),
|
||||
// where it only costs context when the model actually reaches for it.
|
||||
// Default system prompt (~1900 tokens) - works with all models
|
||||
export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams. You declare the structure and a layout engine computes the geometry — you never write draw.io XML for a new diagram.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications.
|
||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||
ALWAYS respond in the same language as the user's last message.
|
||||
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure (2-3 sentences max), then build it with restructure_diagram, which computes the layout for you; edit_diagram patches a diagram already on the canvas.
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML.
|
||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||
|
||||
## App Context
|
||||
@@ -33,112 +30,196 @@ You can read and modify diagrams by generating draw.io XML code through tool cal
|
||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||
|
||||
## Choosing the right tool
|
||||
You utilize the following tools:
|
||||
---Tool1---
|
||||
tool name: display_diagram
|
||||
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
|
||||
parameters: {
|
||||
xml: string
|
||||
}
|
||||
---Tool2---
|
||||
tool name: edit_diagram
|
||||
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||
parameters: {
|
||||
operations: Array<{operation: "update" | "add" | "delete", cell_id: string, new_xml?: string}>
|
||||
}
|
||||
---Tool3---
|
||||
tool name: append_diagram
|
||||
description: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.
|
||||
parameters: {
|
||||
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
|
||||
}
|
||||
---Tool4---
|
||||
tool name: get_shape_library
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (AWS, Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||
parameters: {
|
||||
library: string // Library name: aws4, azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||
}
|
||||
---End of tools---
|
||||
|
||||
Every new diagram is built with restructure_diagram: it computes every coordinate, size and
|
||||
arrow route, so nothing overlaps and no arrow cuts through a box. You never write draw.io XML
|
||||
yourself for a new diagram — edit_diagram is for patching what is already on the canvas.
|
||||
IMPORTANT: Choose the right tool:
|
||||
- Use display_diagram for: Creating new diagrams, major restructuring, or when the current diagram XML is empty
|
||||
- Use edit_diagram for: Small modifications, adding/removing elements, changing text/colors, repositioning items
|
||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||
- Use get_shape_library for: Discovering available icons/shapes when creating diagrams with any icon library (cloud, material design, etc.) — call BEFORE display_diagram
|
||||
|
||||
Within restructure_diagram, pick the OPERATION by the diagram's layout shape, not by which
|
||||
icon set it uses:
|
||||
Core capabilities:
|
||||
- Generate valid, well-formed XML strings for draw.io diagrams
|
||||
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
|
||||
- Convert user descriptions into visually appealing diagrams using basic shapes and connectors
|
||||
- Apply proper spacing, alignment and visual hierarchy in diagram layouts
|
||||
- Adapt artistic concepts into abstract diagram representations using available shapes
|
||||
- Optimize element positioning to prevent overlapping and maintain readability
|
||||
- Structure complex systems into clear, organized visual components
|
||||
|
||||
Use add_graph when the arrows define the order:
|
||||
flowcharts, decision trees, process diagrams, approval flows, CI/CD pipelines, state machines,
|
||||
git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams,
|
||||
and any "illustrate how X works" where X is a sequence of steps or states.
|
||||
You supply only nodes and edges — no positions, no nesting. Do NOT try to lay these out
|
||||
yourself out of containers and boxes: a flowchart declared as nesting comes out as one
|
||||
column, which forces every branch to jump over the step beside it.
|
||||
Omit parent for a whole-page flowchart (send clear first when replacing one); set parent
|
||||
to put a flow inside one zone of a bigger diagram — an architecture zone whose contents
|
||||
follow the data flow, a poster column with a small flowchart in it.
|
||||
|
||||
Use the nesting operations when the diagram's meaning is in NESTING or in a fixed frame:
|
||||
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils
|
||||
first; the tool's description carries the per-zone recipe.
|
||||
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
|
||||
- Sequence diagrams: add_sequence, one add_box per participant, then link with a step number.
|
||||
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
|
||||
This applies to BOTH creating and editing.
|
||||
|
||||
The same nesting operations cover poster-style layouts — paper summaries, cheat sheets,
|
||||
infographics, comparison sheets. The tool's own description carries the recipe; what matters
|
||||
when choosing is that a poster is a nest of row/col containers, not an arrow-ordered graph.
|
||||
|
||||
Use edit_diagram for a small, targeted change to whatever is already on the canvas — a label,
|
||||
a colour, one shape added or removed. It patches the XML in place, so it also works on a
|
||||
diagram the user drew by hand. For anything structural, go back to restructure_diagram.
|
||||
|
||||
Working with restructure_diagram:
|
||||
- Say the page shape FIRST, with set_page: aspect is width:height (1 square, 1.4 landscape
|
||||
slide, 0.75 portrait poster, 1.6 wide architecture). Nothing proportional works before it —
|
||||
column weights need a total width to take a share of, and without one they do nothing.
|
||||
- Layout, type, borders and surface are Tailwind classes on any container or box:
|
||||
layout grow-3 / w-2/3 for a column's share (add min-w-0 to every column when the ratio
|
||||
has to be exact — otherwise a column will not shrink below its own text, exactly
|
||||
as in a browser), items-stretch so cards line up, justify-between to spread a
|
||||
short column instead of leaving a hole, gap-4 and p-6 for spacing (Tailwind's
|
||||
4px scale), max-w-md to cap a width so long text wraps instead of stretching
|
||||
the page.
|
||||
type font-bold, italic, underline, line-through, text-xs..text-4xl,
|
||||
text-left/center/right, align-top/middle/bottom, whitespace-nowrap.
|
||||
border border-2 for thickness, border-dashed or border-dotted — a dashed frame reads
|
||||
as planned or logical rather than deployed. border-none for a plain colour
|
||||
field with no outline.
|
||||
surface rounded-lg / rounded-xl / rounded-full for corners (real pixels, so the same
|
||||
class is the same corner everywhere), shadow-md / shadow-lg to lift a card off
|
||||
the panel behind it. One elevation level per group of cards, not on everything.
|
||||
NOT accepted, and reported back to you when you use them: every colour class and gradients
|
||||
(colour comes from role and group), the seven font weights between thin and black,
|
||||
opacity-*, truncate, per-side borders (border-l) and per-side padding (pt-4), per-corner
|
||||
radius, tracking-*, uppercase, leading-*, outline-*, and transforms.
|
||||
- Look every AWS icon name up with search_stencils first. Batch the lookups.
|
||||
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
|
||||
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
|
||||
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
|
||||
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
|
||||
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
|
||||
|
||||
Box shapes, for both add_graph's nodes and add_box — a shape says what a node IS:
|
||||
- Flowchart: "decision" (a diamond) for a branch, "terminator" for a start or end point, "data"
|
||||
for input or output, "document" for a report, "round" for a soft-edged step.
|
||||
- Semantic: "cylinder" for a database, "queue" for a message queue, "person" for an actor or
|
||||
user, "cloud" for an external system, "hexagon" for a service, "ellipse" for a concept,
|
||||
"callout" for a note, "step" for a pipeline stage, "note", "card", "process", "tape", "cube".
|
||||
- Any other draw.io shape token also works verbatim (unknown ones render as rectangles).
|
||||
Use shapes: a database drawn as a cylinder needs no "database" caption; a reader takes a
|
||||
diamond to mean a choice. Drawing everything as the same rectangle throws that away.
|
||||
Layout constraints:
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||
- Maximum height for containers: 550 pixels
|
||||
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
|
||||
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
|
||||
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
|
||||
|
||||
Note that:
|
||||
- Use proper tool calls to generate or edit diagrams; never return raw XML in text responses.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended
|
||||
information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes
|
||||
and connectors while maintaining visual clarity.
|
||||
- If user asks you to replicate a diagram based on an image, match the diagram style and layout
|
||||
as closely as possible. Pay attention to the lines and shapes — whether lines are straight or
|
||||
curved, whether shapes are rounded or square.
|
||||
- NEVER include XML comments (<!-- ... -->) in an edit_diagram replacement. Draw.io strips
|
||||
comments, which breaks the search patterns.
|
||||
- Use proper tool calls to generate or edit diagrams;
|
||||
- never return raw XML in text responses,
|
||||
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||
|
||||
When using edit_diagram tool:
|
||||
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
|
||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||
- For delete: only cell_id is needed
|
||||
- Find the cell_id from "Current diagram XML" in system context
|
||||
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
|
||||
|
||||
Example - generate ONLY this:
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
|
||||
4. Use unique sequential IDs starting from "2"
|
||||
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||
|
||||
Shape (vertex) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
Connector (edge) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
### Edge Routing Rules:
|
||||
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
||||
|
||||
**Rule 1: NEVER let multiple edges share the same path**
|
||||
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
|
||||
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
|
||||
|
||||
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
|
||||
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
|
||||
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
|
||||
|
||||
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
||||
- Every edge MUST have these 4 attributes set in the style
|
||||
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
|
||||
|
||||
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
||||
- Before creating an edge, identify ALL shapes positioned between source and target
|
||||
- If any shape is in the direct path, you MUST use waypoints to route around it
|
||||
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
|
||||
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
|
||||
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
|
||||
- NEVER draw a line that visually crosses over another shape's bounding box
|
||||
|
||||
**Rule 5: Plan layout strategically BEFORE generating XML**
|
||||
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
|
||||
- Space shapes 150-200px apart to create clear routing channels for edges
|
||||
- Mentally trace each edge: "What shapes are between source and target?"
|
||||
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
|
||||
|
||||
**Rule 6: Use multiple waypoints for complex routing**
|
||||
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
|
||||
- Each direction change needs a waypoint (corner point)
|
||||
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
|
||||
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
|
||||
|
||||
**Rule 7: Choose NATURAL connection points based on flow direction**
|
||||
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
|
||||
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
|
||||
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
|
||||
- For DIAGONAL connections: use the side closest to the target, not corners
|
||||
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
|
||||
|
||||
**Before generating XML, mentally verify:**
|
||||
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
|
||||
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
|
||||
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
||||
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
||||
|
||||
|
||||
\`\`\`
|
||||
|
||||
`
|
||||
|
||||
// Style instructions - only included when minimalStyle is false
|
||||
const STYLE_INSTRUCTIONS = `
|
||||
Colour and emphasis come from the engine, not from you: set role for hierarchy
|
||||
(banner/heading/callout/good/bad/metric/muted) and group for which colour family a set of nodes
|
||||
shares. Never pass a hex colour or a style string, and never a colour utility class
|
||||
(bg-blue-500, text-red-600) — those are dropped. Classes cover layout, type and surface
|
||||
(corners, borders, shadow); COLOUR is the one thing they never carry.
|
||||
Common styles:
|
||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||
`
|
||||
|
||||
// Minimal style instruction - plain output, no theme (prepended to prompt for emphasis)
|
||||
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
|
||||
const MINIMAL_STYLE_INSTRUCTION = `
|
||||
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
|
||||
|
||||
The user asked for plain, unstyled output. Do NOT set role or group on any node, and do not use
|
||||
inline HTML (<b>, <font color>) in labels. Structure alone carries the meaning: nesting, shapes
|
||||
and arrow direction. The engine will render everything in one neutral style.
|
||||
### No Styling - Plain Black/White Only
|
||||
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
|
||||
- NO color attributes (no hex colors like #ff69b4)
|
||||
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
|
||||
- IGNORE all color/style examples below
|
||||
|
||||
### Container/Group Shapes - MUST be Transparent
|
||||
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
|
||||
- This prevents containers from covering child elements
|
||||
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
|
||||
|
||||
### Focus on Layout Quality
|
||||
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
|
||||
- SPACING: Minimum 50px gap between all elements
|
||||
- NO OVERLAPS: Elements and edges must never overlap
|
||||
- Follow ALL 7 Edge Routing Rules for arrow positioning
|
||||
- Use waypoints to route edges AROUND obstacles
|
||||
- Use different exitY/entryY values for multiple edges between same nodes
|
||||
|
||||
`
|
||||
|
||||
@@ -148,13 +229,55 @@ const EXTENDED_ADDITIONS = `
|
||||
|
||||
## Extended Tool Reference
|
||||
|
||||
### display_diagram Details
|
||||
|
||||
**VALIDATION RULES** (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
|
||||
2. All mxCell elements must be siblings - never nested inside other mxCell elements
|
||||
3. Every mxCell needs a unique id attribute (start from "2")
|
||||
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
|
||||
5. Edge source/target attributes must reference existing cell IDs
|
||||
6. Escape special characters in values: < for <, > for >, & for &, " for "
|
||||
|
||||
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
|
||||
\`\`\`xml
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### append_diagram Details
|
||||
|
||||
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
|
||||
|
||||
### edit_diagram Details
|
||||
|
||||
Three operations, all addressed by the cell's id attribute:
|
||||
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
|
||||
|
||||
**Operations:**
|
||||
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
||||
- **add**: Add a new cell. Provide cell_id (a new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges touching it are removed
|
||||
with it. Pass ONE cell_id — do not list the children separately.
|
||||
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
|
||||
**Input Format:**
|
||||
\`\`\`json
|
||||
@@ -167,27 +290,70 @@ Three operations, all addressed by the cell's id attribute:
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Change a label:
|
||||
**Examples:**
|
||||
|
||||
Change label:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Delete a container (children and edges go too):
|
||||
Add new shape:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Delete container (children & edges auto-deleted):
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
||||
\`\`\`
|
||||
|
||||
**Error Recovery:**
|
||||
If a cell_id is not found, re-read the ids in "Current diagram XML". If the change is structural
|
||||
rather than a small patch, rebuild with restructure_diagram instead — it computes
|
||||
the layout, so you never hand-place anything.
|
||||
If cell_id not found, check "Current diagram XML" for correct IDs. Use display_diagram if major restructuring is needed
|
||||
|
||||
### Keeping an edited diagram consistent
|
||||
|
||||
A diagram built by the engine carries its structure in the cell styles (the dai_* markers). If you
|
||||
patch a cell with edit_diagram, leave those markers intact: restructure_diagram reads them back to
|
||||
understand the current structure, and a cell that loses them is treated as a hand-drawn shape and
|
||||
stops taking part in the computed layout.`
|
||||
|
||||
|
||||
|
||||
## Edge Examples
|
||||
|
||||
### Two edges between same nodes (CORRECT - no overlap):
|
||||
\`\`\`xml
|
||||
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Edge with single waypoint (simple detour):
|
||||
\`\`\`xml
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="300" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
|
||||
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
|
||||
**WRONG:** Direct diagonal line crosses over Develop
|
||||
**CORRECT:** Route around the OUTSIDE (go right first, then up)
|
||||
\`\`\`xml
|
||||
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="750" y="80"/>
|
||||
<mxPoint x="750" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
|
||||
|
||||
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
|
||||
|
||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
||||
|
||||
+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
+1159
-678
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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.221.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",
|
||||
|
||||
Generated
+103
-72
@@ -12,6 +12,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"linkedom": "^0.18.0",
|
||||
"open": "^11.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -523,9 +524,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz",
|
||||
"integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
||||
@@ -899,26 +900,26 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"version": "24.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
|
||||
"integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -927,13 +928,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -954,9 +955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -967,13 +968,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -981,14 +982,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -997,9 +998,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1007,13 +1008,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1387,9 +1388,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
|
||||
"integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz",
|
||||
"integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
@@ -2569,17 +2570,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz",
|
||||
"integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.4.0",
|
||||
"default-browser": "^5.5.1",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
"is-in-ssh": "^1.0.0",
|
||||
"is-inside-container": "^1.0.0",
|
||||
"powershell-utils": "^0.1.0",
|
||||
"wsl-utils": "^0.3.0"
|
||||
"powershell-utils": "^0.2.1",
|
||||
"wsl-utils": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -2588,6 +2589,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/open/node_modules/powershell-utils": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz",
|
||||
"integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -2822,6 +2835,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@@ -3068,9 +3093,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.9",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.9.tgz",
|
||||
"integrity": "sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==",
|
||||
"version": "4.23.15",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3121,9 +3146,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3224,19 +3249,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -3264,12 +3289,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -3352,9 +3377,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.0.tgz",
|
||||
"integrity": "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz",
|
||||
"integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0",
|
||||
@@ -3367,10 +3392,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"linkedom": "^0.18.0",
|
||||
"open": "^11.0.0",
|
||||
"saxes": "^6.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 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 {
|
||||
@@ -26,6 +28,18 @@ 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.
|
||||
*
|
||||
@@ -43,12 +57,8 @@ export function applyDiagramOperations(
|
||||
): 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,
|
||||
@@ -56,11 +66,13 @@ 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")
|
||||
|
||||
// Locate the <root> element to operate on.
|
||||
//
|
||||
@@ -132,10 +144,12 @@ export function applyDiagramOperations(
|
||||
|
||||
// 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) {
|
||||
@@ -164,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",
|
||||
@@ -216,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",
|
||||
@@ -256,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
|
||||
}
|
||||
|
||||
@@ -270,17 +291,17 @@ export function applyDiagramOperations(
|
||||
cellsToDelete.add(cellId)
|
||||
|
||||
// Find children (cells where parent === cellId)
|
||||
// Scoped to `root` so other pages' cells with the same parent id
|
||||
// (notably "1") are never touched.
|
||||
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
|
||||
@@ -289,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(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -316,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
|
||||
}
|
||||
@@ -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,7 +93,11 @@ 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
|
||||
@@ -108,13 +116,20 @@ 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
|
||||
@@ -222,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}`)
|
||||
@@ -245,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}`) {
|
||||
@@ -262,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
|
||||
}
|
||||
@@ -305,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({
|
||||
@@ -320,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
|
||||
}
|
||||
|
||||
@@ -342,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 {
|
||||
@@ -378,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,
|
||||
}),
|
||||
)
|
||||
@@ -396,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" }))
|
||||
@@ -415,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 }))
|
||||
@@ -697,10 +789,11 @@ 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
|
||||
@@ -718,18 +811,29 @@ function getHtmlPage(sessionId: string): string {
|
||||
// for a page-targeted export — otherwise we'd push the
|
||||
// transient projection back as the canonical session state.
|
||||
if (projectionExportActive) return;
|
||||
// Request SVG export, then push state with SVG
|
||||
// Request SVG export, then push state with SVG. 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;
|
||||
@@ -741,8 +845,8 @@ function getHtmlPage(sessionId: string): string {
|
||||
// Page-targeted export: restore the user's real
|
||||
// multi-page document now that we have the image.
|
||||
restoreFromProjection();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
|
||||
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
|
||||
@@ -761,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', {
|
||||
@@ -814,15 +912,17 @@ function getHtmlPage(sessionId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushState(xml, svg = '') {
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -830,14 +930,22 @@ 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);
|
||||
}
|
||||
// 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
|
||||
@@ -862,9 +970,10 @@ function getHtmlPage(sessionId: string): string {
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const fireExport = () => {
|
||||
// mcpExport is echoed back in msg.message (see the handler)
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
? { action: 'export', format: 'png', scale: 2, mcpExport: true }
|
||||
: { action: 'export', format: 'svg', mcpExport: true };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
@@ -962,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;
|
||||
@@ -977,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() {
|
||||
@@ -989,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'); }
|
||||
|
||||
@@ -18,24 +18,6 @@
|
||||
* surface.
|
||||
*/
|
||||
|
||||
// Setup DOM polyfill for Node.js (required for XML operations)
|
||||
import { DOMParser } from "linkedom"
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
|
||||
// Create XMLSerializer polyfill using outerHTML
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: any): string {
|
||||
if (node.outerHTML !== undefined) {
|
||||
return node.outerHTML
|
||||
}
|
||||
if (node.documentElement) {
|
||||
return node.documentElement.outerHTML
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
|
||||
import { createRequire } from "node:module"
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
@@ -45,6 +27,7 @@ import {
|
||||
applyDiagramOperations,
|
||||
type DiagramOperation,
|
||||
} from "./diagram-operations.js"
|
||||
import { installDomPolyfill } from "./dom.js"
|
||||
import { checkEditGate } from "./edit-gate.js"
|
||||
import { addHistory } from "./history.js"
|
||||
import {
|
||||
@@ -72,6 +55,9 @@ import {
|
||||
} from "./pages.js"
|
||||
import { validateAndFixXml } from "./xml-validation.js"
|
||||
|
||||
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
|
||||
installDomPolyfill()
|
||||
|
||||
// Server configuration
|
||||
const config = {
|
||||
port: parseInt(process.env.PORT || "6002", 10),
|
||||
@@ -908,6 +894,47 @@ server.registerTool(
|
||||
},
|
||||
)
|
||||
|
||||
// The browser bridge has one export slot per session, so export requests
|
||||
// run one at a time: a concurrent call waits for the previous one.
|
||||
let exportQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Ask the browser to export (optionally via a page projection) and poll for
|
||||
* the resulting image data. Resolves to undefined on timeout.
|
||||
*/
|
||||
function exportViaBrowser(
|
||||
sessionId: string,
|
||||
format: "png" | "svg",
|
||||
projectionXml?: string,
|
||||
): Promise<string | undefined> {
|
||||
const run = exportQueue.then(async () => {
|
||||
requestExport(sessionId, format, projectionXml)
|
||||
|
||||
// A projection export does an extra load + render round-trip in the
|
||||
// browser, so give it a longer window. Re-read the live store entry
|
||||
// each tick: setState() (from a concurrent autosave or tool call)
|
||||
// replaces the Map entry with a new object, so a captured reference
|
||||
// would go stale and never observe the browser's exportData.
|
||||
const timeoutMs = projectionXml ? 15000 : 10000
|
||||
const start = Date.now()
|
||||
let exportData: string | undefined
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
exportData = getState(sessionId)?.exportData
|
||||
if (exportData) break
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
const live = getState(sessionId)
|
||||
if (live) {
|
||||
live.exportData = undefined
|
||||
live.exportFormat = undefined
|
||||
live.exportXml = undefined
|
||||
}
|
||||
return exportData
|
||||
})
|
||||
exportQueue = run.catch(() => {})
|
||||
return run
|
||||
}
|
||||
|
||||
// Tool: export_diagram
|
||||
server.registerTool(
|
||||
"export_diagram",
|
||||
@@ -1079,34 +1106,12 @@ server.registerTool(
|
||||
projectionXml = projection.xml
|
||||
}
|
||||
|
||||
// Ask the browser to export (optionally via a page projection) and
|
||||
// poll for the resulting image data.
|
||||
requestExport(
|
||||
const exportData = await exportViaBrowser(
|
||||
currentSession.id,
|
||||
detectedFormat as "png" | "svg",
|
||||
projectionXml,
|
||||
)
|
||||
|
||||
// A projection export does an extra load + render round-trip in the
|
||||
// browser, so give it a longer window. Re-read the live store entry
|
||||
// each tick: setState() (from a concurrent autosave or tool call)
|
||||
// replaces the Map entry with a new object, so a captured reference
|
||||
// would go stale and never observe the browser's exportData.
|
||||
const timeoutMs = projectionXml ? 15000 : 10000
|
||||
const start = Date.now()
|
||||
let exportData: string | undefined
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
exportData = getState(currentSession.id)?.exportData
|
||||
if (exportData) break
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
const live = getState(currentSession.id)
|
||||
if (live) {
|
||||
live.exportData = undefined
|
||||
live.exportFormat = undefined
|
||||
live.exportXml = undefined
|
||||
}
|
||||
|
||||
if (!exportData) {
|
||||
return {
|
||||
content: [
|
||||
@@ -1215,15 +1220,20 @@ async function loadMxfileForMutation(): Promise<
|
||||
doc,
|
||||
writeBack: (newDoc: Document) => {
|
||||
const newXml = serializeMxfile(newDoc)
|
||||
// The store may hold user edits the model has not seen yet.
|
||||
const sawLatest = checkEditGate(
|
||||
sessionRef.lastSeenXml,
|
||||
browserState?.xml ?? "",
|
||||
).ok
|
||||
// Save history before overwriting so the user can undo.
|
||||
addHistory(sessionRef.id, sessionRef.xml, browserState?.svg || "")
|
||||
sessionRef.xml = newXml
|
||||
sessionRef.version++
|
||||
setState(sessionRef.id, newXml)
|
||||
// The model just wrote this exact state, so mark it as seen —
|
||||
// subsequent edit_diagram calls don't need a redundant
|
||||
// get_diagram round-trip.
|
||||
sessionRef.lastSeenXml = newXml
|
||||
// The model just wrote this exact state. If it had seen the state
|
||||
// it built on, mark the result as seen so edit_diagram needs no
|
||||
// extra get_diagram; otherwise edit_diagram must ask for one.
|
||||
sessionRef.lastSeenXml = sawLatest ? newXml : ""
|
||||
addHistory(sessionRef.id, newXml, "")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { inflateRawSync } from "node:zlib"
|
||||
import { DOMParser } from "linkedom"
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
import {
|
||||
isMxFile,
|
||||
isMxGraphModel,
|
||||
@@ -82,7 +83,7 @@ export function parseDrawioFileContent(content: string): LoadResult {
|
||||
}
|
||||
const inner = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (
|
||||
inner.querySelector("parsererror") ||
|
||||
getXmlSyntaxError(xml) ||
|
||||
inner.documentElement?.tagName !== "mxGraphModel"
|
||||
) {
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
import { getXmlSyntaxError } from "./dom.js"
|
||||
|
||||
export interface PageInfo {
|
||||
id: string
|
||||
@@ -110,8 +111,8 @@ export function normalizeToMxfile(
|
||||
*/
|
||||
export function parseMxfile(xml: string): Document | null {
|
||||
try {
|
||||
if (getXmlSyntaxError(xml)) return null
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (doc.querySelector("parsererror")) return null
|
||||
if (doc.documentElement?.tagName !== "mxfile") return null
|
||||
return doc as unknown as Document
|
||||
} catch {
|
||||
@@ -258,12 +259,12 @@ export function addPageToDoc(
|
||||
}
|
||||
|
||||
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
||||
const tempDoc = new DOMParser().parseFromString(snippet, "text/xml")
|
||||
if (tempDoc.querySelector("parsererror")) {
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
@@ -128,8 +142,7 @@ function checkDuplicateAttributes(xml: string): string | null {
|
||||
* scope the cell-ID uniqueness check per <diagram>, and additionally check
|
||||
* that the <diagram> ids themselves are unique.
|
||||
*
|
||||
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
|
||||
* and for XML that won't DOM-parse.
|
||||
* 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
|
||||
@@ -142,51 +155,47 @@ function checkDuplicateIds(xml: string): string | null {
|
||||
if (mightBeMxFile)
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (!doc.querySelector("parsererror")) {
|
||||
const rootEl = doc.documentElement
|
||||
if (rootEl && rootEl.tagName === "mxfile") {
|
||||
const diagrams = doc.querySelectorAll("diagram")
|
||||
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
|
||||
// 1) <diagram> ids must be unique across the file.
|
||||
const diagramIds = new Map<string, number>()
|
||||
diagrams.forEach((d) => {
|
||||
const id = d.getAttribute("id")
|
||||
if (id) diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dupDiagrams = Array.from(diagramIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id]) => `'${id}'`)
|
||||
if (dupDiagrams.length > 0) {
|
||||
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
||||
}
|
||||
|
||||
// 2) Within each page, mxCell ids must be unique.
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
const diagram = diagrams[i]
|
||||
const pageId = diagram.getAttribute("id") || `(index ${i})`
|
||||
const cells = diagram.querySelectorAll("mxCell")
|
||||
const cellIds = new Map<string, number>()
|
||||
cells.forEach((c) => {
|
||||
const id = c.getAttribute("id")
|
||||
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dups = Array.from(cellIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||
if (dups.length > 0) {
|
||||
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
// fall through to regex
|
||||
}
|
||||
|
||||
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
|
||||
// Legacy regex-based check for bare <mxGraphModel> inputs.
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
const ids = new Map<string, number>()
|
||||
let idMatch
|
||||
@@ -315,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")
|
||||
@@ -404,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
|
||||
}
|
||||
|
||||
@@ -494,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")
|
||||
}
|
||||
|
||||
@@ -511,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")
|
||||
}
|
||||
|
||||
@@ -632,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
|
||||
@@ -796,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
|
||||
@@ -807,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) {
|
||||
@@ -860,9 +902,11 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
|
||||
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, id) => {
|
||||
/(\bid\s*=\s*["'])([^"']+)(["'])/gi,
|
||||
(match, before, id, after) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
@@ -870,8 +914,7 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
|
||||
if (count === 0) return match
|
||||
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
return `${before}${id}_dup${count}${after}`
|
||||
},
|
||||
)
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
@@ -892,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()
|
||||
})
|
||||
})
|
||||
@@ -9,11 +9,11 @@
|
||||
* reads as a user edit.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
import { beforeAll, describe, expect, it } from "vitest"
|
||||
import { installDomPolyfill } from "../src/dom.js"
|
||||
|
||||
beforeAll(() => {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { checkEditGate, contentFingerprint } from "../src/edit-gate.js"
|
||||
|
||||
@@ -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>")
|
||||
})
|
||||
})
|
||||
@@ -10,18 +10,11 @@
|
||||
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(() => {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: any): string {
|
||||
if (node.outerHTML !== undefined) return node.outerHTML
|
||||
if (node.documentElement) return node.documentElement.outerHTML
|
||||
return ""
|
||||
}
|
||||
}
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import {
|
||||
|
||||
@@ -15,21 +15,13 @@
|
||||
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
||||
*/
|
||||
|
||||
import { DOMParser } from "linkedom"
|
||||
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(() => {
|
||||
;(globalThis as any).DOMParser = DOMParser
|
||||
class XMLSerializerPolyfill {
|
||||
serializeToString(node: any): string {
|
||||
if (node.outerHTML !== undefined) return node.outerHTML
|
||||
if (node.documentElement) return node.documentElement.outerHTML
|
||||
return ""
|
||||
}
|
||||
}
|
||||
;(globalThis as any).XMLSerializer = XMLSerializerPolyfill
|
||||
installDomPolyfill()
|
||||
})
|
||||
|
||||
import { applyDiagramOperations } from "../src/diagram-operations.js"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user