Compare commits

...

15 Commits

Author SHA1 Message Date
Jinze Yu
9398117368 feat(i18n): add Traditional Chinese (zh-Hant) locale
Add full Traditional Chinese support for Hong Kong/Taiwan users by
creating a zh-Hant dictionary and registering the locale across the
web app, metadata, and Electron desktop menu system.
2026-01-27 13:22:33 +09:00
Dayuan Jiang
cb0c0fbcda Revert "fix(electron): prevent beforeunload prompt by using autosave (#642)" (#646)
This reverts commit e7c29fb410.

The PR introduced an IndexedDB error: 'Failed to execute transaction on IDBDatabase: The database connection is closing.'
2026-01-27 09:20:42 +09:00
Dayuan Jiang
e7c29fb410 fix(electron): prevent beforeunload prompt by using autosave (#642)
* fix(electron): prevent beforeunload prompt by using autosave

- Enable draw.io autosave and handle autosave events to update chartXML
- Clear modified state after autosave to avoid beforeunload prompts
- Disable confirmExit in draw.io configuration
- Set modified=false and keepmodified=false URL parameters
- Fix session save condition to also save when only diagram exists

* fix: persist diagram-only saves and ref typing

* fix: harden persistence checks and export timeout
2026-01-26 22:04:41 +09:00
Subhajeetch
f0dd199cd1 feat(prompt): add language-aware response rules with english fallback (#641)
* feat(prompt): add language-aware response rules with english fallback

Added language handling rules for user interactions.

* refactor(prompt): simplify language matching instruction
2026-01-26 15:32:07 +09:00
Dayuan Jiang
7656b64018 chore: switch to release signing policy for Windows builds (#639) 2026-01-24 20:47:44 +09:00
Dayuan Jiang
c3f88e54fe chore: bump version to 0.4.12 (#638) 2026-01-24 19:25:47 +09:00
Dayuan Jiang
a55ef7adf9 feat(electron): bundle draw.io for offline support (#629)
* feat(electron): bundle draw.io for offline support

- Download draw.io static files during CI build (v29.3.5)
- Detect Electron and use local draw.io files instead of CDN
- Add offline=1 parameter to disable external service calls
- Skip /drawio path from i18n middleware redirect
- Add public/drawio/ to .gitignore (downloaded during build)

This allows the Electron app to work completely offline.

* chore: bump version to 0.4.12-beta.3 for offline test

* fix(electron): ad-hoc sign macOS app for bundled draw.io compatibility

* chore: bump version to 0.4.12-beta.4 for ad-hoc sign test

* fix(electron): disable electron-builder signing to use custom ad-hoc signing

* chore: bump version to 0.4.12-beta.5
2026-01-24 12:47:51 +09:00
Dayuan Jiang
984eaae04d feat(mcp): update UI with project logo and modern dialog styles (#636)
* feat(mcp): update UI with project logo and modern dialog styles

- Replace Next.js logo with project favicon-white.svg
- Modernize modal dialogs with backdrop blur, animations, and DM Sans font
- Update button styles to match header design
- Change title to "Next AI Draw.io"
- Rename "Save" to "Download" for clarity
- Bump version to 0.1.15

* chore: remove dead code (unused download helpers and pendingManualSave)
2026-01-24 12:43:39 +09:00
Dayuan Jiang
0ed06360aa fix(mcp): auto-redirect to active session when no sessionId provided (#634)
Fixes #633 - browser showing infinite loading spinner when accessing
the MCP server URL without the session parameter.

When users access http://localhost:6002 directly (without ?mcp=xxx),
the page now auto-redirects to the most recent active session instead
of showing "No session" with an infinite loading spinner.
2026-01-24 11:03:58 +09:00
Biki Kalita
b0313eb2dc feat: implement collapsible view for extracted URL content in chat (#616)
* feat: implement collapsible view for extracted URL content in chat

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: simplify iconColor ternary by removing redundant url condition

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 10:44:11 +09:00
Dayuan Jiang
dc37ce7fb1 fix: upgrade wrangler to ^4.60.0 to address CVE-2026-0933 (#628)
Upgrade wrangler from 4.58.0 to ^4.60.0 to fix the command injection
vulnerability (CWE-78) in the --commit-hash parameter of wrangler pages
deploy command.

Fixes #627
2026-01-23 08:38:07 +09:00
Gideon Ayeni
0baa424bc4 enhancement: Add URL format hint to Base URL field (#593) (#603)
* enhancement: Add URL format hint to Base URL field (#593)

Add dynamic provider-specific format examples to Base URL field labels to clarify expected URL format. Includes missing defaultBaseUrl values for providers.

* fix: Use generic example URL instead of OpenAI URL for fallback
2026-01-20 22:39:33 +09:00
yujinze
afddba364b Add VLM-based diagram validation (#602)
* [Feature] Add VLM-based diagram validation

Add automatic VLM (Vision Language Model) validation after display_diagram
tool execution. The system captures a screenshot of the rendered diagram,
sends it to a VLM for visual analysis, and uses feedback to improve
diagram quality through the existing retry mechanism.

Changes:
- Add /api/validate-diagram endpoint for VLM validation
- Add diagram-validator.ts for client-side validation orchestration
- Add validation-prompts.ts for VLM system prompts
- Add ValidationCard component to display validation status in chat
- Add PNG capture functionality to diagram context
- Integrate validation into tool handlers with retry support (max 3)
- Add "Improve with Suggestions" button for manual regeneration
- Add settings toggle to enable/disable VLM validation
- Add getValidationModel() helper in ai-providers.ts

* refactor(validation): use AI SDK structured outputs and address review feedback

- Replace generateText + manual JSON parsing with generateObject and Zod schema
  for type-safe structured validation output
- Use AbortSignal.timeout() instead of Promise.race for cleaner timeout handling
- Add timeout validation with minimum 1000ms to handle malformed env values
- Remove unused xml parameter from validateRenderedDiagram API
- Remove parseValidationResponse function (now handled by schema)
- Clear validationStates on session switch and new chat to prevent memory leak
- Update 100ms render delay comment to clarify best-effort heuristic
- Remove unused useEffect import from ValidationCard
- Fix optional chaining lint warning in ValidationCard
- Add unit tests for formatValidationFeedback function

* refactor(validation): use AI SDK experimental_useObject hook instead of raw fetch

- Change API endpoint from generateObject to streamObject for useObject compatibility
- Create useValidateDiagram hook using AI SDK's experimental_useObject for reactive validation
- Update useDiagramToolHandlers to accept validation function as parameter
- Update chat-panel to use new useValidateDiagram hook
- Remove validateRenderedDiagram function from lib/diagram-validator.ts (now in hook)
- Export ValidationResultSchema from API route for client-side use

* fix(validation): extract schema to shared file for client/server compatibility

Move ValidationResultSchema to lib/validation-schema.ts to avoid importing
server-side modules (ai-providers) into client-side code. This fixes the
Turbopack build error caused by the hook importing from the API route.

* fix(validation): use 'Valid' instead of 'Complete' for validation success

Change ValidationCard success label from 'Complete' to 'Valid' to avoid
conflicting with ToolCallCard's 'Complete' badge in E2E tests. This fixes
the diagram-generation E2E test that expects a specific count of 'Complete'
badges.

* fix(validation): add aria-hidden to icons to prevent duplicate ID warning

* fix: improve VLM validation with bug fixes and i18n

- Fix race condition in pendingValidationRef (reject previous pending validation)
- Fix response format consistency (use streaming for all responses)
- Remove dead code (unused lastRequestRef and ValidationRequest interface)
- Consolidate duplicate types (re-export from validation-schema.ts)
- Add 'success_with_warnings' status for valid diagrams with warnings
- Fix tool card auto-collapse (only collapse once, respect user toggle)
- Set VLM validation default to disabled
- Add i18n support for diagram validation settings (en/zh/ja)
- Mark feature as experimental in settings UI

* fix: resolve TypeScript errors in electron-standalone

- Add forwardRef support to ChatInput component with ChatInputRef type
- Copy electron.d.ts to electron-standalone/electron folder
- Exclude electron-standalone from root tsconfig type checking

* fix: return empty string for valid result with no issues in formatValidationFeedback

* feat(i18n): add validation strings for ValidationCard component

- Add validation section to en.json, zh.json, ja.json dictionaries
- Update ValidationCard to use useDictionary hook
- Replace all hardcoded English strings with i18n keys

---------

Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
2026-01-20 20:52:04 +09:00
Dayuan Jiang
b386dc45e6 fix(quota): bypass quota for users with Bedrock credentials (#621)
* fix(quota): bypass quota for users with Bedrock credentials

The hasOwnApiKey check only looked for x-ai-api-key header, but Bedrock
users provide AWS credentials via x-aws-access-key-id instead. This
caused Bedrock users with their own credentials to still be subject to
quota limits.

* fix(quota): also bypass quota for Vertex AI users

* style: auto-format with Biome

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-20 20:27:31 +09:00
broBinChen
7b5a3075cf fix: replace ring with border for provider selection state (#617)
* fix: replace ring with border for provider selection state

* fix(settings): use auto width for send shortcut selector to prevent text truncation
2026-01-20 19:14:18 +09:00
42 changed files with 2686 additions and 1088 deletions

View File

@@ -34,6 +34,15 @@ jobs:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Download draw.io static files for offline use
run: |
rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
@@ -57,6 +66,16 @@ jobs:
node-version: 24 node-version: 24
cache: "npm" cache: "npm"
- name: Download draw.io static files for offline use
shell: bash
run: |
rm -rf public/drawio
git clone --depth 1 --branch v29.3.5 https://github.com/jgraph/drawio.git /tmp/drawio
mkdir -p public/drawio
cp -r /tmp/drawio/src/main/webapp/* public/drawio/
rm -rf public/drawio/WEB-INF
rm -rf public/drawio/META-INF
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install
@@ -80,7 +99,7 @@ jobs:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }} api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5' organization-id: '880a211d-2cd3-4e7b-8d04-3d1f8eb39df5'
project-slug: 'next-ai-draw-io' project-slug: 'next-ai-draw-io'
signing-policy-slug: 'test-signing' signing-policy-slug: 'release-signing'
artifact-configuration-slug: 'windows-exe' artifact-configuration-slug: 'windows-exe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }} github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
wait-for-completion: true wait-for-completion: true

2
.gitignore vendored
View File

@@ -56,6 +56,8 @@ push-via-ec2.sh
/dist-electron/ /dist-electron/
/release/ /release/
/electron-standalone/ /electron-standalone/
# Draw.io static files (downloaded during CI build)
public/drawio/
*.dmg *.dmg
*.exe *.exe
*.AppImage *.AppImage

View File

@@ -41,19 +41,24 @@ export async function generateMetadata({
params: Promise<{ lang: string }> params: Promise<{ lang: string }>
}): Promise<Metadata> { }): Promise<Metadata> {
const { lang: rawLang } = await params const { lang: rawLang } = await params
const lang = (rawLang in { en: 1, zh: 1, ja: 1 } ? rawLang : "en") as Locale const lang = (
rawLang in { en: 1, zh: 1, ja: 1, "zh-Hant": 1 } ? rawLang : "en"
) as Locale
// Default to English metadata // Default to English metadata
const titles: Record<Locale, string> = { const titles: Record<Locale, string> = {
en: "Next AI Draw.io - AI-Powered Diagram Generator", en: "Next AI Draw.io - AI-Powered Diagram Generator",
zh: "Next AI Draw.io - AI powered diagram generator", zh: "Next AI Draw.io - AI powered diagram generator",
ja: "Next AI Draw.io - AI-powered diagram generator", ja: "Next AI Draw.io - AI-powered diagram generator",
"zh-Hant": "Next AI Draw.io - AI 驅動的圖表產生器",
} }
const descriptions: Record<Locale, string> = { const descriptions: Record<Locale, string> = {
en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.", en: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Free online tool integrating draw.io with AI assistance for professional diagram creation.",
zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.", zh: "Use AI to create AWS architecture diagrams, flowcharts, and technical diagrams. Free online tool integrated with draw.io and AI assistance for professional diagram creation.",
ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.", ja: "Create AWS architecture diagrams, flowcharts, and technical diagrams using AI. Create professional diagrams with a free online tool that integrates draw.io with an AI assistant.",
"zh-Hant":
"使用 AI 建立 AWS 架構圖、流程圖和技術圖表。免費線上工具整合 draw.io 與 AI 輔助,輕鬆建立專業圖表。",
} }
return { return {
@@ -80,7 +85,14 @@ export async function generateMetadata({
type: "website", type: "website",
url: "https://next-ai-drawio.jiang.jp", url: "https://next-ai-drawio.jiang.jp",
siteName: "Next AI Draw.io", siteName: "Next AI Draw.io",
locale: lang === "zh" ? "zh_CN" : lang === "ja" ? "ja_JP" : "en_US", locale:
lang === "zh"
? "zh_CN"
: lang === "zh-Hant"
? "zh_HK"
: lang === "ja"
? "ja_JP"
: "en_US",
images: [ images: [
{ {
url: "/architecture.png", url: "/architecture.png",
@@ -115,6 +127,7 @@ export async function generateMetadata({
en: "/en", en: "/en",
zh: "/zh", zh: "/zh",
ja: "/ja", ja: "/ja",
"zh-Hant": "/zh-Hant",
}, },
}, },
} }

View File

@@ -12,9 +12,6 @@ import {
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
const drawioBaseUrl =
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
useDiagram() useDiagram()
@@ -28,6 +25,10 @@ export default function Home() {
const [darkMode, setDarkMode] = useState(false) const [darkMode, setDarkMode] = useState(false)
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
const isMobileRef = useRef(false) const isMobileRef = useRef(false)
@@ -64,6 +65,17 @@ export default function Home() {
document.documentElement.classList.toggle("dark", prefersDark) document.documentElement.classList.toggle("dark", prefersDark)
} }
// Detect Electron and use bundled draw.io files for offline use
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
// Include /index.html because Next.js doesn't auto-serve index.html for directories
const electronDetected =
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
!!(window as unknown as { electronAPI?: unknown }).electronAPI
if (electronDetected) {
setIsElectron(true)
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
}
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -160,7 +172,7 @@ export default function Home() {
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
> >
<DrawIoEmbed <DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}`} key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
@@ -174,6 +186,10 @@ export default function Home() {
noExitBtn: true, noExitBtn: true,
dark: darkMode, dark: darkMode,
lang: currentLang, lang: currentLang,
// Enable offline mode in Electron to disable external service calls
...(isElectron && {
offline: true,
}),
}} }}
/> />
</div> </div>

View File

@@ -118,7 +118,10 @@ async function handleChatRequest(req: Request): Promise<Response> {
// === SERVER-SIDE QUOTA CHECK START === // === SERVER-SIDE QUOTA CHECK START ===
// Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set // Quota is opt-in: only enabled when DYNAMODB_QUOTA_TABLE env var is set
const hasOwnApiKey = !!( const hasOwnApiKey = !!(
req.headers.get("x-ai-provider") && req.headers.get("x-ai-api-key") req.headers.get("x-ai-provider") &&
(req.headers.get("x-ai-api-key") ||
req.headers.get("x-aws-access-key-id") ||
req.headers.get("x-vertex-api-key"))
) )
// Skip quota check if: quota disabled, user has own API key, or is anonymous // Skip quota check if: quota disabled, user has own API key, or is anonymous

View File

@@ -0,0 +1,136 @@
/**
* API endpoint for VLM-based diagram validation.
* Accepts a PNG image and streams validation results using useObject-compatible format.
*/
import { streamObject } from "ai"
import { getValidationModel } from "@/lib/ai-providers"
import { VALIDATION_SYSTEM_PROMPT } from "@/lib/validation-prompts"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export const maxDuration = 30
interface ValidateDiagramRequest {
imageData: string // Base64 PNG data URL
sessionId?: string
}
// Default valid result for disabled/error cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
/**
* Create a streaming response for useObject compatibility.
* useObject expects text stream format, not plain JSON.
*/
function createStreamingResponse(result: ValidationResult): Response {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// Stream the JSON as text (useObject parses this)
controller.enqueue(encoder.encode(JSON.stringify(result)))
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
}
export async function POST(req: Request): Promise<Response> {
try {
// Check if VLM validation is enabled (default: true)
const enableValidation = process.env.ENABLE_VLM_VALIDATION !== "false"
if (!enableValidation) {
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
const body: ValidateDiagramRequest = await req.json()
const { imageData, sessionId } = body
if (!imageData) {
return Response.json(
{ error: "Missing imageData" },
{ status: 400 },
)
}
// Validate image data format
if (
!imageData.startsWith("data:image/png;base64,") &&
!imageData.startsWith("data:image/")
) {
return Response.json(
{ error: "Invalid image data format" },
{ status: 400 },
)
}
// Get the validation model
let model
try {
model = getValidationModel()
} catch (error) {
console.warn(
"[validate-diagram] Validation model not available:",
error,
)
// Return valid if no vision model is configured
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
// Parse timeout with validation (minimum 1000ms, default 10000ms)
const timeout =
Math.max(
1000,
parseInt(process.env.VALIDATION_TIMEOUT || "10000", 10),
) || 10000
// Stream the VLM response for useObject consumption
const result = streamObject({
model,
schema: ValidationResultSchema,
system: VALIDATION_SYSTEM_PROMPT,
messages: [
{
role: "user",
content: [
{
type: "image",
image: imageData,
},
{
type: "text",
text: "Please analyze this diagram for visual quality issues.",
},
],
},
],
maxOutputTokens: 1024,
abortSignal: AbortSignal.timeout(timeout),
onFinish: ({ object }) => {
if (sessionId && object) {
console.log(
`[validate-diagram] Session ${sessionId}: valid=${object.valid}, issues=${object.issues?.length ?? 0}`,
)
}
},
})
return result.toTextStreamResponse()
} catch (error) {
// Log with session context if available
const errorMessage =
error instanceof Error ? error.message : String(error)
console.error("[validate-diagram] Error:", errorMessage)
// On error, return valid to not block the user
return createStreamingResponse(DEFAULT_VALID_RESULT)
}
}

View File

@@ -9,7 +9,14 @@ import {
Send, Send,
} from "lucide-react" } from "lucide-react"
import type React from "react" import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react" import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast" import { ErrorToast } from "@/components/error-toast"
@@ -138,6 +145,10 @@ function showValidationErrors(errors: string[], dict: any) {
} }
} }
export interface ChatInputRef {
focus: () => void
}
interface ChatInputProps { interface ChatInputProps {
input: string input: string
status: "submitted" | "streaming" | "ready" | "error" status: "submitted" | "streaming" | "ready" | "error"
@@ -165,134 +176,212 @@ interface ChatInputProps {
onFocused?: () => void onFocused?: () => void
} }
export function ChatInput({ export const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
input, function ChatInput(
status, {
onSubmit, input,
onChange, status,
files = [], onSubmit,
onFileChange = () => {}, onChange,
pdfData = new Map(), files = [],
urlData, onFileChange = () => {},
onUrlChange, pdfData = new Map(),
sessionId, urlData,
error = null, onUrlChange,
models = [], sessionId,
selectedModelId, error = null,
onModelSelect = () => {}, models = [],
showUnvalidatedModels = false, selectedModelId,
onConfigureModels = () => {}, onModelSelect = () => {},
shouldFocus = false, showUnvalidatedModels = false,
onFocused, onConfigureModels = () => {},
}: ChatInputProps) { shouldFocus = false,
const dict = useDictionary() onFocused,
const { },
chartXML, ref,
diagramHistory, ) {
saveDiagramToFile, const dict = useDictionary()
showSaveDialog, const {
setShowSaveDialog, chartXML,
} = useDiagram() diagramHistory,
saveDiagramToFile,
showSaveDialog,
setShowSaveDialog,
} = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
// Focus the textarea when shouldFocus becomes true // Expose focus method via ref
// Use setTimeout to ensure focus happens after drawio iframe settles useImperativeHandle(ref, () => ({
useEffect(() => { focus: () => {
if (shouldFocus) {
const timer = setTimeout(() => {
textareaRef.current?.focus() textareaRef.current?.focus()
onFocused?.() },
}, 150) }))
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const [showHistory, setShowHistory] = useState(false) // Focus the textarea when shouldFocus becomes true
const [showUrlDialog, setShowUrlDialog] = useState(false) // Use setTimeout to ensure focus happens after drawio iframe settles
const [isExtractingUrl, setIsExtractingUrl] = useState(false) useEffect(() => {
const [sendShortcut, setSendShortcut] = useState("ctrl-enter") if (shouldFocus) {
// Allow retry when there's an error (even if status is still "streaming" or "submitted") const timer = setTimeout(() => {
const isDisabled = textareaRef.current?.focus()
(status === "streaming" || status === "submitted") && !error onFocused?.()
}, 150)
return () => clearTimeout(timer)
}
}, [shouldFocus, onFocused])
const adjustTextareaHeight = useCallback(() => { const [showHistory, setShowHistory] = useState(false)
const textarea = textareaRef.current const [showUrlDialog, setShowUrlDialog] = useState(false)
if (textarea) { const [isExtractingUrl, setIsExtractingUrl] = useState(false)
textarea.style.height = "auto" const [sendShortcut, setSendShortcut] = useState("ctrl-enter")
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` // Allow retry when there's an error (even if status is still "streaming" or "submitted")
} const isDisabled =
}, []) (status === "streaming" || status === "submitted") && !error
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
// Load send shortcut preference from localStorage and listen for changes const adjustTextareaHeight = useCallback(() => {
useEffect(() => { const textarea = textareaRef.current
const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut) if (textarea) {
if (stored) setSendShortcut(stored) textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, [])
// Handle programmatic input changes (e.g., setInput("") after form submission)
useEffect(() => {
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
const handleChange = (e: CustomEvent<string>) => // Load send shortcut preference from localStorage and listen for changes
setSendShortcut(e.detail) useEffect(() => {
window.addEventListener( const stored = localStorage.getItem(STORAGE_KEYS.sendShortcut)
"sendShortcutChange", if (stored) setSendShortcut(stored)
handleChange as EventListener,
) const handleChange = (e: CustomEvent<string>) =>
return () => setSendShortcut(e.detail)
window.removeEventListener( window.addEventListener(
"sendShortcutChange", "sendShortcutChange",
handleChange as EventListener, handleChange as EventListener,
) )
}, []) return () =>
window.removeEventListener(
"sendShortcutChange",
handleChange as EventListener,
)
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e) onChange(e)
adjustTextareaHeight() adjustTextareaHeight()
} }
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
const shouldSend = const shouldSend =
sendShortcut === "enter" sendShortcut === "enter"
? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey ? e.key === "Enter" &&
: (e.metaKey || e.ctrlKey) && e.key === "Enter" !e.shiftKey &&
!e.ctrlKey &&
!e.metaKey
: (e.metaKey || e.ctrlKey) && e.key === "Enter"
if (shouldSend) { if (shouldSend) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget.closest("form") const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) { if (form && input.trim() && !isDisabled) {
form.requestSubmit() form.requestSubmit()
}
} }
} }
}
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return if (isDisabled) return
const items = e.clipboardData.items const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) => const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/"), item.type.startsWith("image/"),
) )
if (imageItems.length > 0) { if (imageItems.length > 0) {
const imageFiles = ( const imageFiles = (
await Promise.all( await Promise.all(
imageItems.map(async (item, index) => { imageItems.map(async (item, index) => {
const file = item.getAsFile() const file = item.getAsFile()
if (!file) return null if (!file) return null
return new File( return new File(
[file], [file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type }, { type: file.type },
) )
}), }),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
dict,
) )
).filter((f): f is File => f !== null) showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const triggerFileInput = () => {
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return
const droppedFiles = e.dataTransfer.files
const supportedFiles = Array.from(droppedFiles).filter((file) =>
isValidFileType(file),
)
const { validFiles, errors } = validateFiles( const { validFiles, errors } = validateFiles(
imageFiles, supportedFiles,
files.length, files.length,
dict, dict,
) )
@@ -301,278 +390,219 @@ export function ChatInput({
onFileChange([...files, ...validFiles]) onFileChange([...files, ...validFiles])
} }
} }
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleUrlExtract = async (url: string) => {
const newFiles = Array.from(e.target.files || []) if (!onUrlChange) return
const { validFiles, errors } = validateFiles(
newFiles, setIsExtractingUrl(true)
files.length,
dict, try {
) const existing = urlData
showValidationErrors(errors, dict) ? new Map(urlData)
if (validFiles.length > 0) { : new Map<string, UrlData>()
onFileChange([...files, ...validFiles]) existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
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)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
} }
if (fileInputRef.current) { return (
fileInputRef.current.value = "" <form
} onSubmit={onSubmit}
} className={`w-full transition-all duration-200 ${
isDragging
const handleRemoveFile = (fileToRemove: File) => { ? "ring-2 ring-primary ring-offset-2 rounded-2xl"
onFileChange(files.filter((file) => file !== fileToRemove)) : ""
if (fileInputRef.current) { }`}
fileInputRef.current.value = "" onDragOver={handleDragOver}
} onDragLeave={handleDragLeave}
} onDrop={handleDrop}
>
const triggerFileInput = () => { {/* File & URL previews */}
fileInputRef.current?.click() {(files.length > 0 || (urlData && urlData.size > 0)) && (
} <div className="mb-3">
<FilePreviewList
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => { files={files}
e.preventDefault() onRemoveFile={handleRemoveFile}
e.stopPropagation() pdfData={pdfData}
setIsDragging(true) urlData={urlData}
} onRemoveUrl={
onUrlChange
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => { ? (url) => {
e.preventDefault() const next = new Map(urlData)
e.stopPropagation() next.delete(url)
setIsDragging(false) onUrlChange(next)
} }
: undefined
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => { }
e.preventDefault() />
e.stopPropagation() </div>
setIsDragging(false) )}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
if (isDisabled) return <Textarea
ref={textareaRef}
const droppedFiles = e.dataTransfer.files value={input}
const supportedFiles = Array.from(droppedFiles).filter((file) => onChange={handleChange}
isValidFileType(file), onKeyDown={handleKeyDown}
) onPaste={handlePaste}
placeholder={dict.chat.placeholder}
const { validFiles, errors } = validateFiles( disabled={isDisabled}
supportedFiles, aria-label="Chat input"
files.length, className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
dict,
)
showValidationErrors(errors, dict)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
}
}
const handleUrlExtract = async (url: string) => {
if (!onUrlChange) return
setIsExtractingUrl(true)
try {
const existing = urlData
? new Map(urlData)
: new Map<string, UrlData>()
existing.set(url, {
url,
title: url,
content: "",
charCount: 0,
isExtracting: true,
})
onUrlChange(existing)
const data = await extractUrlContent(url)
const newUrlData = new Map(existing)
newUrlData.set(url, data)
onUrlChange(newUrlData)
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)
showErrorToast(
<span className="text-muted-foreground">
{error instanceof Error
? error.message
: "Failed to extract URL content"}
</span>,
)
} finally {
setIsExtractingUrl(false)
}
}
return (
<form
onSubmit={onSubmit}
className={`w-full transition-all duration-200 ${
isDragging
? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: ""
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* File & URL previews */}
{(files.length > 0 || (urlData && urlData.size > 0)) && (
<div className="mb-3">
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
pdfData={pdfData}
urlData={urlData}
onRemoveUrl={
onUrlChange
? (url) => {
const next = new Map(urlData)
next.delete(url)
onUrlChange(next)
}
: undefined
}
/> />
</div>
)}
<div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
<Textarea
ref={textareaRef}
value={input}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={dict.chat.placeholder}
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60 scrollbar-thin"
/>
<div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50"> <div className="flex items-center justify-end gap-1 px-3 py-2 border-t border-border/50">
<div className="flex items-center gap-1 overflow-x-hidden"> <div className="flex items-center gap-1 overflow-x-hidden">
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled || !isRealDiagram(chartXML)}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip <ButtonWithTooltip
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowUrlDialog(true)} onClick={() => setShowHistory(true)}
disabled={isDisabled} disabled={
tooltipContent={dict.chat.ExtractURL} isDisabled || diagramHistory.length === 0
}
tooltipContent={dict.chat.diagramHistory}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
> >
<Link className="h-4 w-4" /> <History className="h-4 w-4" />
</ButtonWithTooltip> </ButtonWithTooltip>
)}
<input <ButtonWithTooltip
type="file" type="button"
ref={fileInputRef} variant="ghost"
className="hidden" size="sm"
onChange={handleFileChange} onClick={() => setShowSaveDialog(true)}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml" disabled={
multiple isDisabled || !isRealDiagram(chartXML)
}
tooltipContent={dict.chat.saveDiagram}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent={dict.chat.uploadFile}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
{onUrlChange && (
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUrlDialog(true)}
disabled={isDisabled}
tooltipContent={dict.chat.ExtractURL}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Link className="h-4 w-4" />
</ButtonWithTooltip>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*,.pdf,application/pdf,text/*,.md,.markdown,.json,.csv,.xml,.yaml,.yml,.toml"
multiple
disabled={isDisabled}
/>
</div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled} disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/> />
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div> </div>
<ModelSelector
models={models}
selectedModelId={selectedModelId}
onSelect={onModelSelect}
onConfigure={onConfigureModels}
disabled={isDisabled}
showUnvalidatedModels={showUnvalidatedModels}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={
isDisabled ? dict.chat.sending : dict.chat.send
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
{dict.chat.send}
</>
)}
</Button>
</div> </div>
</div> <HistoryDialog
<HistoryDialog showHistory={showHistory}
showHistory={showHistory} onToggleHistory={setShowHistory}
onToggleHistory={setShowHistory}
/>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/> />
)} <SaveDialog
</form> open={showSaveDialog}
) onOpenChange={setShowSaveDialog}
} onSave={(filename, format) =>
saveDiagramToFile(
filename,
format,
sessionId,
dict.save.savedSuccessfully,
)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
{onUrlChange && (
<UrlInputDialog
open={showUrlDialog}
onOpenChange={setShowUrlDialog}
onSubmit={handleUrlExtract}
isExtracting={isExtractingUrl}
/>
)}
</form>
)
},
)

View File

@@ -9,6 +9,7 @@ import {
Copy, Copy,
FileCode, FileCode,
FileText, FileText,
Link,
Pencil, Pencil,
RotateCcw, RotateCcw,
ThumbsDown, ThumbsDown,
@@ -28,6 +29,8 @@ import {
import { ChatLobby } from "@/components/chat/ChatLobby" import { ChatLobby } from "@/components/chat/ChatLobby"
import { ToolCallCard } from "@/components/chat/ToolCallCard" import { ToolCallCard } from "@/components/chat/ToolCallCard"
import type { DiagramOperation, ToolPartLike } from "@/components/chat/types" import type { DiagramOperation, ToolPartLike } from "@/components/chat/types"
import type { ValidationState } from "@/components/chat/ValidationCard"
import { ValidationCard } from "@/components/chat/ValidationCard"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
@@ -57,20 +60,20 @@ function getCompleteOperations(
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
// Helper to split text content into regular text and file sections (PDF or text files) // Helper to split text content into regular text and file/URL sections (PDF, text files, or URLs)
interface TextSection { interface TextSection {
type: "text" | "file" type: "text" | "file" | "url"
content: string content: string
filename?: string filename?: string
charCount?: number charCount?: number
fileType?: "pdf" | "text" fileType?: "pdf" | "text" | "url"
} }
function splitTextIntoFileSections(text: string): TextSection[] { function splitTextIntoFileSections(text: string): TextSection[] {
const sections: TextSection[] = [] const sections: TextSection[] = []
// Match [PDF: filename] or [File: filename] patterns // Match [PDF: filename], [File: filename], or [URL: url] patterns
const filePattern = const filePattern =
/\[(PDF|File):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File):|$)/g /\[(PDF|File|URL):\s*([^\]]+)\]\n([\s\S]*?)(?=\n\n\[(PDF|File|URL):|$)/g
let lastIndex = 0 let lastIndex = 0
let match let match
@@ -81,28 +84,34 @@ function splitTextIntoFileSections(text: string): TextSection[] {
sections.push({ type: "text", content: beforeText }) sections.push({ type: "text", content: beforeText })
} }
// Add file section // Add file/url section
const fileType = match[1].toLowerCase() === "pdf" ? "pdf" : "text" const sectionType = match[1].toLowerCase()
const fileType =
sectionType === "pdf"
? "pdf"
: sectionType === "url"
? "url"
: "text"
const filename = match[2].trim() const filename = match[2].trim()
const fileContent = match[3].trim() const content = match[3].trim()
sections.push({ sections.push({
type: "file", type: sectionType === "url" ? "url" : "file",
content: fileContent, content: content,
filename, filename,
charCount: fileContent.length, charCount: content.length,
fileType, fileType,
}) })
lastIndex = match.index + match[0].length lastIndex = match.index + match[0].length
} }
// Add remaining text after last file section // Add remaining text after last section
const remainingText = text.slice(lastIndex).trim() const remainingText = text.slice(lastIndex).trim()
if (remainingText) { if (remainingText) {
sections.push({ type: "text", content: remainingText }) sections.push({ type: "text", content: remainingText })
} }
// If no file sections found, return original text // If no file/url sections found, return original text
if (sections.length === 0) { if (sections.length === 0) {
sections.push({ type: "text", content: text }) sections.push({ type: "text", content: text })
} }
@@ -121,8 +130,8 @@ const getMessageTextContent = (message: UIMessage): string => {
// Get only the user's original text, excluding appended file content // Get only the user's original text, excluding appended file content
const getUserOriginalText = (message: UIMessage): string => { const getUserOriginalText = (message: UIMessage): string => {
const fullText = getMessageTextContent(message) const fullText = getMessageTextContent(message)
// Strip out [PDF: ...] and [File: ...] sections that were appended // Strip out [PDF: ...], [File: ...], and [URL: ...] sections that were appended
const filePattern = /\n\n\[(PDF|File):\s*[^\]]+\]\n[\s\S]*$/ const filePattern = /\n\n\[(PDF|File|URL):\s*[^\]]+\]\n[\s\S]*$/
return fullText.replace(filePattern, "").trim() return fullText.replace(filePattern, "").trim()
} }
@@ -148,6 +157,8 @@ interface ChatMessageDisplayProps {
onSelectSession?: (id: string) => void onSelectSession?: (id: string) => void
onDeleteSession?: (id: string) => void onDeleteSession?: (id: string) => void
loadedMessageIdsRef?: MutableRefObject<Set<string>> loadedMessageIdsRef?: MutableRefObject<Set<string>>
validationStates?: Record<string, ValidationState>
onImproveWithSuggestions?: (feedback: string) => void
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -165,6 +176,8 @@ export function ChatMessageDisplay({
onSelectSession, onSelectSession,
onDeleteSession, onDeleteSession,
loadedMessageIdsRef, loadedMessageIdsRef,
validationStates = {},
onImproveWithSuggestions,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
@@ -429,11 +442,15 @@ export function ChatMessageDisplay({
const toolPart = part as ToolPartLike const toolPart = part as ToolPartLike
const { toolCallId, state, input } = toolPart const { toolCallId, state, input } = toolPart
// Auto-collapse on completion, but only if user hasn't manually toggled
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => ({ setExpandedTools((prev) => {
...prev, // Only auto-collapse if not already set (user hasn't interacted)
[toolCallId]: false, if (prev[toolCallId] === undefined) {
})) return { ...prev, [toolCallId]: false }
}
return prev
})
} }
if ( if (
@@ -911,30 +928,56 @@ export function ChatMessageDisplay({
return groups.map( return groups.map(
(group, groupIndex) => { (group, groupIndex) => {
if (group.type === "tool") { if (group.type === "tool") {
const toolPart = group
.parts[0] as ToolPartLike
const toolCallId =
toolPart.toolCallId
const isDisplayDiagram =
toolPart.type ===
"tool-display_diagram"
const validationState =
validationStates[
toolCallId
]
return ( return (
<ToolCallCard <div
key={`${message.id}-tool-${group.startIndex}`} key={`${message.id}-tool-${group.startIndex}`}
part={ >
group <ToolCallCard
.parts[0] as ToolPartLike part={
} toolPart
expandedTools={ }
expandedTools expandedTools={
} expandedTools
setExpandedTools={ }
setExpandedTools setExpandedTools={
} setExpandedTools
onCopy={ }
copyMessageToClipboard onCopy={
} copyMessageToClipboard
copiedToolCallId={ }
copiedToolCallId copiedToolCallId={
} copiedToolCallId
copyFailedToolCallId={ }
copyFailedToolCallId copyFailedToolCallId={
} copyFailedToolCallId
dict={dict} }
/> dict={dict}
/>
{/* Show validation card for display_diagram tools */}
{isDisplayDiagram &&
validationState && (
<ValidationCard
state={
validationState
}
onImproveWithSuggestions={
onImproveWithSuggestions
}
/>
)}
</div>
) )
} }
@@ -1048,12 +1091,14 @@ export function ChatMessageDisplay({
) => { ) => {
if ( if (
section.type === section.type ===
"file" "file" ||
section.type ===
"url"
) { ) {
const pdfKey = `${message.id}-file-${partIndex}-${sectionIndex}` const sectionKey = `${message.id}-${section.type}-${partIndex}-${sectionIndex}`
const isExpanded = const isExpanded =
expandedPdfSections[ expandedPdfSections[
pdfKey sectionKey
] ?? ] ??
false false
const charDisplay = const charDisplay =
@@ -1062,10 +1107,27 @@ export function ChatMessageDisplay({
1000 1000
? `${(section.charCount / 1000).toFixed(1)}k` ? `${(section.charCount / 1000).toFixed(1)}k`
: section.charCount : section.charCount
// Icon selector
const Icon =
section.fileType ===
"pdf"
? FileText
: section.fileType ===
"url"
? Link
: FileCode
const iconColor =
section.fileType ===
"pdf"
? "text-red-500"
: "text-blue-700"
return ( return (
<div <div
key={ key={
pdfKey sectionKey
} }
className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden" className="rounded-lg border border-border/60 bg-muted/30 overflow-hidden"
> >
@@ -1080,7 +1142,7 @@ export function ChatMessageDisplay({
prev, prev,
) => ({ ) => ({
...prev, ...prev,
[pdfKey]: [sectionKey]:
!isExpanded, !isExpanded,
}), }),
) )
@@ -1088,13 +1150,10 @@ export function ChatMessageDisplay({
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors" className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{section.fileType === <Icon
"pdf" ? ( className={`h-4 w-4 ${iconColor}`}
<FileText className="h-4 w-4 text-red-500" /> />
) : ( <span className="text-xs font-medium truncate max-w-[200px]">
<FileCode className="h-4 w-4 text-blue-500" />
)}
<span className="text-xs font-medium">
{ {
section.filename section.filename
} }

View File

@@ -29,15 +29,18 @@ import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers"
import { useDictionary } from "@/hooks/use-dictionary" import { useDictionary } from "@/hooks/use-dictionary"
import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config"
import { useSessionManager } from "@/hooks/use-session-manager" import { useSessionManager } from "@/hooks/use-session-manager"
import { useValidateDiagram } from "@/hooks/use-validate-diagram"
import { getApiEndpoint } from "@/lib/base-path" import { getApiEndpoint } from "@/lib/base-path"
import { findCachedResponse } from "@/lib/cached-responses" import { findCachedResponse } from "@/lib/cached-responses"
import { formatMessage } from "@/lib/i18n/utils" import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { sanitizeMessages } from "@/lib/session-storage" import { sanitizeMessages } from "@/lib/session-storage"
import { STORAGE_KEYS } from "@/lib/storage"
import type { UrlData } from "@/lib/url-utils" import type { UrlData } from "@/lib/url-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor" import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager" import { useQuotaManager } from "@/lib/use-quota-manager"
import { cn, formatXML, isRealDiagram } from "@/lib/utils" import { cn, formatXML, isRealDiagram } from "@/lib/utils"
import type { ValidationState } from "./chat/ValidationCard"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator" import { DevXmlSimulator } from "./dev-xml-simulator"
@@ -75,7 +78,8 @@ interface ChatPanelProps {
// Constants for tool states // Constants for tool states
const TOOL_ERROR_STATE = "output-error" as const const TOOL_ERROR_STATE = "output-error" as const
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
const MAX_AUTO_RETRY_COUNT = 1 // Increased to 3 to support VLM validation retries (matches MAX_VALIDATION_RETRIES)
const MAX_AUTO_RETRY_COUNT = 3
const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries const MAX_CONTINUATION_RETRY_COUNT = 2 // Limit for truncation continuation retries
@@ -120,6 +124,7 @@ export default function ChatPanel({
latestSvg, latestSvg,
clearDiagram, clearDiagram,
getThumbnailSvg, getThumbnailSvg,
captureValidationPng,
diagramHistory, diagramHistory,
setDiagramHistory, setDiagramHistory,
} = useDiagram() } = useDiagram()
@@ -173,6 +178,7 @@ export default function ChatPanel({
const [dailyTokenLimit, setDailyTokenLimit] = useState(0) const [dailyTokenLimit, setDailyTokenLimit] = useState(0)
const [tpmLimit, setTpmLimit] = useState(0) const [tpmLimit, setTpmLimit] = useState(0)
const [minimalStyle, setMinimalStyle] = useState(false) const [minimalStyle, setMinimalStyle] = useState(false)
const [vlmValidationEnabled, setVlmValidationEnabled] = useState(false)
const [shouldFocusInput, setShouldFocusInput] = useState(false) const [shouldFocusInput, setShouldFocusInput] = useState(false)
// Restore input from sessionStorage on mount (when ChatPanel remounts due to key change) // Restore input from sessionStorage on mount (when ChatPanel remounts due to key change)
@@ -183,6 +189,14 @@ export default function ChatPanel({
} }
}, []) }, [])
// Load VLM validation setting from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.vlmValidationEnabled)
if (stored !== null) {
setVlmValidationEnabled(stored === "true")
}
}, [])
// Check config on mount // Check config on mount
useEffect(() => { useEffect(() => {
fetch(getApiEndpoint("/api/config")) fetch(getApiEndpoint("/api/config"))
@@ -270,6 +284,46 @@ export default function ChatPanel({
> | null>(null) > | null>(null)
const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second const LOCAL_STORAGE_DEBOUNCE_MS = 1000 // Save at most once per second
// Validation state for displaying VLM validation progress
// Key: toolCallId, Value: ValidationState
const [validationStates, setValidationStates] = useState<
Record<string, ValidationState>
>({})
// Callback to update validation state from tool handler
const handleValidationStateChange = useCallback(
(toolCallId: string, state: ValidationState) => {
setValidationStates((prev) => ({
...prev,
[toolCallId]: state,
}))
},
[],
)
// Handler for VLM validation setting change
const handleVlmValidationChange = useCallback((value: boolean) => {
setVlmValidationEnabled(value)
localStorage.setItem(STORAGE_KEYS.vlmValidationEnabled, String(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 }],
})
}
}, [])
// VLM validation hook using AI SDK's useObject
const { validateWithFallback } = useValidateDiagram()
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram) // Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
const { handleToolCall } = useDiagramToolHandlers({ const { handleToolCall } = useDiagramToolHandlers({
partialXmlRef, partialXmlRef,
@@ -278,6 +332,11 @@ export default function ChatPanel({
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
onExport, onExport,
captureValidationPng,
validateDiagram: validateWithFallback,
enableVlmValidation: vlmValidationEnabled,
sessionId,
onValidationStateChange: handleValidationStateChange,
}) })
const { messages, sendMessage, addToolOutput, status, error, setMessages } = const { messages, sendMessage, addToolOutput, status, error, setMessages } =
@@ -426,6 +485,11 @@ export default function ChatPanel({
}, },
}) })
// Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions)
useEffect(() => {
sendMessageRef.current = sendMessage
}, [sendMessage])
// Ref to track latest messages for unload persistence // Ref to track latest messages for unload persistence
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
useEffect(() => { useEffect(() => {
@@ -820,6 +884,7 @@ export default function ChatPanel({
} else { } else {
justLoadedSessionIdRef.current = null justLoadedSessionIdRef.current = null
} }
setValidationStates({}) // Clear validation states when switching sessions
syncUIWithSession(sessionData) syncUIWithSession(sessionData)
router.replace(`?session=${sessionId}`, { scroll: false }) router.replace(`?session=${sessionId}`, { scroll: false })
} }
@@ -860,6 +925,7 @@ export default function ChatPanel({
setInput("") setInput("")
clearDiagram() clearDiagram()
setDiagramHistory([]) setDiagramHistory([])
setValidationStates({}) // Clear validation states to prevent memory leak
handleFileChange([]) // Use handleFileChange to also clear pdfData handleFileChange([]) // Use handleFileChange to also clear pdfData
setUrlData(new Map()) setUrlData(new Map())
const newSessionId = `session-${Date.now()}-${Math.random() const newSessionId = `session-${Date.now()}-${Math.random()
@@ -1266,6 +1332,8 @@ export default function ChatPanel({
onSelectSession={handleSelectSession} onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession} onDeleteSession={handleDeleteSession}
loadedMessageIdsRef={loadedMessageIdsRef} loadedMessageIdsRef={loadedMessageIdsRef}
validationStates={validationStates}
onImproveWithSuggestions={handleImproveWithSuggestions}
/> />
</main> </main>
@@ -1315,6 +1383,8 @@ export default function ChatPanel({
onToggleDarkMode={onToggleDarkMode} onToggleDarkMode={onToggleDarkMode}
minimalStyle={minimalStyle} minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle} onMinimalStyleChange={setMinimalStyle}
vlmValidationEnabled={vlmValidationEnabled}
onVlmValidationChange={handleVlmValidationChange}
/> />
<ModelConfigDialog <ModelConfigDialog

View File

@@ -67,8 +67,8 @@ export function ToolCallCard({
}: ToolCallCardProps) { }: ToolCallCardProps) {
const callId = part.toolCallId const callId = part.toolCallId
const { state, input, output } = part const { state, input, output } = part
// Default to collapsed if tool is complete, expanded if still streaming // Default to expanded for all states (user can manually collapse if needed)
const isExpanded = expandedTools[callId] ?? state !== "output-available" const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "") const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId const isCopied = copiedToolCallId === callId

View File

@@ -0,0 +1,328 @@
"use client"
import {
AlertTriangle,
Check,
ChevronDown,
ChevronUp,
Eye,
ImageIcon,
RefreshCw,
X,
} from "lucide-react"
import Image from "next/image"
import { useState } from "react"
import { useDictionary } from "@/hooks/use-dictionary"
import type { ValidationResult } from "@/lib/diagram-validator"
export type ValidationStatus =
| "idle"
| "capturing"
| "validating"
| "success"
| "success_with_warnings"
| "failed"
| "error"
| "skipped"
export interface ValidationState {
status: ValidationStatus
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string // Base64 PNG data URL
}
interface ValidationCardProps {
state: ValidationState
onImproveWithSuggestions?: (feedback: string) => void
}
export function ValidationCard({
state,
onImproveWithSuggestions,
}: ValidationCardProps) {
const dict = useDictionary()
const [isExpanded, setIsExpanded] = useState(
state.status === "validating" || state.status === "failed",
)
const [hasRequestedImprovement, setHasRequestedImprovement] =
useState(false)
// Generate improvement feedback from validation result
const generateImprovementFeedback = (): string => {
if (!state.result) return ""
const lines: string[] = []
lines.push(
"Please improve the diagram based on the following visual analysis feedback:",
)
lines.push("")
if (state.result.issues.length > 0) {
lines.push("Issues to address:")
for (const issue of state.result.issues) {
lines.push(
` - [${issue.severity}] ${issue.type}: ${issue.description}`,
)
}
lines.push("")
}
if (state.result.suggestions.length > 0) {
lines.push("Suggestions for improvement:")
for (const suggestion of state.result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push("Regenerate the diagram with these improvements applied.")
return lines.join("\n")
}
const handleImproveClick = () => {
if (
!onImproveWithSuggestions ||
!state.result ||
hasRequestedImprovement
)
return
setHasRequestedImprovement(true)
const feedback = generateImprovementFeedback()
onImproveWithSuggestions(feedback)
}
// Check if we should show the improve button
const showImproveButton =
onImproveWithSuggestions &&
state.result &&
(state.status === "success" ||
state.status === "success_with_warnings" ||
state.status === "skipped") &&
(state.result.issues.length > 0 || state.result.suggestions.length > 0)
const getStatusDisplay = () => {
switch (state.status) {
case "capturing":
return {
label: dict.validation.capturing,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "validating":
return {
label: state.attempt
? dict.validation.validatingWithAttempt
.replace("{attempt}", String(state.attempt))
.replace("{max}", String(state.maxAttempts || 3))
: dict.validation.validating,
color: "text-blue-600 bg-blue-50",
icon: (
<div className="h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
),
}
case "success":
return {
label: dict.validation.valid,
color: "text-green-600 bg-green-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
case "success_with_warnings":
return {
label: dict.validation.validWithWarnings,
color: "text-amber-600 bg-amber-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "failed":
return {
label: dict.validation.issuesFound,
color: "text-yellow-600 bg-yellow-50",
icon: (
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
),
}
case "error":
return {
label: dict.validation.error,
color: "text-red-600 bg-red-50",
icon: <X className="h-4 w-4" aria-hidden="true" />,
}
case "skipped":
return {
label: dict.validation.skipped,
color: "text-gray-600 bg-gray-50",
icon: <Check className="h-4 w-4" aria-hidden="true" />,
}
default:
return null
}
}
const statusDisplay = getStatusDisplay()
if (!statusDisplay || state.status === "idle") return null
return (
<div className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Eye
className="w-3.5 h-3.5 text-primary"
aria-hidden="true"
/>
</div>
<span className="text-sm font-medium text-foreground/80">
{dict.validation.title}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full flex items-center gap-1 ${statusDisplay.color}`}
>
{statusDisplay.icon}
<span className="ml-1">{statusDisplay.label}</span>
</span>
{(state.result || state.error) && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 rounded hover:bg-muted transition-colors"
>
{isExpanded ? (
<ChevronUp
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
) : (
<ChevronDown
className="w-4 h-4 text-muted-foreground"
aria-hidden="true"
/>
)}
</button>
)}
</div>
</div>
{/* Validation details when expanded */}
{isExpanded && (state.result || state.imageData) && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/20 space-y-3">
{/* Captured image */}
{state.imageData && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2 flex items-center gap-1">
<ImageIcon
className="h-3 w-3"
aria-hidden="true"
/>
{dict.validation.capturedScreenshot}
</div>
<div className="rounded-lg border border-border/50 overflow-hidden bg-white">
<Image
src={state.imageData}
alt="Captured diagram for validation"
width={400}
height={300}
className="w-full h-auto max-h-48 object-contain"
unoptimized
/>
</div>
</div>
)}
{/* Issues */}
{state.result && state.result.issues.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.issuesFoundLabel}
</div>
<div className="space-y-2">
{state.result.issues.map((issue, index) => (
<div
key={index}
className={`text-xs px-3 py-2 rounded-lg border ${
issue.severity === "critical"
? "bg-red-50 border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300"
: "bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-950 dark:border-yellow-800 dark:text-yellow-300"
}`}
>
<span className="font-medium uppercase text-[10px] mr-2">
[{issue.type}]
</span>
{issue.description}
</div>
))}
</div>
</div>
)}
{/* Suggestions */}
{state.result && state.result.suggestions.length > 0 && (
<div>
<div className="text-xs font-medium text-foreground/70 mb-2">
{dict.validation.suggestions}
</div>
<ul className="text-xs text-foreground/60 space-y-1 list-disc list-inside">
{state.result.suggestions.map(
(suggestion, index) => (
<li key={index}>{suggestion}</li>
),
)}
</ul>
</div>
)}
{/* Valid result message */}
{state.result?.valid &&
state.result.issues.length === 0 && (
<div className="text-xs text-green-600 dark:text-green-400">
{dict.validation.passedValidation}
</div>
)}
</div>
)}
{/* Improve with Suggestions button - shown when validation passed but has suggestions */}
{showImproveButton && (
<div className="px-4 py-3 border-t border-border/40 bg-muted/10">
{hasRequestedImprovement ? (
<div className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-green-600 dark:text-green-400">
<Check className="h-4 w-4" aria-hidden="true" />
{dict.validation.improvementRequested}
</div>
) : (
<>
<button
type="button"
onClick={handleImproveClick}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-lg transition-colors"
>
<RefreshCw
className="h-4 w-4"
aria-hidden="true"
/>
{dict.validation.improveWithSuggestions}
</button>
<p className="text-xs text-muted-foreground mt-2 text-center">
{dict.validation.regenerateWithFeedback}
</p>
</>
)}
</div>
)}
{/* Error details when expanded */}
{isExpanded && state.error && (
<div className="px-4 py-3 border-t border-border/40 bg-red-50/50">
<div className="text-xs text-red-600">{state.error}</div>
</div>
)}
</div>
)
}

View File

@@ -443,12 +443,12 @@ export function ModelConfigDialog({
}} }}
className={cn( className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full", "group flex items-center gap-3 px-3 py-2.5 rounded-xl w-full",
"text-left text-sm transition-all duration-150", "text-left text-sm transition-all duration-150 border border-transparent",
"hover:bg-interactive-hover", "hover:bg-interactive-hover",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selectedProviderId === selectedProviderId ===
provider.id && provider.id &&
"bg-surface-0 shadow-sm ring-1 ring-border-subtle", "bg-surface-0 shadow-sm border-border-subtle",
)} )}
> >
<div <div
@@ -997,10 +997,19 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
Base URL{" "} {formatMessage(
<span className="text-muted-foreground font-normal"> dict.modelConfig
(optional) .baseUrlWithExample,
</span> {
example:
PROVIDER_INFO[
selectedProvider
.provider
]
.defaultBaseUrl ||
"https://api.example.com/v1",
},
)}
</Label> </Label>
<Input <Input
id="vertex-base-url" id="vertex-base-url"
@@ -1204,17 +1213,19 @@ export function ModelConfigDialog({
className="text-xs font-medium flex items-center gap-1.5" className="text-xs font-medium flex items-center gap-1.5"
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{ {formatMessage(
dict.modelConfig dict.modelConfig
.baseUrl .baseUrlWithExample,
}
<span className="text-muted-foreground font-normal">
{ {
dict example:
.modelConfig PROVIDER_INFO[
.optional selectedProvider
} .provider
</span> ]
.defaultBaseUrl ||
"https://api.example.com/v1",
},
)}
</Label> </Label>
<Input <Input
id="base-url" id="base-url"

View File

@@ -56,6 +56,7 @@ const LANGUAGE_LABELS: Record<Locale, string> = {
en: "English", en: "English",
zh: "中文", zh: "中文",
ja: "日本語", ja: "日本語",
"zh-Hant": "繁體中文",
} }
interface SettingsDialogProps { interface SettingsDialogProps {
@@ -67,6 +68,8 @@ interface SettingsDialogProps {
onToggleDarkMode: () => void onToggleDarkMode: () => void
minimalStyle?: boolean minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void onMinimalStyleChange?: (value: boolean) => void
vlmValidationEnabled?: boolean
onVlmValidationChange?: (value: boolean) => void
} }
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code" export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
@@ -88,6 +91,8 @@ function SettingsContent({
onToggleDarkMode, onToggleDarkMode,
minimalStyle = false, minimalStyle = false,
onMinimalStyleChange = () => {}, onMinimalStyleChange = () => {},
vlmValidationEnabled = false,
onVlmValidationChange = () => {},
}: SettingsDialogProps) { }: SettingsDialogProps) {
const dict = useDictionary() const dict = useDictionary()
const router = useRouter() const router = useRouter()
@@ -410,6 +415,25 @@ function SettingsContent({
</div> </div>
</SettingItem> </SettingItem>
{/* VLM Diagram Validation */}
<SettingItem
label={dict.settings.diagramValidation}
description={dict.settings.diagramValidationDescription}
>
<div className="flex items-center gap-2">
<Switch
id="vlm-validation"
checked={vlmValidationEnabled}
onCheckedChange={onVlmValidationChange}
/>
<span className="text-sm text-muted-foreground">
{vlmValidationEnabled
? dict.settings.enabled
: dict.settings.disabled}
</span>
</div>
</SettingItem>
{/* Send Shortcut */} {/* Send Shortcut */}
<SettingItem <SettingItem
label={dict.settings.sendShortcut} label={dict.settings.sendShortcut}
@@ -432,7 +456,7 @@ function SettingsContent({
> >
<SelectTrigger <SelectTrigger
id="send-shortcut-select" id="send-shortcut-select"
className="w-[170px] h-9 rounded-xl" className="w-auto h-9 rounded-xl"
> >
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -31,6 +31,7 @@ interface DiagramContextType {
successMessage?: string, successMessage?: string,
) => void ) => void
getThumbnailSvg: () => Promise<string | null> getThumbnailSvg: () => Promise<string | null>
captureValidationPng: () => Promise<string | null>
isDrawioReady: boolean isDrawioReady: boolean
onDrawioLoad: () => void onDrawioLoad: () => void
resetDrawioReady: () => void resetDrawioReady: () => void
@@ -51,6 +52,8 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const hasCalledOnLoadRef = useRef(false) const hasCalledOnLoadRef = useRef(false)
const drawioRef = useRef<DrawIoEmbedRef | null>(null) const drawioRef = useRef<DrawIoEmbedRef | null>(null)
const resolverRef = useRef<((value: string) => void) | 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)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change) // Track if diagram has been restored after DrawIO remount (e.g., theme change)
@@ -147,6 +150,37 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
// 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
try {
const pngData = await Promise.race([
new Promise<string>((resolve) => {
pngResolverRef.current = resolve
drawioRef.current?.exportDiagram({ format: "png" })
}),
new Promise<string>((_, reject) =>
setTimeout(
() => reject(new Error("PNG export timeout")),
5000,
),
),
])
// PNG data should be a base64 data URL
if (pngData?.startsWith("data:image/png")) {
return pngData
}
return null
} catch {
// Timeout is expected occasionally - don't log as error
return null
}
}
const loadDiagram = ( const loadDiagram = (
chart: string, chart: string,
skipValidation?: boolean, skipValidation?: boolean,
@@ -186,6 +220,13 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
const handleDiagramExport = (data: any) => { const handleDiagramExport = (data: any) => {
// Handle PNG export for VLM validation
if (pngResolverRef.current && data.data?.startsWith("data:image/png")) {
pngResolverRef.current(data.data)
pngResolverRef.current = null
return
}
// Handle save to file if requested (process raw data before extraction) // Handle save to file if requested (process raw data before extraction)
if (saveResolverRef.current.resolver) { if (saveResolverRef.current.resolver) {
const format = saveResolverRef.current.format const format = saveResolverRef.current.format
@@ -353,6 +394,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,
captureValidationPng,
isDrawioReady, isDrawioReady,
onDrawioLoad, onDrawioLoad,
resetDrawioReady, resetDrawioReady,

View File

@@ -37,10 +37,11 @@ mac:
arch: arch:
- x64 - x64
- arm64 - arm64
hardenedRuntime: true # Disable electron-builder's signing - we use custom ad-hoc signing in afterPack
# to properly sign nested bundles with --deep flag for bundled draw.io files
identity: null
hardenedRuntime: false
gatekeeperAssess: false gatekeeperAssess: false
entitlements: resources/entitlements.mac.plist
entitlementsInherit: resources/entitlements.mac.plist
dmg: dmg:
contents: contents:

View File

@@ -69,7 +69,9 @@ declare global {
/** Set proxy configuration (saves and restarts server) */ /** Set proxy configuration (saves and restarts server) */
setProxy: (config: ProxyConfig) => Promise<SetProxyResult> setProxy: (config: ProxyConfig) => Promise<SetProxyResult>
/** Get user's preferred locale */ /** Get user's preferred locale */
getUserLocale: () => Promise<"en" | "zh" | "ja" | undefined> getUserLocale: () => Promise<
"en" | "zh" | "ja" | "zh-Hant" | undefined
>
/** Set user's preferred locale */ /** Set user's preferred locale */
setUserLocale: (locale: string) => Promise<SetUserLocaleResult> setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
} }

View File

@@ -137,7 +137,7 @@ interface ConfigPresetsFile {
version: 1 version: 1
currentPresetId: string | null currentPresetId: string | null
presets: ConfigPreset[] presets: ConfigPreset[]
userLocale?: "en" | "zh" | "ja" userLocale?: "en" | "zh" | "ja" | "zh-Hant"
} }
const CONFIG_FILE_NAME = "config-presets.json" const CONFIG_FILE_NAME = "config-presets.json"
@@ -470,7 +470,7 @@ export function getCurrentPresetEnv(): Record<string, string> {
* Get user's preferred locale from config * Get user's preferred locale from config
* Returns undefined if not set * Returns undefined if not set
*/ */
export function getUserLocale(): "en" | "zh" | "ja" | undefined { export function getUserLocale(): "en" | "zh" | "ja" | "zh-Hant" | undefined {
const data = loadPresets() const data = loadPresets()
return data.userLocale return data.userLocale
} }
@@ -478,7 +478,9 @@ export function getUserLocale(): "en" | "zh" | "ja" | undefined {
/** /**
* Set user's preferred locale in config * Set user's preferred locale in config
*/ */
export function setUserLocale(locale: "en" | "zh" | "ja" | null): void { export function setUserLocale(
locale: "en" | "zh" | "ja" | "zh-Hant" | null,
): void {
const data = loadPresets() const data = loadPresets()
data.userLocale = locale === null ? undefined : locale data.userLocale = locale === null ? undefined : locale
savePresets(data) savePresets(data)

View File

@@ -263,12 +263,12 @@ export function registerIpcHandlers(): void {
ipcMain.handle("set-user-locale", (_event, locale: string) => { ipcMain.handle("set-user-locale", (_event, locale: string) => {
// Validate locale is one of the supported values // Validate locale is one of the supported values
if (!["en", "zh", "ja"].includes(locale)) { if (!["en", "zh", "ja", "zh-Hant"].includes(locale)) {
return { success: false, error: "Invalid locale" } return { success: false, error: "Invalid locale" }
} }
try { try {
setUserLocale(locale as "en" | "zh" | "ja") setUserLocale(locale as "en" | "zh" | "ja" | "zh-Hant")
// Rebuild the menu to reflect the new locale // Rebuild the menu to reflect the new locale
rebuildAppMenu() rebuildAppMenu()
return { success: true } return { success: true }

View File

@@ -5,7 +5,7 @@
import { getUserLocale } from "./config-manager" import { getUserLocale } from "./config-manager"
export type MenuLocale = "en" | "zh" | "ja" export type MenuLocale = "en" | "zh" | "ja" | "zh-Hant"
export interface MenuTranslations { export interface MenuTranslations {
// App menu (macOS only) // App menu (macOS only)
@@ -119,6 +119,34 @@ const translations: Record<MenuLocale, MenuTranslations> = {
documentation: "ドキュメント", documentation: "ドキュメント",
reportIssue: "問題を報告", reportIssue: "問題を報告",
}, },
"zh-Hant": {
// App menu
settings: "設定...",
// File menu
file: "檔案",
// Edit menu
edit: "編輯",
// View menu
view: "檢視",
// Configuration menu
configuration: "配置",
switchPreset: "切換預設",
managePresets: "管理預設...",
addConfigurationPreset: "新增配置預設...",
// Window menu
window: "視窗",
// Help menu
help: "說明",
documentation: "文件",
reportIssue: "回報問題",
},
} }
/** /**
@@ -126,6 +154,16 @@ const translations: Record<MenuLocale, MenuTranslations> = {
* Falls back to English if locale is not supported * Falls back to English if locale is not supported
*/ */
export function getMenuTranslations(locale: string): MenuTranslations { export function getMenuTranslations(locale: string): MenuTranslations {
// Check for zh-Hant before normalizing
if (
locale === "zh-Hant" ||
locale.toLowerCase().startsWith("zh-hant") ||
locale.toLowerCase().startsWith("zh-tw") ||
locale.toLowerCase().startsWith("zh-hk")
) {
return translations["zh-Hant"]
}
// Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja") // Normalize locale (e.g., "zh-CN" -> "zh", "ja-JP" -> "ja")
const normalized = locale.toLowerCase().split("-")[0] const normalized = locale.toLowerCase().split("-")[0]
@@ -136,10 +174,21 @@ export function getMenuTranslations(locale: string): MenuTranslations {
/** /**
* Detect system locale from Electron app * Detect system locale from Electron app
* Returns one of: "en", "zh", "ja" * Returns one of: "en", "zh", "ja", "zh-Hant"
*/ */
export function detectSystemLocale(appLocale: string): MenuLocale { export function detectSystemLocale(appLocale: string): MenuLocale {
const normalized = appLocale.toLowerCase().split("-")[0] const lower = appLocale.toLowerCase()
// Distinguish Traditional Chinese locales (TW, HK, Hant) from Simplified
if (
lower.startsWith("zh-hant") ||
lower.startsWith("zh-tw") ||
lower.startsWith("zh-hk")
) {
return "zh-Hant"
}
const normalized = lower.split("-")[0]
if (normalized === "zh") return "zh" if (normalized === "zh") return "zh"
if (normalized === "ja") return "ja" if (normalized === "ja") return "ja"

View File

@@ -1,5 +1,12 @@
import type { MutableRefObject } from "react" import type { MutableRefObject } from "react"
import { useRef } from "react"
import type { DiagramOperation } from "@/components/chat/types" import type { DiagramOperation } from "@/components/chat/types"
import type {
ValidationState,
ValidationStatus,
} from "@/components/chat/ValidationCard"
import type { ValidationResult } from "@/lib/diagram-validator"
import { formatValidationFeedback } from "@/lib/diagram-validator"
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils" import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
const DEBUG = process.env.NODE_ENV === "development" const DEBUG = process.env.NODE_ENV === "development"
@@ -30,6 +37,14 @@ type AddToolOutputParams = AddToolOutputSuccess | AddToolOutputError
type AddToolOutputFn = (params: AddToolOutputParams) => void type AddToolOutputFn = (params: AddToolOutputParams) => void
const MAX_VALIDATION_RETRIES = 3
// Type for the validation function passed from useValidateDiagram hook
type ValidateDiagramFn = (
imageData: string,
sessionId?: string,
) => Promise<ValidationResult>
interface UseDiagramToolHandlersParams { interface UseDiagramToolHandlersParams {
partialXmlRef: MutableRefObject<string> partialXmlRef: MutableRefObject<string>
editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>> editDiagramOriginalXmlRef: MutableRefObject<Map<string, string>>
@@ -37,6 +52,14 @@ interface UseDiagramToolHandlersParams {
onDisplayChart: (xml: string, skipValidation?: boolean) => string | null onDisplayChart: (xml: string, skipValidation?: boolean) => string | null
onFetchChart: (saveToHistory?: boolean) => Promise<string> onFetchChart: (saveToHistory?: boolean) => Promise<string>
onExport: () => void onExport: () => void
captureValidationPng?: () => Promise<string | null>
validateDiagram?: ValidateDiagramFn
enableVlmValidation?: boolean
sessionId?: string
onValidationStateChange?: (
toolCallId: string,
state: ValidationState,
) => void
} }
/** /**
@@ -53,7 +76,34 @@ export function useDiagramToolHandlers({
onDisplayChart, onDisplayChart,
onFetchChart, onFetchChart,
onExport, onExport,
captureValidationPng,
validateDiagram,
enableVlmValidation = true,
sessionId,
onValidationStateChange,
}: UseDiagramToolHandlersParams) { }: 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,
status: ValidationStatus,
options?: {
attempt?: number
maxAttempts?: number
result?: ValidationResult
error?: string
imageData?: string
},
) => {
if (onValidationStateChange) {
onValidationStateChange(toolCallId, {
status,
...options,
})
}
}
const handleToolCall = async ( const handleToolCall = async (
{ toolCall }: { toolCall: ToolCall }, { toolCall }: { toolCall: ToolCall },
addToolOutput: AddToolOutputFn, addToolOutput: AddToolOutputFn,
@@ -155,7 +205,159 @@ ${finalXml}
// Success - diagram will be rendered by chat-message-display // Success - diagram will be rendered by chat-message-display
if (DEBUG) { if (DEBUG) {
console.log( console.log(
"[display_diagram] Success! Adding tool output with state: output-available", "[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",
)
}
const retryCount =
validationRetryCountRef.current.get(
toolCall.toolCallId,
) || 0
// Notify UI that we're validating (include the image)
updateValidationState(
toolCall.toolCallId,
"validating",
{
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
imageData: capturedPngData,
},
)
const result = await validateDiagram(
capturedPngData,
sessionId,
)
if (!result.valid) {
if (retryCount < MAX_VALIDATION_RETRIES) {
validationRetryCountRef.current.set(
toolCall.toolCallId,
retryCount + 1,
)
const feedback =
formatValidationFeedback(result)
if (DEBUG) {
console.log(
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
result.issues,
)
}
// Notify UI of validation failure (include the image)
updateValidationState(
toolCall.toolCallId,
"failed",
{
attempt: retryCount + 1,
maxAttempts: MAX_VALIDATION_RETRIES,
result,
imageData: capturedPngData,
},
)
addToolOutput({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
})
return
} else {
// Max retries reached - accept the diagram with warning
if (DEBUG) {
console.log(
"[display_diagram] Max validation retries reached, accepting diagram",
)
}
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
// 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 - clean up retry count
validationRetryCountRef.current.delete(
toolCall.toolCallId,
)
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({ addToolOutput({

View File

@@ -0,0 +1,136 @@
"use client"
/**
* Hook for VLM-based diagram validation using AI SDK's useObject.
*/
import { experimental_useObject as useObject } from "@ai-sdk/react"
import { useCallback, useRef } from "react"
import { getApiEndpoint } from "@/lib/base-path"
import {
type ValidationResult,
ValidationResultSchema,
} from "@/lib/validation-schema"
export type { ValidationResult }
// Default valid result for fallback cases
const DEFAULT_VALID_RESULT: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
interface UseValidateDiagramOptions {
onSuccess?: (result: ValidationResult) => void
onError?: (error: Error) => void
}
// Track pending validation promises for imperative API
type PendingValidation = {
resolve: (result: ValidationResult) => void
reject: (error: Error) => void
}
export function useValidateDiagram(options: UseValidateDiagramOptions = {}) {
const { onSuccess, onError } = options
const pendingValidationRef = useRef<PendingValidation | null>(null)
const { object, submit, isLoading, error, stop } = useObject({
api: getApiEndpoint("/api/validate-diagram"),
schema: ValidationResultSchema,
onFinish: ({
object,
error: finishError,
}: {
object: ValidationResult | undefined
error: Error | undefined
}) => {
if (finishError) {
console.error(
"[useValidateDiagram] Validation error:",
finishError,
)
onError?.(finishError)
pendingValidationRef.current?.reject(finishError)
pendingValidationRef.current = null
return
}
if (object) {
const result = object as ValidationResult
onSuccess?.(result)
pendingValidationRef.current?.resolve(result)
pendingValidationRef.current = null
}
},
onError: (err: Error) => {
console.error("[useValidateDiagram] Stream error:", err)
onError?.(err)
pendingValidationRef.current?.reject(err)
pendingValidationRef.current = null
},
})
/**
* Validate a diagram image.
* Returns a promise that resolves with the validation result.
*/
const validate = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
// Reject any pending validation to prevent promise leaks
if (pendingValidationRef.current) {
pendingValidationRef.current.reject(
new Error("Validation superseded by new request"),
)
pendingValidationRef.current = null
}
return new Promise((resolve, reject) => {
// Store the promise handlers
pendingValidationRef.current = { resolve, reject }
// Submit the validation request
submit({ imageData, sessionId })
})
},
[submit],
)
/**
* Validate with fallback - returns default valid result on error.
* Use this to avoid blocking the user on validation failures.
*/
const validateWithFallback = useCallback(
async (
imageData: string,
sessionId?: string,
): Promise<ValidationResult> => {
try {
return await validate(imageData, sessionId)
} catch (error) {
console.warn(
"[useValidateDiagram] Validation failed, using fallback:",
error,
)
return DEFAULT_VALID_RESULT
}
},
[validate],
)
return {
// Validation functions
validate,
validateWithFallback,
stop,
// State
isValidating: isLoading,
partialResult: object as ValidationResult | undefined,
error,
}
}

View File

@@ -1177,3 +1177,27 @@ export function supportsImageInput(modelId: string): boolean {
// Default: assume model supports images // Default: assume model supports images
return true return true
} }
/**
* Get the AI model for diagram validation.
* Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL.
* Throws if the model doesn't support image input.
*/
export function getValidationModel(): ReturnType<typeof getAIModel>["model"] {
const modelId = process.env.VALIDATION_MODEL || process.env.AI_MODEL
if (!modelId) {
throw new Error(
"No validation model configured. Set VALIDATION_MODEL or AI_MODEL.",
)
}
if (!supportsImageInput(modelId)) {
throw new Error(
`Validation requires a vision-capable model. Model "${modelId}" does not support image input.`,
)
}
const { model } = getAIModel({ modelId })
return model
}

64
lib/diagram-validator.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Types and utilities for VLM-based diagram validation.
* The actual validation is performed via useValidateDiagram hook using AI SDK's useObject.
*/
// Re-export types from the schema file (single source of truth)
export type { ValidationIssue, ValidationResult } from "./validation-schema"
import type { ValidationResult } from "./validation-schema"
/**
* Format validation feedback for display to the AI model.
* This creates a human-readable error message that guides the AI to fix issues.
*
* @param result - The validation result from VLM
* @returns Formatted string for tool error output
*/
export function formatValidationFeedback(result: ValidationResult): string {
// If validation passed with no issues, return empty string
if (result.valid && result.issues.length === 0) {
return ""
}
const lines: string[] = []
lines.push("DIAGRAM VISUAL VALIDATION FAILED")
lines.push("")
// Group issues by severity
const criticalIssues = result.issues.filter(
(i) => i.severity === "critical",
)
const warnings = result.issues.filter((i) => i.severity === "warning")
if (criticalIssues.length > 0) {
lines.push("Critical Issues (must fix):")
for (const issue of criticalIssues) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (warnings.length > 0) {
lines.push("Warnings:")
for (const issue of warnings) {
lines.push(` - [${issue.type}] ${issue.description}`)
}
lines.push("")
}
if (result.suggestions.length > 0) {
lines.push("Suggestions to fix:")
for (const suggestion of result.suggestions) {
lines.push(` - ${suggestion}`)
}
lines.push("")
}
lines.push(
"Please regenerate the diagram with corrected layout to fix these visual issues.",
)
return lines.join("\n")
}

View File

@@ -1,6 +1,6 @@
export const i18n = { export const i18n = {
defaultLocale: "en", defaultLocale: "en",
locales: ["en", "zh", "ja"], locales: ["en", "zh", "ja", "zh-Hant"],
} as const } as const
export type Locale = (typeof i18n)["locales"][number] export type Locale = (typeof i18n)["locales"][number]

View File

@@ -6,6 +6,8 @@ const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default), en: () => import("./dictionaries/en.json").then((m) => m.default),
zh: () => import("./dictionaries/zh.json").then((m) => m.default), zh: () => import("./dictionaries/zh.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default), ja: () => import("./dictionaries/ja.json").then((m) => m.default),
"zh-Hant": () =>
import("./dictionaries/zh-Hant.json").then((m) => m.default),
} }
export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>> export type Dictionary = Awaited<ReturnType<(typeof dictionaries)["en"]>>

View File

@@ -115,7 +115,11 @@
"httpProxy": "HTTP Proxy", "httpProxy": "HTTP Proxy",
"httpsProxy": "HTTPS Proxy", "httpsProxy": "HTTPS Proxy",
"applyProxy": "Apply", "applyProxy": "Apply",
"proxyApplied": "Proxy settings applied" "proxyApplied": "Proxy settings applied",
"diagramValidation": "Diagram Validation (Experimental)",
"diagramValidationDescription": "Use a vision language model to validate generated diagrams. Requires a VLM like GPT-5.2 or Sonnet-4.5.",
"enabled": "Enabled",
"disabled": "Disabled"
}, },
"save": { "save": {
"title": "Save Diagram", "title": "Save Diagram",
@@ -248,6 +252,24 @@
"searchPlaceholder": "Search chats...", "searchPlaceholder": "Search chats...",
"noResults": "No chats found" "noResults": "No chats found"
}, },
"validation": {
"title": "Validate Diagram",
"capturing": "Capturing",
"validating": "Validating",
"validatingWithAttempt": "Validating ({attempt}/{max})",
"valid": "Valid",
"validWithWarnings": "Valid with Warnings",
"issuesFound": "Issues Found",
"error": "Error",
"skipped": "Skipped",
"capturedScreenshot": "Captured Screenshot:",
"issuesFoundLabel": "Issues Found:",
"suggestions": "Suggestions:",
"passedValidation": "Diagram passed visual validation - no issues detected.",
"improvementRequested": "Improvement requested - check the new diagram below",
"improveWithSuggestions": "Improve with Suggestions",
"regenerateWithFeedback": "Regenerate the diagram using the validation feedback"
},
"modelConfig": { "modelConfig": {
"title": "AI Model Configuration", "title": "AI Model Configuration",
"description": "Configure multiple AI providers and models", "description": "Configure multiple AI providers and models",
@@ -280,6 +302,7 @@
"enterSecretKey": "Enter your secret access key", "enterSecretKey": "Enter your secret access key",
"baseUrl": "Base URL", "baseUrl": "Base URL",
"optional": "(optional)", "optional": "(optional)",
"baseUrlWithExample": "Base URL (optional, e.g. {example})",
"customEndpoint": "Custom endpoint URL", "customEndpoint": "Custom endpoint URL",
"models": "Models", "models": "Models",
"customModelId": "Custom model ID...", "customModelId": "Custom model ID...",

View File

@@ -115,7 +115,11 @@
"httpProxy": "HTTP プロキシ", "httpProxy": "HTTP プロキシ",
"httpsProxy": "HTTPS プロキシ", "httpsProxy": "HTTPS プロキシ",
"applyProxy": "適用", "applyProxy": "適用",
"proxyApplied": "プロキシ設定が適用されました" "proxyApplied": "プロキシ設定が適用されました",
"diagramValidation": "ダイアグラム検証(実験的)",
"diagramValidationDescription": "視覚言語モデルを使用して生成されたダイアグラムを検証します。GPT-5.2 や Sonnet-4.5 などの VLM が必要です。",
"enabled": "有効",
"disabled": "無効"
}, },
"save": { "save": {
"title": "ダイアグラムを保存", "title": "ダイアグラムを保存",
@@ -248,6 +252,24 @@
"searchPlaceholder": "チャットを検索...", "searchPlaceholder": "チャットを検索...",
"noResults": "チャットが見つかりません" "noResults": "チャットが見つかりません"
}, },
"validation": {
"title": "ダイアグラムを検証",
"capturing": "キャプチャ中",
"validating": "検証中",
"validatingWithAttempt": "検証中 ({attempt}/{max})",
"valid": "有効",
"validWithWarnings": "有効(警告あり)",
"issuesFound": "問題が見つかりました",
"error": "エラー",
"skipped": "スキップ",
"capturedScreenshot": "キャプチャした画像:",
"issuesFoundLabel": "検出された問題:",
"suggestions": "提案:",
"passedValidation": "ダイアグラムは視覚検証に合格しました - 問題は検出されませんでした。",
"improvementRequested": "改善リクエスト済み - 下の新しいダイアグラムを確認してください",
"improveWithSuggestions": "提案で改善",
"regenerateWithFeedback": "検証フィードバックを使用してダイアグラムを再生成"
},
"modelConfig": { "modelConfig": {
"title": "AIモデル設定", "title": "AIモデル設定",
"description": "複数のAIプロバイダーとモデルを設定", "description": "複数のAIプロバイダーとモデルを設定",
@@ -280,6 +302,7 @@
"enterSecretKey": "シークレットアクセスキーを入力", "enterSecretKey": "シークレットアクセスキーを入力",
"baseUrl": "ベース URL", "baseUrl": "ベース URL",
"optional": "(オプション)", "optional": "(オプション)",
"baseUrlWithExample": "ベース URLオプション、例: {example}",
"customEndpoint": "カスタムエンドポイント URL", "customEndpoint": "カスタムエンドポイント URL",
"models": "モデル", "models": "モデル",
"customModelId": "カスタムモデル ID...", "customModelId": "カスタムモデル ID...",

View File

@@ -0,0 +1,340 @@
{
"common": {
"save": "儲存",
"cancel": "取消",
"close": "關閉",
"confirm": "確認",
"clear": "清除",
"edit": "編輯",
"delete": "刪除",
"loading": "載入中...",
"new": "新建"
},
"nav": {
"about": "關於",
"editor": "編輯器",
"newChat": "開始新對話",
"github": "GitHub",
"settings": "設定",
"hidePanel": "隱藏聊天面板 (Ctrl+B)",
"showPanel": "顯示聊天面板 (Ctrl+B)",
"aiChat": "AI 聊天"
},
"providers": {
"useServerDefault": "使用伺服器預設值",
"openai": "OpenAI",
"anthropic": "Anthropic",
"google": "Google",
"azure": "Azure OpenAI",
"openrouter": "OpenRouter",
"deepseek": "DeepSeek",
"siliconflow": "SiliconFlow",
"modelscope": "ModelScope"
},
"chat": {
"placeholder": "描述您的圖表或上傳檔案...",
"send": "傳送",
"sending": "傳送中...",
"sendMessage": "傳送訊息",
"clearConversation": "清除對話",
"diagramHistory": "圖表歷史",
"saveDiagram": "儲存圖表",
"uploadFile": "上傳檔案圖片、PDF、文字",
"minimalStyle": "簡約",
"styledMode": "精緻",
"minimalTooltip": "使用簡約模式以加快產生速度(無顏色)",
"regenerate": "重新產生回應",
"copyResponse": "複製回應",
"copied": "已複製!",
"failedToCopy": "複製失敗",
"failedToCopyDetail": "複製訊息失敗。請手動複製或檢查剪貼簿權限。",
"goodResponse": "有幫助",
"badResponse": "無幫助",
"clickToEdit": "點擊編輯",
"editMessage": "編輯訊息",
"saveAndSubmit": "儲存並提交",
"ExtractURL": "從 URL 擷取"
},
"examples": {
"title": "用 AI 建立圖表",
"subtitle": "描述您想要建立的內容或上傳圖片進行複製",
"quickExamples": "快速範例",
"paperToDiagram": "文件轉圖表",
"paperDescription": "上傳 .pdf, .txt, .md, .json, .csv, .py, .js, .ts 等檔案",
"animatedDiagram": "動畫圖表",
"animatedDescription": "繪製帶有動畫連接器的 Transformer 架構",
"awsArchitecture": "AWS 架構",
"awsDescription": "使用 AWS 圖示建立雲端架構圖",
"replicateFlowchart": "複製流程圖",
"replicateDescription": "上傳並複製現有流程圖",
"creativeDrawing": "創意繪圖",
"creativeDescription": "繪製有趣且富有創意的內容",
"cachedNote": "範例已快取,可即時回應",
"mcpServer": "MCP 伺服器",
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
"preview": "預覽"
},
"settings": {
"title": "設定",
"description": "配置您的應用程式設定。",
"accessCode": "存取碼",
"accessCodePlaceholder": "輸入存取碼",
"accessCodeDescription": "使用此應用程式需要存取碼。",
"aiProvider": "AI 提供商設定",
"aiProviderDescription": "使用您自己的 API 金鑰來繞過使用限制。您的金鑰僅儲存在瀏覽器本機,不會儲存在伺服器上。",
"provider": "提供商",
"modelId": "模型 ID",
"apiKey": "API 金鑰",
"apiKeyPlaceholder": "您的 API 金鑰",
"baseUrl": "基礎 URL可選",
"customEndpoint": "自訂端點 URL",
"overrides": "覆寫",
"clearSettings": "清除設定",
"useServerDefault": "使用伺服器預設值",
"language": "語言",
"languageDescription": "選擇介面語言。",
"theme": "主題",
"themeDescription": "介面和 DrawIO 畫布的深色/淺色模式。",
"drawioStyle": "DrawIO 樣式",
"drawioStyleDescription": "畫布樣式:",
"switchTo": "切換到",
"minimal": "簡約",
"sketch": "草圖",
"diagramStyle": "圖表樣式",
"diagramStyleDescription": "切換簡約與精緻圖表輸出模式。",
"sendShortcut": "傳送快捷鍵",
"sendShortcutDescription": "選擇傳送訊息的方式。",
"enterToSend": "Enter 傳送",
"ctrlEnterToSend": "Cmd/Ctrl+Enter 傳送",
"diagramActions": "圖表操作",
"diagramActionsDescription": "管理圖表歷史紀錄和匯出",
"history": "歷史紀錄",
"download": "下載",
"proxy": "代理設定",
"proxyDescription": "配置 API 請求的 HTTP/HTTPS 代理(僅桌面版)",
"httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理",
"applyProxy": "套用",
"proxyApplied": "代理設定已套用",
"diagramValidation": "圖表驗證(實驗性)",
"diagramValidationDescription": "使用視覺語言模型驗證產生的圖表。需要支援視覺的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已啟用",
"disabled": "已停用"
},
"save": {
"title": "儲存圖表",
"description": "選擇格式和檔案名稱以儲存您的圖表。",
"format": "格式",
"filename": "檔案名稱",
"filenamePlaceholder": "輸入檔案名稱",
"formats": {
"drawio": "Draw.io XML",
"png": "PNG 圖片",
"svg": "SVG 圖片"
},
"savedSuccessfully": "儲存成功!"
},
"history": {
"title": "圖表歷史",
"description": "在 AI 修改之前儲存的每個圖表。\n點擊圖表以還原它",
"noHistory": "尚無歷史紀錄。傳送訊息以建立圖表歷史。",
"version": "版本",
"restoreTo": "還原到版本 {version}"
},
"dialogs": {
"clearTitle": "清除所有內容?",
"clearDescription": "這將清除目前對話並重設圖表。此操作無法復原。",
"clearEverything": "清除所有內容",
"clearSuccess": "已開始新對話"
},
"errors": {
"maxFiles": "檔案太多。最多允許 {max} 個。",
"onlyMoreAllowed": "只能再新增 {slots} 個檔案",
"fileExceeds": "「{name}」大小為 {size}(超過 {max}MB",
"unsupportedType": "「{name}」不是支援的檔案類型",
"filesRejected": "{count} 個檔案被拒絕:",
"andMore": "...還有 {count} 個",
"invalidAccessCode": "無效或缺少存取碼。請在設定中配置。",
"networkError": "網路錯誤。請檢查您的連線。",
"retryLimit": "已達自動重試限制({max})。請手動重試。",
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
"failedToSave": "無法儲存訊息到 localStorage",
"failedToRestore": "無法從 localStorage 還原",
"failedToPersist": "卸載前無法持久化狀態",
"failedToExport": "取得圖表資料時出錯",
"failedToLoadExample": "載入範例圖片時出錯",
"failedToRecordFeedback": "記錄您的回饋失敗。請重試。",
"storageUpdateFailed": "聊天已清除,但無法更新瀏覽器儲存空間"
},
"quota": {
"dailyLimit": "已達每日配額",
"tokenLimit": "已達每日令牌限制",
"tpmLimit": "速率限制",
"tpmMessage": "請求過多。請稍等片刻。",
"tpmMessageDetailed": "達到速率限制({limit} 令牌/分鐘)。請等待 {seconds} 秒後再傳送請求。",
"messageApi": "看來您今天的體驗次數已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
"reset": "您的限制將在明天重設。感謝您的理解。",
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token包括豆包、DeepSeek 和 Kimi然後在模型設定中配置您的 API Key。",
"configModel": "使用您的金鑰",
"selfHost": "自行託管",
"sponsor": "贊助",
"learnMore": "了解更多 →",
"usedOf": "{used}/{limit}"
},
"tools": {
"generateDiagram": "產生圖表",
"editDiagram": "編輯圖表",
"appendDiagram": "繼續圖表",
"complete": "完成",
"error": "錯誤",
"truncated": "已截斷"
},
"file": {
"reading": "讀取中...",
"chars": "字元",
"removeFile": "移除檔案"
},
"url": {
"title": "從 URL 擷取內容",
"description": "貼上 URL 以擷取和分析其內容",
"Extracting": "擷取中...",
"extract": "擷取",
"Cancel": "取消",
"enterUrl": "請輸入 URL",
"invalidFormat": "URL 格式無效"
},
"reasoning": {
"thinking": "思考中...",
"thoughtFor": "思考了 {duration} 秒",
"thoughtBrief": "思考了幾秒鐘"
},
"dev": {
"title": "開發XML 串流模擬器",
"preset": "預設:",
"selectPreset": "選擇預設...",
"clear": "清除",
"placeholder": "在此貼上 mxCell XML 或選擇預設...",
"interval": "間隔:",
"chars": "字元:",
"streaming": "串流傳輸中...",
"simulate": "模擬",
"stop": "停止",
"testQuotaToast": "測試配額提示",
"simulatingMessage": "[開發] 模擬 XML 串流傳輸",
"successMessage": "成功顯示圖表。"
},
"about": {
"modelChange": "模型變更與用量限制",
"walletCrying": "(別名:我的錢包頂不住了)",
"seekingSponsorship": "尋求贊助(求大佬撈一把)",
"contactMe": "聯絡我",
"usageNotice": "由於使用量過高,我已將模型從 Claude 更換為 minimax-m2並設定了一些用量限制。詳情請查看關於頁面。"
},
"sessionHistory": {
"tooltip": "聊天歷史",
"newChat": "新對話",
"empty": "暫無聊天紀錄",
"emptyHint": "開始對話吧",
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本週",
"earlier": "更早",
"deleteTitle": "刪除此對話?",
"deleteDescription": "這將永久刪除此聊天工作階段及其圖表。此操作無法復原。",
"recentChats": "最近對話",
"justNow": "剛剛",
"searchPlaceholder": "搜尋對話...",
"noResults": "未找到對話"
},
"validation": {
"title": "驗證圖表",
"capturing": "截圖中",
"validating": "驗證中",
"validatingWithAttempt": "驗證中 ({attempt}/{max})",
"valid": "通過",
"validWithWarnings": "通過(有警告)",
"issuesFound": "發現問題",
"error": "錯誤",
"skipped": "已跳過",
"capturedScreenshot": "截圖預覽:",
"issuesFoundLabel": "發現的問題:",
"suggestions": "建議:",
"passedValidation": "圖表通過視覺驗證 - 未發現問題。",
"improvementRequested": "改進請求已傳送 - 請查看下方新圖表",
"improveWithSuggestions": "根據建議改進",
"regenerateWithFeedback": "使用驗證回饋重新產生圖表"
},
"modelConfig": {
"title": "AI 模型配置",
"description": "配置多個 AI 提供商和模型",
"configure": "配置",
"addProvider": "新增提供商",
"addModel": "新增模型",
"modelId": "模型 ID",
"modelLabel": "顯示名稱",
"streaming": "啟用串流輸出",
"deleteProvider": "刪除提供商",
"deleteModel": "刪除模型",
"noModels": "尚未配置模型。新增模型以開始使用。",
"selectProvider": "選擇一個提供商或新增",
"configureMultiple": "配置多個 AI 提供商並輕鬆切換",
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
"test": "測試",
"validationError": "驗證失敗",
"addModelFirst": "請先新增至少一個模型以進行驗證",
"providers": "提供商",
"addProviderHint": "新增提供商即可開始使用",
"verified": "已驗證",
"configuration": "配置",
"displayName": "顯示名稱",
"awsAccessKeyId": "AWS 存取金鑰 ID",
"awsSecretAccessKey": "AWS Secret Access Key",
"awsRegion": "AWS 區域",
"selectRegion": "選擇區域",
"apiKey": "API 金鑰",
"enterApiKey": "輸入您的 API 金鑰",
"enterSecretKey": "輸入您的 Secret Key",
"baseUrl": "基礎 URL",
"optional": "(可選)",
"baseUrlWithExample": "基礎 URL可選例如 {example}",
"customEndpoint": "自訂端點 URL",
"models": "模型",
"customModelId": "自訂模型 ID...",
"allAdded": "已全部新增",
"suggested": "推薦",
"noModelsConfigured": "尚未配置模型",
"modelIdEmpty": "模型 ID 不能為空",
"modelIdExists": "此模型 ID 已存在",
"configureProviders": "配置 AI 提供商",
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
"typeToConfirm": "輸入「{name}」以確認",
"typeProviderName": "輸入提供商名稱...",
"modelsConfiguredCount": "已配置 {count} 個模型",
"validationFailedCount": "{count} 個模型驗證失敗",
"cancel": "取消",
"delete": "刪除",
"clickToChange": "(點擊變更)",
"usingServerDefault": "使用伺服器預設模型",
"selectModel": "選擇模型",
"searchModels": "搜尋模型...",
"noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。",
"noModelsFound": "未找到模型。",
"default": "預設",
"serverDefault": "伺服器預設",
"serverModels": "伺服器模型",
"userModels": "使用者模型",
"configureModels": "配置模型...",
"onlyVerifiedShown": "僅顯示已驗證的模型",
"showUnvalidatedModels": "顯示未驗證的模型",
"allModelsShown": "顯示所有模型(包括未驗證的)",
"unvalidatedModelWarning": "此模型尚未驗證",
"serverDefaultModel": "伺服器預設模型"
}
}

View File

@@ -115,7 +115,11 @@
"httpProxy": "HTTP 代理", "httpProxy": "HTTP 代理",
"httpsProxy": "HTTPS 代理", "httpsProxy": "HTTPS 代理",
"applyProxy": "应用", "applyProxy": "应用",
"proxyApplied": "代理设置已应用" "proxyApplied": "代理设置已应用",
"diagramValidation": "图表验证(实验性)",
"diagramValidationDescription": "使用视觉语言模型验证生成的图表。需要支持视觉的模型,如 GPT-5.2 或 Sonnet-4.5。",
"enabled": "已启用",
"disabled": "已禁用"
}, },
"save": { "save": {
"title": "保存图表", "title": "保存图表",
@@ -248,6 +252,24 @@
"searchPlaceholder": "搜索对话...", "searchPlaceholder": "搜索对话...",
"noResults": "未找到对话" "noResults": "未找到对话"
}, },
"validation": {
"title": "验证图表",
"capturing": "截图中",
"validating": "验证中",
"validatingWithAttempt": "验证中 ({attempt}/{max})",
"valid": "通过",
"validWithWarnings": "通过(有警告)",
"issuesFound": "发现问题",
"error": "错误",
"skipped": "已跳过",
"capturedScreenshot": "截图预览:",
"issuesFoundLabel": "发现的问题:",
"suggestions": "建议:",
"passedValidation": "图表通过视觉验证 - 未发现问题。",
"improvementRequested": "改进请求已发送 - 请查看下方新图表",
"improveWithSuggestions": "根据建议改进",
"regenerateWithFeedback": "使用验证反馈重新生成图表"
},
"modelConfig": { "modelConfig": {
"title": "AI 模型配置", "title": "AI 模型配置",
"description": "配置多个 AI 提供商和模型", "description": "配置多个 AI 提供商和模型",
@@ -280,6 +302,7 @@
"enterSecretKey": "输入您的 Secret Key", "enterSecretKey": "输入您的 Secret Key",
"baseUrl": "基础 URL", "baseUrl": "基础 URL",
"optional": "(可选)", "optional": "(可选)",
"baseUrlWithExample": "基础 URL可选例如 {example}",
"customEndpoint": "自定义端点 URL", "customEndpoint": "自定义端点 URL",
"models": "模型", "models": "模型",
"customModelId": "自定义模型 ID...", "customModelId": "自定义模型 ID...",

View File

@@ -24,4 +24,7 @@ export const STORAGE_KEYS = {
// Chat input preferences // Chat input preferences
sendShortcut: "next-ai-draw-io-send-shortcut", sendShortcut: "next-ai-draw-io-send-shortcut",
// Diagram validation
vlmValidationEnabled: "next-ai-draw-io-vlm-validation-enabled",
} as const } as const

View File

@@ -11,6 +11,7 @@ export const DEFAULT_SYSTEM_PROMPT = `
You are an expert diagram creation assistant specializing in draw.io XML generation. You are an expert diagram creation assistant specializing in draw.io XML generation.
Your primary function is chat with user and crafting clear, well-organized visual diagrams through precise XML specifications. 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. 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 to avoid object overlapping or edge cross the objects. (2-3 sentences max), then use display_diagram tool to generate the XML. 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. After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.

View File

@@ -83,21 +83,36 @@ export const PROVIDER_INFO: Record<
ProviderName, ProviderName,
{ label: string; defaultBaseUrl?: string } { label: string; defaultBaseUrl?: string }
> = { > = {
openai: { label: "OpenAI" }, openai: {
label: "OpenAI",
defaultBaseUrl: "https://api.openai.com/v1",
},
anthropic: { anthropic: {
label: "Anthropic", label: "Anthropic",
defaultBaseUrl: "https://api.anthropic.com/v1", defaultBaseUrl: "https://api.anthropic.com/v1",
}, },
google: { label: "Google" }, google: {
label: "Google",
defaultBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
},
vertexai: { label: "Google Vertex AI" }, vertexai: { label: "Google Vertex AI" },
azure: { label: "Azure OpenAI" }, azure: {
label: "Azure OpenAI",
defaultBaseUrl: "https://your-resource.openai.azure.com/openai",
},
bedrock: { label: "Amazon Bedrock" }, bedrock: { label: "Amazon Bedrock" },
ollama: { ollama: {
label: "Ollama", label: "Ollama",
defaultBaseUrl: "http://localhost:11434", defaultBaseUrl: "http://localhost:11434",
}, },
openrouter: { label: "OpenRouter" }, openrouter: {
deepseek: { label: "DeepSeek" }, label: "OpenRouter",
defaultBaseUrl: "https://openrouter.ai/api/v1",
},
deepseek: {
label: "DeepSeek",
defaultBaseUrl: "https://api.deepseek.com/v1",
},
siliconflow: { siliconflow: {
label: "SiliconFlow", label: "SiliconFlow",
defaultBaseUrl: "https://api.siliconflow.cn/v1", defaultBaseUrl: "https://api.siliconflow.cn/v1",
@@ -106,7 +121,10 @@ export const PROVIDER_INFO: Record<
label: "SGLang", label: "SGLang",
defaultBaseUrl: "http://127.0.0.1:8000/v1", defaultBaseUrl: "http://127.0.0.1:8000/v1",
}, },
gateway: { label: "AI Gateway" }, gateway: {
label: "AI Gateway",
defaultBaseUrl: "https://ai-gateway.vercel.sh/v1/ai",
},
edgeone: { label: "EdgeOne Pages" }, edgeone: { label: "EdgeOne Pages" },
doubao: { doubao: {
label: "Doubao (ByteDance)", label: "Doubao (ByteDance)",

22
lib/validation-prompts.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* VLM system prompt for diagram validation.
* Note: Response parsing is now handled via AI SDK's structured outputs (generateObject with schema).
*/
export const VALIDATION_SYSTEM_PROMPT = `You are a diagram quality validator. Analyze the rendered diagram image for visual issues.
Evaluate the diagram for the following issues:
1. **Overlapping elements** (critical): Shapes covering each other inappropriately, making content unreadable
2. **Edge routing issues** (critical): Lines/arrows crossing through shapes that are not their source or target
3. **Text readability** (warning): Labels cut off, overlapping, or too small to read
4. **Layout quality** (warning): Poor spacing, misalignment, or cramped elements
5. **Rendering errors** (critical): Incomplete, corrupted, or missing visual elements
Rules:
- Set "valid" to true ONLY if there are no critical issues
- Be specific about which elements have problems (e.g., "The 'Login' box overlaps with 'Register' box")
- Provide actionable suggestions (e.g., "Move the Login box 50 pixels to the left")
- Minor cosmetic issues (slight misalignment, non-uniform spacing) should be warnings, not critical
- Empty diagrams or diagrams with only 1-2 elements should pass unless they have obvious errors
- If the diagram looks generally acceptable, set valid to true even with minor warnings`

38
lib/validation-schema.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Shared validation schema for VLM-based diagram validation.
* This file can be safely imported on both client and server.
*/
import { z } from "zod"
// Schema for structured validation output
export const ValidationResultSchema = z.object({
valid: z.boolean().describe("True if there are no critical issues"),
issues: z
.array(
z.object({
type: z
.enum([
"overlap",
"edge_routing",
"text",
"layout",
"rendering",
])
.describe("Type of visual issue"),
severity: z
.enum(["critical", "warning"])
.describe("Severity level"),
description: z
.string()
.describe("Clear description of the issue"),
}),
)
.describe("List of visual issues found"),
suggestions: z
.array(z.string())
.describe("Actionable suggestions to fix issues"),
})
export type ValidationResult = z.infer<typeof ValidationResultSchema>
export type ValidationIssue = ValidationResult["issues"][number]

616
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.10", "version": "0.4.12-beta.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.10", "version": "0.4.12-beta.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1", "@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -28,6 +28,7 @@
"@next/third-parties": "^16.0.6", "@next/third-parties": "^16.0.6",
"@opennextjs/cloudflare": "1.14.8", "@opennextjs/cloudflare": "1.14.8",
"@openrouter/ai-sdk-provider": "^1.5.4", "@openrouter/ai-sdk-provider": "^1.5.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.209.0", "@opentelemetry/exporter-trace-otlp-http": "^0.209.0",
"@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -105,7 +106,7 @@
"vite-tsconfig-paths": "^6.0.3", "vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16", "vitest": "^4.0.16",
"wait-on": "^9.0.3", "wait-on": "^9.0.3",
"wrangler": "4.58.0" "wrangler": "^4.60.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "^4.1.18",
@@ -6127,37 +6128,22 @@
} }
}, },
"node_modules/@cloudflare/kv-asset-handler": { "node_modules/@cloudflare/kv-asset-handler": {
"version": "0.4.1", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz",
"integrity": "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==", "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": {
"mime": "^3.0.0"
},
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@cloudflare/kv-asset-handler/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@cloudflare/unenv-preset": { "node_modules/@cloudflare/unenv-preset": {
"version": "2.8.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.8.0.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.11.0.tgz",
"integrity": "sha512-oIAu6EdQ4zJuPwwKr9odIEqd8AV96z1aqi3RBEA4iKaJ+Vd3fvuI6m5EDC7/QCv+oaPIhy1SkYBYxmD09N+oZg==", "integrity": "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg==",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"peerDependencies": { "peerDependencies": {
"unenv": "2.0.0-rc.24", "unenv": "2.0.0-rc.24",
"workerd": "^1.20251202.0" "workerd": "^1.20260115.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"workerd": { "workerd": {
@@ -6166,9 +6152,9 @@
} }
}, },
"node_modules/@cloudflare/workerd-darwin-64": { "node_modules/@cloudflare/workerd-darwin-64": {
"version": "1.20260107.1", "version": "1.20260120.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260107.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260120.0.tgz",
"integrity": "sha512-Srwe/IukVppkMU2qTndkFaKCmZBI7CnZoq4Y0U0gD/8158VGzMREHTqCii4IcCeHifwrtDqTWu8EcA1VBKI4mg==", "integrity": "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -6182,9 +6168,9 @@
} }
}, },
"node_modules/@cloudflare/workerd-darwin-arm64": { "node_modules/@cloudflare/workerd-darwin-arm64": {
"version": "1.20260107.1", "version": "1.20260120.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260107.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260120.0.tgz",
"integrity": "sha512-aAYwU7zXW+UZFh/a4vHP5cs1ulTOcDRLzwU9547yKad06RlZ6ioRm7ovjdYvdqdmbI8mPd99v4LN9gMmecazQw==", "integrity": "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -6198,9 +6184,9 @@
} }
}, },
"node_modules/@cloudflare/workerd-linux-64": { "node_modules/@cloudflare/workerd-linux-64": {
"version": "1.20260107.1", "version": "1.20260120.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260107.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260120.0.tgz",
"integrity": "sha512-Wh7xWtFOkk6WY3CXe3lSqZ1anMkFcwy+qOGIjtmvQ/3nCOaG34vKNwPIE9iwryPupqkSuDmEqkosI1UUnSTh1A==", "integrity": "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -6214,9 +6200,9 @@
} }
}, },
"node_modules/@cloudflare/workerd-linux-arm64": { "node_modules/@cloudflare/workerd-linux-arm64": {
"version": "1.20260107.1", "version": "1.20260120.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260107.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260120.0.tgz",
"integrity": "sha512-NI0/5rdssdZZKYHxNG4umTmMzODByq86vSCEk8u4HQbGhRCQo7rV1eXn84ntSBdyWBzWdYGISCbeZMsgfIjSTg==", "integrity": "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -6230,9 +6216,9 @@
} }
}, },
"node_modules/@cloudflare/workerd-windows-64": { "node_modules/@cloudflare/workerd-windows-64": {
"version": "1.20260107.1", "version": "1.20260120.0",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260107.1.tgz", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260120.0.tgz",
"integrity": "sha512-gmBMqs606Gd/IhBEBPSL/hJAqy2L8IyPUjKtoqd/Ccy7GQxbSc0rYlRkxbQ9YzmqnuhrTVYvXuLscyWrpmAJkw==", "integrity": "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -7846,7 +7832,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -11984,9 +11969,9 @@
} }
}, },
"node_modules/@speed-highlight/core": { "node_modules/@speed-highlight/core": {
"version": "1.2.12", "version": "1.2.14",
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.12.tgz", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
"integrity": "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA==", "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==",
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
@@ -13547,15 +13532,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/acorn-walk": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
@@ -15053,19 +15029,6 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc" "react-dom": "^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -15084,16 +15047,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/colorette": { "node_modules/colorette": {
"version": "2.0.20", "version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -17173,18 +17126,6 @@
"which": "bin/which" "which": "bin/which"
} }
}, },
"node_modules/exit-hook": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
"integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -18064,12 +18005,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/glob-to-regexp": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
},
"node_modules/global-agent": { "node_modules/global-agent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz",
@@ -18863,12 +18798,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-async-function": { "node_modules/is-async-function": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
@@ -21283,20 +21212,15 @@
} }
}, },
"node_modules/miniflare": { "node_modules/miniflare": {
"version": "4.20260107.0", "version": "4.20260120.0",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260107.0.tgz", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260120.0.tgz",
"integrity": "sha512-X93sXczqbBq9ixoM6jnesmdTqp+4baVC/aM/DuPpRS0LK0XtcqaO75qPzNEvDEzBAHxwMAWRIum/9hg32YB8iA==", "integrity": "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "0.8.1", "@cspotcode/source-map-support": "0.8.1",
"acorn": "8.14.0", "sharp": "^0.34.5",
"acorn-walk": "8.3.2", "undici": "7.18.2",
"exit-hook": "2.2.1", "workerd": "1.20260120.0",
"glob-to-regexp": "0.4.1",
"sharp": "^0.33.5",
"stoppable": "1.1.0",
"undici": "7.14.0",
"workerd": "1.20260107.1",
"ws": "8.18.0", "ws": "8.18.0",
"youch": "4.1.0-beta.10", "youch": "4.1.0-beta.10",
"zod": "^3.25.76" "zod": "^3.25.76"
@@ -21308,418 +21232,6 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/miniflare/node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/miniflare/node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/miniflare/node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/miniflare/node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/miniflare/node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/miniflare/node_modules/ws": { "node_modules/miniflare/node_modules/ws": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
@@ -24411,7 +23923,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -24641,15 +24152,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-update-notifier": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -24857,16 +24359,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/stoppable": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
"integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
"license": "MIT",
"engines": {
"node": ">=4",
"npm": ">=6"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -26040,9 +25532,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.14.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.14.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.18.2.tgz",
"integrity": "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==", "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
@@ -26864,9 +26356,9 @@
} }
}, },
"node_modules/workerd": { "node_modules/workerd": {
"version": "1.20260107.1", "version": "1.20260120.0",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260107.1.tgz", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260120.0.tgz",
"integrity": "sha512-4ylAQJDdJZdMAUl2SbJgTa77YHpa88l6qmhiuCLNactP933+rifs7I0w1DslhUIFgydArUX5dNLAZnZhT7Bh7g==", "integrity": "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
@@ -26877,28 +26369,28 @@
"node": ">=16" "node": ">=16"
}, },
"optionalDependencies": { "optionalDependencies": {
"@cloudflare/workerd-darwin-64": "1.20260107.1", "@cloudflare/workerd-darwin-64": "1.20260120.0",
"@cloudflare/workerd-darwin-arm64": "1.20260107.1", "@cloudflare/workerd-darwin-arm64": "1.20260120.0",
"@cloudflare/workerd-linux-64": "1.20260107.1", "@cloudflare/workerd-linux-64": "1.20260120.0",
"@cloudflare/workerd-linux-arm64": "1.20260107.1", "@cloudflare/workerd-linux-arm64": "1.20260120.0",
"@cloudflare/workerd-windows-64": "1.20260107.1" "@cloudflare/workerd-windows-64": "1.20260120.0"
} }
}, },
"node_modules/wrangler": { "node_modules/wrangler": {
"version": "4.58.0", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.58.0.tgz", "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.60.0.tgz",
"integrity": "sha512-Jm6EYtlt8iUcznOCPSMYC54DYkwrMNESzbH0Vh3GFHv/7XVw5gBC13YJAB+nWMRGJ+6B2dMzy/NVQS4ONL51Pw==", "integrity": "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g==",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/kv-asset-handler": "0.4.2",
"@cloudflare/unenv-preset": "2.8.0", "@cloudflare/unenv-preset": "2.11.0",
"blake3-wasm": "2.1.5", "blake3-wasm": "2.1.5",
"esbuild": "0.27.0", "esbuild": "0.27.0",
"miniflare": "4.20260107.0", "miniflare": "4.20260120.0",
"path-to-regexp": "6.3.0", "path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24", "unenv": "2.0.0-rc.24",
"workerd": "1.20260107.1" "workerd": "1.20260120.0"
}, },
"bin": { "bin": {
"wrangler": "bin/wrangler.js", "wrangler": "bin/wrangler.js",
@@ -26911,7 +26403,7 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
}, },
"peerDependencies": { "peerDependencies": {
"@cloudflare/workers-types": "^4.20260107.1" "@cloudflare/workers-types": "^4.20260120.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@cloudflare/workers-types": { "@cloudflare/workers-types": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.11", "version": "0.4.12",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -139,7 +139,7 @@
"vite-tsconfig-paths": "^6.0.3", "vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16", "vitest": "^4.0.16",
"wait-on": "^9.0.3", "wait-on": "^9.0.3",
"wrangler": "4.58.0" "wrangler": "^4.60.0"
}, },
"overrides": { "overrides": {
"@openrouter/ai-sdk-provider": { "@openrouter/ai-sdk-provider": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.13", "version": "0.1.15",
"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

@@ -44,6 +44,17 @@ function isLikelyMcpSessionId(sessionId: string): boolean {
return sessionId.startsWith("mcp-") && sessionId.length <= 128 return sessionId.startsWith("mcp-") && sessionId.length <= 128
} }
// Find the most recent active session (for auto-redirect when no sessionId provided)
function getMostRecentSessionId(): string | null {
let mostRecent: { id: string; lastUpdated: Date } | null = null
for (const [sessionId, state] of stateStore) {
if (!mostRecent || state.lastUpdated > mostRecent.lastUpdated) {
mostRecent = { id: sessionId, lastUpdated: state.lastUpdated }
}
}
return mostRecent?.id || null
}
function ensureSessionStateInitialized(sessionId: string): void { function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return if (!isLikelyMcpSessionId(sessionId)) return
@@ -195,6 +206,17 @@ function handleRequest(
if (url.pathname === "/" || url.pathname === "/index.html") { if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || "" const sessionId = url.searchParams.get("mcp") || ""
// Auto-redirect to most recent session if no sessionId provided
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
res.end()
return
}
}
ensureSessionStateInitialized(sessionId) ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
@@ -375,85 +397,202 @@ function getHtmlPage(sessionId: string): string {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Draw.io MCP</title> <title>Next AI Draw.io</title>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; } html, body { width: 100%; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; display: flex; flex-direction: column; } #container { width: 100%; height: 100%; display: flex; flex-direction: column; }
#header { #header {
padding: 8px 16px; background: #1a1a2e; color: #eee; padding: 0 20px; height: 52px;
font-family: system-ui, sans-serif; font-size: 14px; background: linear-gradient(to bottom, #ffffff, #fafbfc);
border-bottom: 1px solid #e8ecf0;
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
position: relative; z-index: 10;
}
#header .brand {
display: flex; align-items: center; gap: 10px;
}
#header .logo {
width: 28px; height: 28px; border-radius: 6px;
background: #18181b;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
#header .title {
font-size: 15px; font-weight: 600; color: #1a1a2e;
letter-spacing: -0.3px;
}
#header .session {
font-size: 11px; color: #8b95a5; font-weight: 400;
background: #f1f3f9; padding: 3px 8px; border-radius: 4px;
margin-left: 12px; font-family: 'SF Mono', Monaco, monospace;
}
#header .right { display: flex; align-items: center; gap: 12px; }
#save-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: linear-gradient(to bottom, #18181b, #27272a);
color: white; border: none; cursor: pointer;
font-weight: 500; font-family: inherit;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
transition: all 0.15s ease;
}
#save-btn svg { width: 14px; height: 14px; }
#save-btn:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.1);
}
#save-btn:active { transform: translateY(0); }
#save-btn:disabled, #history-btn:disabled {
background: #e5e7eb; color: #9ca3af;
cursor: not-allowed; transform: none; box-shadow: none;
} }
#header .session { color: #888; font-size: 12px; }
#header .status { font-size: 12px; }
#header .status.connected { color: #4ade80; }
#header .status.disconnected { color: #f87171; }
#drawio { flex: 1; border: none; }
#history-btn { #history-btn {
position: fixed; bottom: 24px; right: 24px; display: flex; align-items: center; gap: 6px;
width: 48px; height: 48px; border-radius: 50%; padding: 7px 14px; border-radius: 8px; font-size: 13px;
background: #3b82f6; color: white; border: none; cursor: pointer; background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); cursor: pointer; font-weight: 500; font-family: inherit;
display: flex; align-items: center; justify-content: center; transition: all 0.15s ease;
z-index: 1000;
} }
#history-btn:hover { background: #2563eb; } #history-btn svg { width: 14px; height: 14px; }
#history-btn:disabled { background: #6b7280; cursor: not-allowed; } #history-btn:hover {
#history-btn svg { width: 24px; height: 24px; } background: #e4e4e7; border-color: #d4d4d8;
#history-modal { }
#drawio { flex: 1; border: none; }
#history-modal, #save-modal {
display: none; position: fixed; inset: 0; display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 2000; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px);
align-items: center; justify-content: center; z-index: 2000; align-items: center; justify-content: center;
} }
#history-modal.open { display: flex; } #history-modal.open, #save-modal.open { display: flex; }
.modal-content { .modal-content {
background: white; border-radius: 12px; background: white; border-radius: 16px;
width: 90%; max-width: 500px; max-height: 70vh; width: 90%; max-width: 480px; max-height: 70vh;
display: flex; flex-direction: column; display: flex; flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
animation: modalIn 0.2s ease-out;
} }
.modal-header { padding: 16px; border-bottom: 1px solid #e5e7eb; } @keyframes modalIn {
.modal-header h2 { font-size: 18px; margin: 0; } from { opacity: 0; transform: scale(0.95) translateY(-10px); }
.modal-body { flex: 1; overflow-y: auto; padding: 16px; } to { opacity: 1; transform: scale(1) translateY(0); }
.modal-footer { padding: 12px 16px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; justify-content: flex-end; } }
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .modal-header {
padding: 20px 24px 16px; border-bottom: 1px solid #f1f3f5;
}
.modal-header h2 {
font-size: 17px; font-weight: 600; margin: 0; color: #18181b;
letter-spacing: -0.3px;
}
.modal-body { flex: 1; overflow-y: auto; padding: 20px 24px; }
.modal-footer {
padding: 16px 24px; border-top: 1px solid #f1f3f5;
display: flex; gap: 10px; justify-content: flex-end;
}
.history-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.history-item { .history-item {
border: 2px solid #e5e7eb; border-radius: 8px; padding: 8px; border: 2px solid #e4e4e7; border-radius: 10px; padding: 10px;
cursor: pointer; text-align: center; cursor: pointer; text-align: center; transition: all 0.15s ease;
background: #fafafa;
}
.history-item:hover { border-color: #a1a1aa; background: white; }
.history-item.selected {
border-color: #18181b; background: white;
box-shadow: 0 0 0 3px rgba(24,24,27,0.1);
} }
.history-item:hover { border-color: #3b82f6; }
.history-item.selected { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.3); }
.history-item .thumb { .history-item .thumb {
aspect-ratio: 4/3; background: #f3f4f6; border-radius: 4px; aspect-ratio: 4/3; background: #f4f4f5; border-radius: 6px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
margin-bottom: 4px; overflow: hidden; margin-bottom: 6px; overflow: hidden;
} }
.history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; } .history-item .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.history-item .label { font-size: 12px; color: #666; } .history-item .label { font-size: 11px; color: #71717a; font-weight: 500; }
.btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; } .btn {
.btn-primary { background: #3b82f6; color: white; } padding: 9px 18px; border-radius: 8px; font-size: 13px;
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; } cursor: pointer; border: none; font-weight: 500;
.btn-secondary { background: #f3f4f6; color: #374151; } font-family: inherit; transition: all 0.15s ease;
.empty { text-align: center; padding: 40px; color: #666; } }
.btn-primary {
background: linear-gradient(to bottom, #18181b, #27272a);
color: white;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.1);
}
.btn-primary:hover {
background: linear-gradient(to bottom, #27272a, #3f3f46);
transform: translateY(-1px);
}
.btn-primary:disabled {
background: #e4e4e7; color: #a1a1aa;
cursor: not-allowed; transform: none; box-shadow: none;
}
.btn-secondary {
background: #f4f4f5; color: #3f3f46; border: 1px solid #e4e4e7;
}
.btn-secondary:hover { background: #e4e4e7; }
.empty { text-align: center; padding: 40px; color: #71717a; font-size: 14px; }
.form-group { margin-bottom: 18px; }
.form-group label {
display: block; font-size: 13px; font-weight: 500;
margin-bottom: 8px; color: #3f3f46;
}
.form-group select, .form-group input {
width: 100%; padding: 10px 14px; border: 1px solid #e4e4e7;
border-radius: 8px; font-size: 14px; outline: none;
font-family: inherit; background: white;
transition: all 0.15s ease;
}
.form-group select:focus, .form-group input:focus {
border-color: #18181b;
box-shadow: 0 0 0 3px rgba(24,24,27,0.08);
}
.filename-group { display: flex; }
.filename-group input { border-radius: 8px 0 0 8px; border-right: none; }
.filename-group .ext {
padding: 10px 14px; background: #f4f4f5; border: 1px solid #e4e4e7;
border-radius: 0 8px 8px 0; font-size: 13px; color: #71717a;
font-family: 'SF Mono', Monaco, monospace;
}
</style> </style>
</head> </head>
<body> <body>
<div id="container"> <div id="container">
<div id="header"> <div id="header">
<div> <div class="brand">
<strong>Draw.io MCP</strong> <div class="logo">
<span class="session">${sessionId ? `Session: ${sessionId}` : "No session"}</span> <svg viewBox="0 0 1536 1536" fill="#ffffff">
<g transform="translate(0,1536) scale(0.1,-0.1)">
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431 -560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356 319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85 -17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529 -2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128 -82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303 -177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276 286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242 241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337 -25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22 -5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204 -383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72 -167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300 6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884 -15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534 c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154 11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448 -20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10 -47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293 -369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6 39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52 -149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415 20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50 118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0 535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539 376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70 290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
</g>
</svg>
</div>
<span class="title">Next AI Draw.io</span>
${sessionId ? `<span class="session">${sessionId.slice(-8)}</span>` : ""}
</div>
<div class="right">
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
History
</button>
<button id="save-btn" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Download
</button>
</div> </div>
<div id="status" class="status disconnected">Connecting...</div>
</div> </div>
<iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1"></iframe> <iframe id="drawio" src="${normalizeUrl(DRAWIO_BASE_URL)}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0"></iframe>
</div> </div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<div id="history-modal"> <div id="history-modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"><h2>History</h2></div> <div class="modal-header"><h2>History</h2></div>
@@ -467,10 +606,35 @@ function getHtmlPage(sessionId: string): string {
</div> </div>
</div> </div>
</div> </div>
<div id="save-modal">
<div class="modal-content">
<div class="modal-header"><h2>Download Diagram</h2></div>
<div class="modal-body">
<div class="form-group">
<label>Format</label>
<select id="save-format">
<option value="drawio">Draw.io (.drawio)</option>
<option value="png">PNG Image (.png)</option>
<option value="svg">SVG Vector (.svg)</option>
</select>
</div>
<div class="form-group">
<label>Filename</label>
<div class="filename-group">
<input type="text" id="save-filename" value="diagram" placeholder="Enter filename">
<span class="ext" id="save-ext">.drawio</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="save-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="save-confirm-btn">Save</button>
</div>
</div>
</div>
<script> <script>
const sessionId = "${sessionId}"; const sessionId = "${sessionId}";
const iframe = document.getElementById('drawio'); const iframe = document.getElementById('drawio');
const statusEl = document.getElementById('status');
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null; let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
let pendingSvgExport = null; let pendingSvgExport = null;
let pendingAiSvg = false; let pendingAiSvg = false;
@@ -481,8 +645,6 @@ function getHtmlPage(sessionId: string): string {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.event === 'init') { if (msg.event === 'init') {
isReady = true; isReady = true;
statusEl.textContent = 'Ready';
statusEl.className = 'status connected';
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; } if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) { } else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
// Request SVG export, then push state with SVG // Request SVG export, then push state with SVG
@@ -491,6 +653,23 @@ function getHtmlPage(sessionId: string): string {
// Fallback if export doesn't respond // 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, ''); pendingSvgExport = null; } }, 2000);
} else if (msg.event === 'export' && msg.data) { } else if (msg.event === 'export' && msg.data) {
// Handle file download export (PNG/SVG only, drawio uses lastXml directly)
if (pendingDownload && (pendingDownload.format === 'png' || pendingDownload.format === 'svg')) {
const dl = pendingDownload;
pendingDownload = null;
let dataUrl = msg.data;
if (!dataUrl.startsWith('data:')) {
const mime = dl.format === 'png' ? 'image/png' : 'image/svg+xml';
dataUrl = 'data:' + mime + ';base64,' + btoa(unescape(encodeURIComponent(msg.data)));
}
const a = document.createElement('a');
a.href = dataUrl; a.download = dl.filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
return;
}
// Handle sync export (XML format) - server requested fresh state // Handle sync export (XML format) - server requested fresh state
if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) { if (pendingSyncExport && !msg.data.startsWith('data:') && !msg.data.startsWith('<svg')) {
pendingSyncExport = false; pendingSyncExport = false;
@@ -563,6 +742,64 @@ function getHtmlPage(sessionId: string): string {
if (sessionId) { poll(); setInterval(poll, 2000); } if (sessionId) { poll(); setInterval(poll, 2000); }
// Save modal
const saveBtn = document.getElementById('save-btn');
const saveModal = document.getElementById('save-modal');
const saveFormat = document.getElementById('save-format');
const saveFilename = document.getElementById('save-filename');
const saveExt = document.getElementById('save-ext');
const saveCancelBtn = document.getElementById('save-cancel-btn');
const saveConfirmBtn = document.getElementById('save-confirm-btn');
let pendingDownload = null;
const extMap = { drawio: '.drawio', png: '.png', svg: '.svg' };
saveBtn.onclick = () => {
if (!sessionId || !isReady) return;
saveModal.classList.add('open');
saveFilename.focus();
saveFilename.select();
};
saveFormat.onchange = () => {
saveExt.textContent = extMap[saveFormat.value] || '.drawio';
};
saveCancelBtn.onclick = () => { saveModal.classList.remove('open'); };
saveModal.onclick = (e) => { if (e.target === saveModal) saveCancelBtn.onclick(); };
saveConfirmBtn.onclick = () => {
const format = saveFormat.value;
const filename = (saveFilename.value.trim() || 'diagram') + extMap[format];
saveConfirmBtn.disabled = true;
saveConfirmBtn.textContent = 'Exporting...';
if (format === 'drawio') {
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
let xmlData = lastXml || '';
if (xmlData && !xmlData.includes('<mxfile')) {
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
}
const blob = new Blob([xmlData], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
saveModal.classList.remove('open');
saveConfirmBtn.disabled = false;
saveConfirmBtn.textContent = 'Save';
} else if (format === 'png') {
pendingDownload = { format: 'png', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'png', scale: 2 }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
} else if (format === 'svg') {
pendingDownload = { format: 'svg', filename };
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
setTimeout(() => { saveConfirmBtn.disabled = false; saveConfirmBtn.textContent = 'Save'; pendingDownload = null; }, 5000);
}
};
// History UI // History UI
const historyBtn = document.getElementById('history-btn'); const historyBtn = document.getElementById('history-btn');
const historyModal = document.getElementById('history-modal'); const historyModal = document.getElementById('history-modal');

View File

@@ -31,6 +31,7 @@ export function proxy(request: NextRequest) {
if ( if (
pathname.startsWith("/api/") || pathname.startsWith("/api/") ||
pathname.startsWith("/_next/") || pathname.startsWith("/_next/") ||
pathname.startsWith("/drawio") ||
pathname.includes("/favicon") || pathname.includes("/favicon") ||
/\.(.*)$/.test(pathname) /\.(.*)$/.test(pathname)
) { ) {

View File

@@ -1,6 +1,7 @@
/** /**
* electron-builder afterPack hook * electron-builder afterPack hook
* Copies node_modules to the standalone directory in the packaged app * Copies node_modules to the standalone directory in the packaged app
* and ad-hoc signs macOS apps for offline draw.io bundle compatibility
*/ */
const { const {
@@ -12,6 +13,7 @@ const {
statSync, statSync,
} = require("fs") } = require("fs")
const path = require("path") const path = require("path")
const { execSync } = require("child_process")
/** /**
* Copy directory recursively, converting symlinks to regular files/directories. * Copy directory recursively, converting symlinks to regular files/directories.
@@ -80,4 +82,22 @@ module.exports = async (context) => {
"Ensure 'npm run electron:prepare' was run before building.", "Ensure 'npm run electron:prepare' was run before building.",
) )
} }
// Ad-hoc sign macOS apps to fix signature issues with bundled draw.io files
if (context.packager.platform.name === "mac") {
const appPath = path.join(
appOutDir,
`${context.packager.appInfo.productFilename}.app`,
)
console.log(`[afterPack] Ad-hoc signing macOS app: ${appPath}`)
try {
execSync(`codesign --force --deep --sign - "${appPath}"`, {
stdio: "inherit",
})
console.log("[afterPack] Ad-hoc signing completed successfully")
} catch (error) {
console.error("[afterPack] Ad-hoc signing failed:", error.message)
throw error
}
}
} }

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest"
import {
formatValidationFeedback,
type ValidationResult,
} from "@/lib/diagram-validator"
describe("formatValidationFeedback", () => {
it("formats result with critical issues", () => {
const result: ValidationResult = {
valid: false,
issues: [
{
type: "overlap",
severity: "critical",
description: "Box A overlaps with Box B",
},
],
suggestions: ["Move Box A to the left"],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("DIAGRAM VISUAL VALIDATION FAILED")
expect(feedback).toContain("Critical Issues (must fix):")
expect(feedback).toContain("[overlap] Box A overlaps with Box B")
expect(feedback).toContain("Suggestions to fix:")
expect(feedback).toContain("Move Box A to the left")
expect(feedback).toContain(
"Please regenerate the diagram with corrected layout",
)
})
it("formats result with warnings only", () => {
const result: ValidationResult = {
valid: true,
issues: [
{
type: "text",
severity: "warning",
description: "Label text is small",
},
],
suggestions: [],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("Warnings:")
expect(feedback).toContain("[text] Label text is small")
expect(feedback).not.toContain("Critical Issues")
})
it("formats result with both critical issues and warnings", () => {
const result: ValidationResult = {
valid: false,
issues: [
{
type: "edge_routing",
severity: "critical",
description: "Edge crosses through node",
},
{
type: "layout",
severity: "warning",
description: "Uneven spacing",
},
],
suggestions: ["Reroute the edge", "Adjust spacing"],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("Critical Issues (must fix):")
expect(feedback).toContain("[edge_routing] Edge crosses through node")
expect(feedback).toContain("Warnings:")
expect(feedback).toContain("[layout] Uneven spacing")
expect(feedback).toContain("Reroute the edge")
expect(feedback).toContain("Adjust spacing")
})
it("returns empty string for valid result with no issues", () => {
const result: ValidationResult = {
valid: true,
issues: [],
suggestions: [],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toBe("")
})
it("formats result with multiple suggestions", () => {
const result: ValidationResult = {
valid: false,
issues: [
{
type: "rendering",
severity: "critical",
description: "Missing element",
},
],
suggestions: [
"Check the XML syntax",
"Ensure all elements are defined",
"Verify parent-child relationships",
],
}
const feedback = formatValidationFeedback(result)
expect(feedback).toContain("Check the XML syntax")
expect(feedback).toContain("Ensure all elements are defined")
expect(feedback).toContain("Verify parent-child relationships")
})
})

View File

@@ -30,5 +30,11 @@
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": ["node_modules", "packages", "electron", "dist-electron"] "exclude": [
"node_modules",
"packages",
"electron",
"electron-standalone",
"dist-electron"
]
} }