Compare commits

..

1 Commits

Author SHA1 Message Date
Dayuan Jiang
4dbda5ba8e refactor: extract diagram tool handlers to dedicated hook
- Create useDiagramToolHandlers hook for display_diagram, edit_diagram, append_diagram
- Remove ~300 lines from chat-panel.tsx
- Remove unused stopRef
- Gate debug console.log statements with DEBUG constant
2025-12-24 10:15:41 +09:00
26 changed files with 182 additions and 391 deletions

View File

@@ -1,44 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Lint check
run: npm run check
- name: Build
run: npm run build
- name: Security audit
run: npm audit --audit-level=high --omit=dev

View File

@@ -1,5 +1,4 @@
"use client" "use client"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { DrawIoEmbed } from "react-drawio" import { DrawIoEmbed } from "react-drawio"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
@@ -11,7 +10,6 @@ import {
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl = const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net" process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
@@ -26,8 +24,6 @@ export default function Home() {
showSaveDialog, showSaveDialog,
setShowSaveDialog, setShowSaveDialog,
} = useDiagram() } = useDiagram()
const router = useRouter()
const pathname = usePathname()
const [isMobile, setIsMobile] = useState(false) const [isMobile, setIsMobile] = useState(false)
const [isChatVisible, setIsChatVisible] = useState(true) const [isChatVisible, setIsChatVisible] = useState(true)
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
@@ -62,18 +58,6 @@ export default function Home() {
// Load preferences from localStorage after mount // Load preferences from localStorage after mount
useEffect(() => { useEffect(() => {
// Restore saved locale and redirect if needed
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
const pathParts = pathname.split("/").filter(Boolean)
const currentLocale = pathParts[0]
if (currentLocale !== savedLocale) {
pathParts[0] = savedLocale
router.replace(`/${pathParts.join("/")}`)
return // Wait for redirect
}
}
const savedUi = localStorage.getItem("drawio-theme") const savedUi = localStorage.getItem("drawio-theme")
if (savedUi === "min" || savedUi === "sketch") { if (savedUi === "min" || savedUi === "sketch") {
setDrawioUi(savedUi) setDrawioUi(savedUi)
@@ -100,7 +84,7 @@ export default function Home() {
} }
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [])
const handleDarkModeChange = async () => { const handleDarkModeChange = async () => {
await saveDiagramToStorage() await saveDiagramToStorage()

View File

@@ -26,7 +26,6 @@ import {
wrapWithObserve, wrapWithObserve,
} from "@/lib/langfuse" } from "@/lib/langfuse"
import { getSystemPrompt } from "@/lib/system-prompts" import { getSystemPrompt } from "@/lib/system-prompts"
import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120 export const maxDuration = 120
@@ -168,8 +167,13 @@ async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml, previousXml, sessionId } = await req.json() const { messages, xml, previousXml, sessionId } = await req.json()
// Get user ID for Langfuse tracking and quota // Get user IP for Langfuse tracking (hashed for privacy)
const userId = getUserIdFromRequest(req) const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
// Validate sessionId for Langfuse (must be string, max 200 chars) // Validate sessionId for Langfuse (must be string, max 200 chars)
const validSessionId = const validSessionId =
@@ -609,22 +613,14 @@ Operations:
For update/add, new_xml must be a complete mxCell element including mxGeometry. For update/add, new_xml must be a complete mxCell element including mxGeometry.
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\" ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"`,
Example - Add a rectangle:
{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
Example - Delete a cell:
{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}`,
inputSchema: z.object({ inputSchema: z.object({
operations: z operations: z
.array( .array(
z.object({ z.object({
operation: z type: z
.enum(["update", "add", "delete"]) .enum(["update", "add", "delete"])
.describe( .describe("Operation type"),
"Operation to perform: add, update, or delete",
),
cell_id: z cell_id: z
.string() .string()
.describe( .describe(

View File

@@ -1,7 +1,6 @@
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { z } from "zod" import { z } from "zod"
import { getLangfuseClient } from "@/lib/langfuse" import { getLangfuseClient } from "@/lib/langfuse"
import { getUserIdFromRequest } from "@/lib/user-id"
const feedbackSchema = z.object({ const feedbackSchema = z.object({
messageId: z.string().min(1).max(200), messageId: z.string().min(1).max(200),
@@ -33,8 +32,13 @@ export async function POST(req: Request) {
return Response.json({ success: true, logged: false }) return Response.json({ success: true, logged: false })
} }
// Get user ID for tracking // Get user IP for tracking (hashed for privacy)
const userId = getUserIdFromRequest(req) const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
const userId =
rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url").slice(0, 8)}`
try { try {
// Find the most recent chat trace for this session to attach the score to // Find the most recent chat trace for this session to attach the score to

View File

@@ -66,22 +66,8 @@ export const ModelSelectorInput = ({
export type ModelSelectorListProps = ComponentProps<typeof CommandList> export type ModelSelectorListProps = ComponentProps<typeof CommandList>
export const ModelSelectorList = ({ export const ModelSelectorList = (props: ModelSelectorListProps) => (
className, <CommandList {...props} />
...props
}: ModelSelectorListProps) => (
<div className="relative">
<CommandList
className={cn(
// Hide scrollbar on all platforms
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
className,
)}
{...props}
/>
{/* Bottom shadow indicator for scrollable content */}
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-muted/80 via-muted/40 to-transparent" />
</div>
) )
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty> export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>

View File

@@ -40,7 +40,7 @@ import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block" import { CodeBlock } from "./code-block"
interface DiagramOperation { interface DiagramOperation {
operation: "update" | "add" | "delete" type: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }
@@ -53,12 +53,12 @@ function getCompleteOperations(
return operations.filter( return operations.filter(
(op) => (op) =>
op && op &&
typeof op.operation === "string" && typeof op.type === "string" &&
["update", "add", "delete"].includes(op.operation) && ["update", "add", "delete"].includes(op.type) &&
typeof op.cell_id === "string" && typeof op.cell_id === "string" &&
op.cell_id.length > 0 && op.cell_id.length > 0 &&
// delete doesn't need new_xml, update/add do // delete doesn't need new_xml, update/add do
(op.operation === "delete" || typeof op.new_xml === "string"), (op.type === "delete" || typeof op.new_xml === "string"),
) )
} }
@@ -79,20 +79,20 @@ function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
<div className="space-y-3"> <div className="space-y-3">
{operations.map((op, index) => ( {operations.map((op, index) => (
<div <div
key={`${op.operation}-${op.cell_id}-${index}`} key={`${op.type}-${op.cell_id}-${index}`}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50" className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
> >
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2"> <div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span <span
className={`text-[10px] font-medium uppercase tracking-wide ${ className={`text-[10px] font-medium uppercase tracking-wide ${
op.operation === "delete" op.type === "delete"
? "text-red-600" ? "text-red-600"
: op.operation === "add" : op.type === "add"
? "text-green-600" ? "text-green-600"
: "text-blue-600" : "text-blue-600"
}`} }`}
> >
{op.operation} {op.type}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
cell_id: {op.cell_id} cell_id: {op.cell_id}

View File

@@ -412,7 +412,11 @@ export function ModelConfigDialog({
setSelectedProviderId( setSelectedProviderId(
provider.id, provider.id,
) )
setValidationStatus("idle") setValidationStatus(
provider.validated
? "success"
: "idle",
)
setShowApiKey(false) setShowApiKey(false)
}} }}
className={cn( className={cn(
@@ -500,7 +504,7 @@ export function ModelConfigDialog({
</div> </div>
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden "> <div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{selectedProvider ? ( {selectedProvider ? (
<> <>
<ScrollArea className="flex-1" ref={scrollRef}> <ScrollArea className="flex-1" ref={scrollRef}>
@@ -551,20 +555,6 @@ export function ModelConfigDialog({
</span> </span>
</div> </div>
)} )}
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4 mr-1.5" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div> </div>
{/* Configuration Section */} {/* Configuration Section */}
@@ -1426,6 +1416,24 @@ export function ModelConfigDialog({
)} )}
</div> </div>
</ConfigSection> </ConfigSection>
{/* Danger Zone */}
<div className="pt-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDeleteConfirmOpen(true)
}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl"
>
<Trash2 className="h-4 w-4 mr-2" />
{
dict.modelConfig
.deleteProvider
}
</Button>
</div>
</div> </div>
</ScrollArea> </ScrollArea>
</> </>

View File

@@ -124,7 +124,7 @@ export function ModelSelector({
<ModelSelectorInput <ModelSelectorInput
placeholder={dict.modelConfig.searchModels} placeholder={dict.modelConfig.searchModels}
/> />
<ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"> <ModelSelectorList>
<ModelSelectorEmpty> <ModelSelectorEmpty>
{validatedModels.length === 0 && models.length > 0 {validatedModels.length === 0 && models.length > 0
? dict.modelConfig.noVerifiedModels ? dict.modelConfig.noVerifiedModels

View File

@@ -151,9 +151,6 @@ function SettingsContent({
}, [open]) }, [open])
const changeLanguage = (lang: string) => { const changeLanguage = (lang: string) => {
// Save locale to localStorage for persistence across restarts
localStorage.setItem("next-ai-draw-io-locale", lang)
const parts = pathname.split("/") const parts = pathname.split("/")
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) { if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
parts[1] = lang parts[1] = lang

View File

@@ -3,16 +3,16 @@ import { app } from "electron"
/** /**
* Port configuration * Port configuration
* Using fixed ports to preserve localStorage across restarts
* (localStorage is origin-specific, so changing ports loses all saved data)
*/ */
const PORT_CONFIG = { const PORT_CONFIG = {
// Development mode uses fixed port for hot reload compatibility // Development mode uses fixed port for hot reload compatibility
development: 6002, development: 6002,
// Production mode uses fixed port (61337) to preserve localStorage // Production mode port range (will find first available)
// Falls back to sequential ports if unavailable production: {
production: 61337, min: 10000,
// Maximum attempts to find an available port (fallback) max: 65535,
},
// Maximum attempts to find an available port
maxAttempts: 100, maxAttempts: 100,
} }
@@ -36,11 +36,19 @@ export function isPortAvailable(port: number): Promise<boolean> {
}) })
} }
/**
* Generate a random port within the production range
*/
function getRandomPort(): number {
const { min, max } = PORT_CONFIG.production
return Math.floor(Math.random() * (max - min + 1)) + min
}
/** /**
* Find an available port * Find an available port
* - In development: uses fixed port (6002) * - In development: uses fixed port (6002)
* - In production: uses fixed port (61337) to preserve localStorage * - In production: finds a random available port
* - Falls back to sequential ports if preferred port is unavailable * - If a port was previously allocated, verifies it's still available
* *
* @param reuseExisting If true, try to reuse the previously allocated port * @param reuseExisting If true, try to reuse the previously allocated port
* @returns Promise<number> The available port * @returns Promise<number> The available port
@@ -48,9 +56,6 @@ export function isPortAvailable(port: number): Promise<boolean> {
*/ */
export async function findAvailablePort(reuseExisting = true): Promise<number> { export async function findAvailablePort(reuseExisting = true): Promise<number> {
const isDev = !app.isPackaged const isDev = !app.isPackaged
const preferredPort = isDev
? PORT_CONFIG.development
: PORT_CONFIG.production
// Try to reuse cached port if requested and available // Try to reuse cached port if requested and available
if (reuseExisting && allocatedPort !== null) { if (reuseExisting && allocatedPort !== null) {
@@ -64,22 +69,29 @@ export async function findAvailablePort(reuseExisting = true): Promise<number> {
allocatedPort = null allocatedPort = null
} }
// Try preferred port first if (isDev) {
if (await isPortAvailable(preferredPort)) { // Development mode: use fixed port
allocatedPort = preferredPort const port = PORT_CONFIG.development
return preferredPort const available = await isPortAvailable(port)
if (available) {
allocatedPort = port
return port
}
console.warn(
`Development port ${port} is in use, finding alternative...`,
)
} }
console.warn( // Production mode or dev port unavailable: find random available port
`Preferred port ${preferredPort} is in use, finding alternative...`, for (let attempt = 0; attempt < PORT_CONFIG.maxAttempts; attempt++) {
) const port = isDev
? PORT_CONFIG.development + attempt + 1
: getRandomPort()
// Fallback: try sequential ports starting from preferred + 1 const available = await isPortAvailable(port)
for (let attempt = 1; attempt <= PORT_CONFIG.maxAttempts; attempt++) { if (available) {
const port = preferredPort + attempt
if (await isPortAvailable(port)) {
allocatedPort = port allocatedPort = port
console.log(`Allocated fallback port: ${port}`) console.log(`Allocated port: ${port}`)
return port return port
} }
} }

View File

@@ -9,12 +9,6 @@
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="deprecation-notice">
<strong>⚠️ Deprecation Notice</strong>
<p>This settings panel will be removed in a future update.</p>
<p>Please use the <strong>AI Model Configuration</strong> button (left of the Send button in the chat panel) to configure your AI providers. Your settings there will persist across updates.</p>
</div>
<h1>Configuration Presets</h1> <h1>Configuration Presets</h1>
<div class="section"> <div class="section">

View File

@@ -24,39 +24,6 @@
} }
} }
.deprecation-notice {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
}
.deprecation-notice strong {
color: #856404;
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.deprecation-notice p {
color: #856404;
font-size: 13px;
margin: 4px 0;
}
@media (prefers-color-scheme: dark) {
.deprecation-notice {
background-color: #332701;
border-color: #665200;
}
.deprecation-notice strong,
.deprecation-notice p {
color: #ffc107;
}
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;

View File

@@ -30,7 +30,7 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void type AddToolOutputFn = (params: AddToolOutputParams) => void
interface DiagramOperation { interface DiagramOperation {
operation: "update" | "add" | "delete" type: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }

View File

@@ -14,14 +14,22 @@ export function register() {
publicKey: process.env.LANGFUSE_PUBLIC_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY, secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASEURL, baseUrl: process.env.LANGFUSE_BASEURL,
// Whitelist approach: only export AI-related spans // Filter out Next.js HTTP request spans so AI SDK spans become root traces
shouldExportSpan: ({ otelSpan }) => { shouldExportSpan: ({ otelSpan }) => {
const spanName = otelSpan.name const spanName = otelSpan.name
// Only export AI SDK spans (ai.*) and our explicit "chat" wrapper // Skip Next.js HTTP infrastructure spans
if (spanName === "chat" || spanName.startsWith("ai.")) { if (
return true spanName.startsWith("POST") ||
spanName.startsWith("GET") ||
spanName.startsWith("RSC") ||
spanName.includes("BaseServer") ||
spanName.includes("handleRequest") ||
spanName.includes("resolve page") ||
spanName.includes("start response")
) {
return false
} }
return false return true
}, },
}) })

View File

@@ -9,30 +9,6 @@ import {
// OSS users who don't need quota tracking can simply not set this env var // OSS users who don't need quota tracking can simply not set this env var
const TABLE = process.env.DYNAMODB_QUOTA_TABLE const TABLE = process.env.DYNAMODB_QUOTA_TABLE
const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1" const DYNAMODB_REGION = process.env.DYNAMODB_REGION || "ap-northeast-1"
// Timezone for daily quota reset (e.g., "Asia/Tokyo" for JST midnight reset)
// Defaults to UTC if not set
let QUOTA_TIMEZONE = process.env.QUOTA_TIMEZONE || "UTC"
// Validate timezone at module load
try {
new Intl.DateTimeFormat("en-CA", { timeZone: QUOTA_TIMEZONE }).format(
new Date(),
)
} catch {
console.warn(
`[quota] Invalid QUOTA_TIMEZONE "${QUOTA_TIMEZONE}", using UTC`,
)
QUOTA_TIMEZONE = "UTC"
}
/**
* Get today's date string in the configured timezone (YYYY-MM-DD format)
*/
function getTodayInTimezone(): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: QUOTA_TIMEZONE,
}).format(new Date())
}
// Only create client if quota is enabled // Only create client if quota is enabled
const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null const client = TABLE ? new DynamoDBClient({ region: DYNAMODB_REGION }) : null
@@ -73,67 +49,32 @@ export async function checkAndIncrementRequest(
return { allowed: true } return { allowed: true }
} }
const today = getTodayInTimezone() const today = new Date().toISOString().split("T")[0]
const currentMinute = Math.floor(Date.now() / 60000).toString() const currentMinute = Math.floor(Date.now() / 60000).toString()
const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60 const ttl = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
try { try {
// First, try to reset counts if it's a new day (atomic day reset) // Atomic check-and-increment with ConditionExpression
// This will succeed only if lastResetDate < today or doesn't exist // This prevents race conditions by failing if limits are exceeded
try {
await client.send(
new UpdateItemCommand({
TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } },
// Reset all counts to 1/0 for the new day
UpdateExpression: `
SET lastResetDate = :today,
dailyReqCount = :one,
dailyTokenCount = :zero,
lastMinute = :minute,
tpmCount = :zero,
#ttl = :ttl
`,
// Only succeed if it's a new day (or new item)
ConditionExpression: `
attribute_not_exists(lastResetDate) OR lastResetDate < :today
`,
ExpressionAttributeNames: { "#ttl": "ttl" },
ExpressionAttributeValues: {
":today": { S: today },
":zero": { N: "0" },
":one": { N: "1" },
":minute": { S: currentMinute },
":ttl": { N: String(ttl) },
},
}),
)
// New day reset successful
return { allowed: true }
} catch (resetError: any) {
// If condition failed, it's the same day - continue to increment logic
if (!(resetError instanceof ConditionalCheckFailedException)) {
throw resetError // Re-throw unexpected errors
}
}
// Same day - increment request count with limit checks
await client.send( await client.send(
new UpdateItemCommand({ new UpdateItemCommand({
TableName: TABLE, TableName: TABLE,
Key: { PK: { S: `IP#${ip}` } }, Key: { PK: { S: `IP#${ip}` } },
// Increment request count, handle minute boundary for TPM // Reset counts if new day/minute, then increment request count
UpdateExpression: ` UpdateExpression: `
SET lastMinute = :minute, SET lastResetDate = :today,
dailyReqCount = if_not_exists(dailyReqCount, :zero) + :one,
dailyTokenCount = if_not_exists(dailyTokenCount, :zero),
lastMinute = :minute,
tpmCount = if_not_exists(tpmCount, :zero), tpmCount = if_not_exists(tpmCount, :zero),
#ttl = :ttl #ttl = :ttl
ADD dailyReqCount :one
`, `,
// Check all limits before allowing increment // Atomic condition: only succeed if ALL limits pass
// Uses attribute_not_exists for new items, then checks limits for existing items
ConditionExpression: ` ConditionExpression: `
lastResetDate = :today AND (attribute_not_exists(lastResetDate) OR lastResetDate < :today OR
(attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND ((attribute_not_exists(dailyReqCount) OR dailyReqCount < :reqLimit) AND
(attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit) AND (attribute_not_exists(dailyTokenCount) OR dailyTokenCount < :tokenLimit))) AND
(attribute_not_exists(lastMinute) OR lastMinute <> :minute OR (attribute_not_exists(lastMinute) OR lastMinute <> :minute OR
attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit) attribute_not_exists(tpmCount) OR tpmCount < :tpmLimit)
`, `,

View File

@@ -99,9 +99,9 @@ When using edit_diagram tool:
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry) - For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
- For delete: only cell_id is needed - For delete: only cell_id is needed
- Find the cell_id from "Current diagram XML" in system context - 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 update: {"operations": [{"type": "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 delete: {"operations": [{"type": "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>"}]} - Example add: {"operations": [{"type": "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\\" ⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
@@ -282,9 +282,9 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
\`\`\`json \`\`\`json
{ {
"operations": [ "operations": [
{"operation": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"}, {"type": "update", "cell_id": "3", "new_xml": "<mxCell ...complete element...>"},
{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"}, {"type": "add", "cell_id": "new1", "new_xml": "<mxCell ...new element...>"},
{"operation": "delete", "cell_id": "5"} {"type": "delete", "cell_id": "5"}
] ]
} }
\`\`\` \`\`\`
@@ -293,17 +293,17 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
Change label: Change label:
\`\`\`json \`\`\`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>"}]} {"operations": [{"type": "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>"}]}
\`\`\` \`\`\`
Add new shape: Add new shape:
\`\`\`json \`\`\`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>"}]} {"operations": [{"type": "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 cell: Delete cell:
\`\`\`json \`\`\`json
{"operations": [{"operation": "delete", "cell_id": "5"}]} {"operations": [{"type": "delete", "cell_id": "5"}]}
\`\`\` \`\`\`
**Error Recovery:** **Error Recovery:**

View File

@@ -83,24 +83,22 @@ export const PROVIDER_INFO: Record<
// Suggested models per provider for quick add // Suggested models per provider for quick add
export const SUGGESTED_MODELS: Record<ProviderName, string[]> = { export const SUGGESTED_MODELS: Record<ProviderName, string[]> = {
openai: [ openai: [
"gpt-5.2-pro", // GPT-4o series (latest)
"gpt-5.2-chat-latest",
"gpt-5.2",
"gpt-5.1-codex-mini",
"gpt-5.1-codex",
"gpt-5.1-chat-latest",
"gpt-5.1",
"gpt-5-pro",
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5-codex",
"gpt-5-chat-latest",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o", "gpt-4o",
"gpt-4o-mini", "gpt-4o-mini",
"gpt-4o-2024-11-20",
// GPT-4 Turbo
"gpt-4-turbo",
"gpt-4-turbo-preview",
// o1/o3 reasoning models
"o1",
"o1-mini",
"o1-preview",
"o3-mini",
// GPT-4
"gpt-4",
// GPT-3.5
"gpt-3.5-turbo",
], ],
anthropic: [ anthropic: [
// Claude 4.5 series (latest) // Claude 4.5 series (latest)

View File

@@ -1,12 +0,0 @@
/**
* Generate a userId from request for tracking purposes.
* Uses base64url encoding of IP for URL-safe identifier.
* Note: base64 is reversible - this is NOT privacy protection.
*/
export function getUserIdFromRequest(req: Request): string {
const forwardedFor = req.headers.get("x-forwarded-for")
const rawIp = forwardedFor?.split(",")[0]?.trim() || "anonymous"
return rawIp === "anonymous"
? rawIp
: `user-${Buffer.from(rawIp).toString("base64url")}`
}

View File

@@ -455,7 +455,7 @@ export function replaceNodes(currentXML: string, nodes: string): string {
// ============================================================================ // ============================================================================
export interface DiagramOperation { export interface DiagramOperation {
operation: "update" | "add" | "delete" type: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }
@@ -528,7 +528,7 @@ export function applyDiagramOperations(
// Process each operation // Process each operation
for (const op of operations) { for (const op of operations) {
if (op.operation === "update") { if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
@@ -580,7 +580,7 @@ export function applyDiagramOperations(
// Update the map with the new element // Update the map with the new element
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "add") { } else if (op.type === "add") {
// Check if ID already exists // Check if ID already exists
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
@@ -632,7 +632,7 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.7", "version": "0.4.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.7", "version": "0.4.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^4.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.7", "version": "0.4.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.6", "version": "0.1.5",
"description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview", "description": "MCP server for Next AI Draw.io - AI-powered diagram generation with real-time browser preview",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -4,7 +4,7 @@
*/ */
export interface DiagramOperation { export interface DiagramOperation {
operation: "update" | "add" | "delete" type: "update" | "add" | "delete"
cell_id: string cell_id: string
new_xml?: string new_xml?: string
} }
@@ -77,7 +77,7 @@ export function applyDiagramOperations(
// Process each operation // Process each operation
for (const op of operations) { for (const op of operations) {
if (op.operation === "update") { if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
@@ -129,7 +129,7 @@ export function applyDiagramOperations(
// Update the map with the new element // Update the map with the new element
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "add") { } else if (op.type === "add") {
// Check if ID already exists // Check if ID already exists
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
@@ -181,7 +181,7 @@ export function applyDiagramOperations(
// Add to map // Add to map
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({

View File

@@ -265,22 +265,14 @@ server.registerTool(
"- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" + "- add: Add a new cell. Provide cell_id (new unique id) and new_xml.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" + "- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed.\n\n" + "- delete: Remove a cell by its id. Only cell_id is needed.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry.\n\n" + "For add/update, new_xml must be a complete mxCell element including mxGeometry.",
"Example - Add a rectangle:\n" +
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
"Example - Update a cell:\n" +
'{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
"Example - Delete a cell:\n" +
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
inputSchema: { inputSchema: {
operations: z operations: z
.array( .array(
z.object({ z.object({
operation: z type: z
.enum(["update", "add", "delete"]) .enum(["update", "add", "delete"])
.describe( .describe("Operation type"),
"Operation to perform: add, update, or delete",
),
cell_id: z.string().describe("The id of the mxCell"), cell_id: z.string().describe("The id of the mxCell"),
new_xml: z new_xml: z
.string() .string()
@@ -364,13 +356,13 @@ server.registerTool(
) )
if (fixed) { if (fixed) {
log.info( log.info(
`Operation ${op.operation} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`, `Operation ${op.type} ${op.cell_id}: XML auto-fixed: ${fixes.join(", ")}`,
) )
return { ...op, new_xml: fixed } return { ...op, new_xml: fixed }
} }
if (!valid && error) { if (!valid && error) {
log.warn( log.warn(
`Operation ${op.operation} ${op.cell_id}: XML validation failed: ${error}`, `Operation ${op.type} ${op.cell_id}: XML validation failed: ${error}`,
) )
} }
} }

View File

@@ -1,40 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["after 10am on saturday"],
"timezone": "Asia/Tokyo",
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"matchPackagePatterns": ["*"],
"groupName": "minor and patch dependencies",
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"matchPackagePatterns": ["*"],
"automerge": false
},
{
"matchPackagePatterns": ["@ai-sdk/*"],
"groupName": "AI SDK packages"
},
{
"matchPackagePatterns": ["@radix-ui/*"],
"groupName": "Radix UI packages"
},
{
"matchPackagePatterns": ["electron", "electron-builder"],
"groupName": "Electron packages",
"automerge": false
},
{
"matchPackagePatterns": ["@ai-sdk/*", "ai", "next"],
"groupName": "Core framework packages",
"automerge": false
}
],
"vulnerabilityAlerts": {
"enabled": true
}
}

View File

@@ -22,7 +22,7 @@ function applyDiagramOperations(xmlContent, operations) {
result: xmlContent, result: xmlContent,
errors: [ errors: [
{ {
operation: "update", type: "update",
cellId: "", cellId: "",
message: `XML parse error: ${parseError.textContent}`, message: `XML parse error: ${parseError.textContent}`,
}, },
@@ -36,7 +36,7 @@ function applyDiagramOperations(xmlContent, operations) {
result: xmlContent, result: xmlContent,
errors: [ errors: [
{ {
operation: "update", type: "update",
cellId: "", cellId: "",
message: "Could not find <root> element in XML", message: "Could not find <root> element in XML",
}, },
@@ -51,11 +51,11 @@ function applyDiagramOperations(xmlContent, operations) {
}) })
for (const op of operations) { for (const op of operations) {
if (op.operation === "update") { if (op.type === "update") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
operation: "update", type: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cell with id="${op.cell_id}" not found`,
}) })
@@ -63,7 +63,7 @@ function applyDiagramOperations(xmlContent, operations) {
} }
if (!op.new_xml) { if (!op.new_xml) {
errors.push({ errors.push({
operation: "update", type: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml is required for update operation", message: "new_xml is required for update operation",
}) })
@@ -76,7 +76,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCell = newDoc.querySelector("mxCell") const newCell = newDoc.querySelector("mxCell")
if (!newCell) { if (!newCell) {
errors.push({ errors.push({
operation: "update", type: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml must contain an mxCell element", message: "new_xml must contain an mxCell element",
}) })
@@ -85,7 +85,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCellId = newCell.getAttribute("id") const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) { if (newCellId !== op.cell_id) {
errors.push({ errors.push({
operation: "update", type: "update",
cellId: op.cell_id, cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
}) })
@@ -94,10 +94,10 @@ function applyDiagramOperations(xmlContent, operations) {
const importedNode = doc.importNode(newCell, true) const importedNode = doc.importNode(newCell, true)
existingCell.parentNode?.replaceChild(importedNode, existingCell) existingCell.parentNode?.replaceChild(importedNode, existingCell)
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "add") { } else if (op.type === "add") {
if (cellMap.has(op.cell_id)) { if (cellMap.has(op.cell_id)) {
errors.push({ errors.push({
operation: "add", type: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" already exists`, message: `Cell with id="${op.cell_id}" already exists`,
}) })
@@ -105,7 +105,7 @@ function applyDiagramOperations(xmlContent, operations) {
} }
if (!op.new_xml) { if (!op.new_xml) {
errors.push({ errors.push({
operation: "add", type: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml is required for add operation", message: "new_xml is required for add operation",
}) })
@@ -118,7 +118,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCell = newDoc.querySelector("mxCell") const newCell = newDoc.querySelector("mxCell")
if (!newCell) { if (!newCell) {
errors.push({ errors.push({
operation: "add", type: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: "new_xml must contain an mxCell element", message: "new_xml must contain an mxCell element",
}) })
@@ -127,7 +127,7 @@ function applyDiagramOperations(xmlContent, operations) {
const newCellId = newCell.getAttribute("id") const newCellId = newCell.getAttribute("id")
if (newCellId !== op.cell_id) { if (newCellId !== op.cell_id) {
errors.push({ errors.push({
operation: "add", type: "add",
cellId: op.cell_id, cellId: op.cell_id,
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`, message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
}) })
@@ -136,11 +136,11 @@ function applyDiagramOperations(xmlContent, operations) {
const importedNode = doc.importNode(newCell, true) const importedNode = doc.importNode(newCell, true)
root.appendChild(importedNode) root.appendChild(importedNode)
cellMap.set(op.cell_id, importedNode) cellMap.set(op.cell_id, importedNode)
} else if (op.operation === "delete") { } else if (op.type === "delete") {
const existingCell = cellMap.get(op.cell_id) const existingCell = cellMap.get(op.cell_id)
if (!existingCell) { if (!existingCell) {
errors.push({ errors.push({
operation: "delete", type: "delete",
cellId: op.cell_id, cellId: op.cell_id,
message: `Cell with id="${op.cell_id}" not found`, message: `Cell with id="${op.cell_id}" not found`,
}) })
@@ -201,7 +201,7 @@ function assert(condition, message) {
test("Update operation changes cell value", () => { test("Update operation changes cell value", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "update", type: "update",
cell_id: "2", cell_id: "2",
new_xml: new_xml:
'<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>', '<mxCell id="2" value="Updated Box A" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
@@ -224,7 +224,7 @@ test("Update operation changes cell value", () => {
test("Update operation fails for non-existent cell", () => { test("Update operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "update", type: "update",
cell_id: "999", cell_id: "999",
new_xml: '<mxCell id="999" value="Test"/>', new_xml: '<mxCell id="999" value="Test"/>',
}, },
@@ -239,7 +239,7 @@ test("Update operation fails for non-existent cell", () => {
test("Update operation fails on ID mismatch", () => { test("Update operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "update", type: "update",
cell_id: "2", cell_id: "2",
new_xml: '<mxCell id="WRONG" value="Test"/>', new_xml: '<mxCell id="WRONG" value="Test"/>',
}, },
@@ -254,7 +254,7 @@ test("Update operation fails on ID mismatch", () => {
test("Add operation creates new cell", () => { test("Add operation creates new cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "add", type: "add",
cell_id: "new1", cell_id: "new1",
new_xml: new_xml:
'<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>', '<mxCell id="new1" value="New Box" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
@@ -274,7 +274,7 @@ test("Add operation creates new cell", () => {
test("Add operation fails for duplicate ID", () => { test("Add operation fails for duplicate ID", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "add", type: "add",
cell_id: "2", cell_id: "2",
new_xml: '<mxCell id="2" value="Duplicate"/>', new_xml: '<mxCell id="2" value="Duplicate"/>',
}, },
@@ -289,7 +289,7 @@ test("Add operation fails for duplicate ID", () => {
test("Add operation fails on ID mismatch", () => { test("Add operation fails on ID mismatch", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "add", type: "add",
cell_id: "new1", cell_id: "new1",
new_xml: '<mxCell id="WRONG" value="Test"/>', new_xml: '<mxCell id="WRONG" value="Test"/>',
}, },
@@ -303,7 +303,7 @@ test("Add operation fails on ID mismatch", () => {
test("Delete operation removes cell", () => { test("Delete operation removes cell", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ operation: "delete", cell_id: "3" }, { type: "delete", cell_id: "3" },
]) ])
assert( assert(
errors.length === 0, errors.length === 0,
@@ -315,7 +315,7 @@ test("Delete operation removes cell", () => {
test("Delete operation fails for non-existent cell", () => { test("Delete operation fails for non-existent cell", () => {
const { errors } = applyDiagramOperations(sampleXml, [ const { errors } = applyDiagramOperations(sampleXml, [
{ operation: "delete", cell_id: "999" }, { type: "delete", cell_id: "999" },
]) ])
assert(errors.length === 1, "Should have one error") assert(errors.length === 1, "Should have one error")
assert( assert(
@@ -327,18 +327,18 @@ test("Delete operation fails for non-existent cell", () => {
test("Multiple operations in sequence", () => { test("Multiple operations in sequence", () => {
const { result, errors } = applyDiagramOperations(sampleXml, [ const { result, errors } = applyDiagramOperations(sampleXml, [
{ {
operation: "update", type: "update",
cell_id: "2", cell_id: "2",
new_xml: new_xml:
'<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>', '<mxCell id="2" value="Updated" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>',
}, },
{ {
operation: "add", type: "add",
cell_id: "new1", cell_id: "new1",
new_xml: new_xml:
'<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>', '<mxCell id="new1" value="Added" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="500" y="100" width="120" height="60" as="geometry"/></mxCell>',
}, },
{ operation: "delete", cell_id: "3" }, { type: "delete", cell_id: "3" },
]) ])
assert( assert(
errors.length === 0, errors.length === 0,
@@ -354,14 +354,14 @@ test("Multiple operations in sequence", () => {
test("Invalid XML returns parse error", () => { test("Invalid XML returns parse error", () => {
const { errors } = applyDiagramOperations("<not valid xml", [ const { errors } = applyDiagramOperations("<not valid xml", [
{ operation: "delete", cell_id: "1" }, { type: "delete", cell_id: "1" },
]) ])
assert(errors.length === 1, "Should have one error") assert(errors.length === 1, "Should have one error")
}) })
test("Missing root element returns error", () => { test("Missing root element returns error", () => {
const { errors } = applyDiagramOperations("<mxfile></mxfile>", [ const { errors } = applyDiagramOperations("<mxfile></mxfile>", [
{ operation: "delete", cell_id: "1" }, { type: "delete", cell_id: "1" },
]) ])
assert(errors.length === 1, "Should have one error") assert(errors.length === 1, "Should have one error")
assert( assert(