Compare commits

..

1 Commits

Author SHA1 Message Date
dayuan.jiang
e598891322 fix: detect models that don't support image input and return clear error
Some models (Kimi K2, DeepSeek, Qwen text models) don't support image/vision
input. The AI SDK silently drops unsupported image parts, causing confusing
responses where the model acts as if no image was uploaded.

Added supportsImageInput() function to detect unsupported models by name,
and return a 400 error with clear guidance when users try to upload images
to these models.

Closes #469
2025-12-31 12:14:37 +09:00
16 changed files with 1242 additions and 4432 deletions

View File

@@ -38,7 +38,7 @@ jobs:
cache: "npm" cache: "npm"
- name: Install dependencies - name: Install dependencies
run: npm install run: npm ci
- name: Build and publish Electron app - name: Build and publish Electron app
run: npm run dist:${{ matrix.platform }} run: npm run dist:${{ matrix.platform }}

View File

@@ -35,7 +35,6 @@ export default function Home() {
const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min") const [drawioUi, setDrawioUi] = useState<"min" | "sketch">("min")
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 [closeProtection, setCloseProtection] = useState(false) const [closeProtection, setCloseProtection] = useState(false)
const chatPanelRef = useRef<ImperativePanelHandle>(null) const chatPanelRef = useRef<ImperativePanelHandle>(null)
@@ -105,18 +104,12 @@ export default function Home() {
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
const handleDrawioLoad = useCallback(() => {
setIsDrawioReady(true)
onDrawioLoad()
}, [onDrawioLoad])
const handleDarkModeChange = async () => { const handleDarkModeChange = async () => {
await saveDiagramToStorage() await saveDiagramToStorage()
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue)) localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
document.documentElement.classList.toggle("dark", newValue) document.documentElement.classList.toggle("dark", newValue)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
@@ -125,7 +118,6 @@ export default function Home() {
const newUi = drawioUi === "min" ? "sketch" : "min" const newUi = drawioUi === "min" ? "sketch" : "min"
localStorage.setItem("drawio-theme", newUi) localStorage.setItem("drawio-theme", newUi)
setDrawioUi(newUi) setDrawioUi(newUi)
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
@@ -139,7 +131,6 @@ export default function Home() {
newIsMobile !== isMobileRef.current newIsMobile !== isMobileRef.current
) { ) {
saveDiagramToStorage().catch(() => {}) saveDiagramToStorage().catch(() => {})
setIsDrawioReady(false)
resetDrawioReady() resetDrawioReady()
} }
isMobileRef.current = newIsMobile isMobileRef.current = newIsMobile
@@ -215,21 +206,18 @@ export default function Home() {
mouseOverDrawioRef.current = false mouseOverDrawioRef.current = false
}} }}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30">
{isLoaded && ( {isLoaded ? (
<div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
>
<DrawIoEmbed <DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}`} key={`${drawioUi}-${darkMode}-${currentLang}`}
ref={drawioRef} ref={drawioRef}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={onDrawioLoad}
onSave={handleDrawioSave} onSave={handleDrawioSave}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
spin: false, spin: true,
libraries: false, libraries: false,
saveAndExit: false, saveAndExit: false,
noExitBtn: true, noExitBtn: true,
@@ -237,13 +225,9 @@ export default function Home() {
lang: currentLang, lang: currentLang,
}} }}
/> />
</div> ) : (
)} <div className="h-full w-full flex items-center justify-center bg-background">
{(!isLoaded || !isDrawioReady) && ( <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
<div className="h-full w-full bg-background flex items-center justify-center">
<span className="text-muted-foreground">
Draw.io panel is loading...
</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -193,7 +193,6 @@ interface ChatMessageDisplayProps {
onRegenerate?: (messageIndex: number) => void onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void onEditMessage?: (messageIndex: number, newText: string) => void
status?: "streaming" | "submitted" | "idle" | "error" | "ready" status?: "streaming" | "submitted" | "idle" | "error" | "ready"
isRestored?: boolean
} }
export function ChatMessageDisplay({ export function ChatMessageDisplay({
@@ -206,7 +205,6 @@ export function ChatMessageDisplay({
onRegenerate, onRegenerate,
onEditMessage, onEditMessage,
status = "idle", status = "idle",
isRestored = false,
}: ChatMessageDisplayProps) { }: ChatMessageDisplayProps) {
const dict = useDictionary() const dict = useDictionary()
const { chartXML, loadDiagram: onDisplayChart } = useDiagram() const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
@@ -252,15 +250,6 @@ export function ChatMessageDisplay({
const [expandedPdfSections, setExpandedPdfSections] = useState< const [expandedPdfSections, setExpandedPdfSections] = useState<
Record<string, boolean> Record<string, boolean>
>({}) >({})
// Track message IDs that were restored from localStorage (skip animation for these)
const restoredMessageIdsRef = useRef<Set<string> | null>(null)
// Capture restored message IDs once when isRestored becomes true
useEffect(() => {
if (isRestored && restoredMessageIdsRef.current === null) {
restoredMessageIdsRef.current = new Set(messages.map((m) => m.id))
}
}, [isRestored, messages])
const setCopyState = ( const setCopyState = (
messageId: string, messageId: string,
@@ -294,7 +283,7 @@ export function ChatMessageDisplay({
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
setCopyState(messageId, isToolCall, true) setCopyState(messageId, isToolCall, true)
} catch (_err) { } catch (err) {
// Fallback for non-secure contexts (HTTP) or permission denied // Fallback for non-secure contexts (HTTP) or permission denied
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
textarea.value = text textarea.value = text
@@ -680,8 +669,7 @@ export function ChatMessageDisplay({
const renderToolPart = (part: ToolPartLike) => { const renderToolPart = (part: ToolPartLike) => {
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 const isExpanded = expandedTools[callId] ?? true
const isExpanded = expandedTools[callId] ?? state !== "output-available"
const toolName = part.type?.replace("tool-", "") const toolName = part.type?.replace("tool-", "")
const isCopied = copiedToolCallId === callId const isCopied = copiedToolCallId === callId
@@ -871,9 +859,9 @@ export function ChatMessageDisplay({
return ( return (
<ScrollArea className="h-full w-full scrollbar-thin"> <ScrollArea className="h-full w-full scrollbar-thin">
{messages.length === 0 && isRestored ? ( {messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} /> <ExamplePanel setInput={setInput} setFiles={setFiles} />
) : messages.length === 0 ? null : ( ) : (
<div className="py-4 px-4 space-y-4"> <div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const userMessageText = const userMessageText =
@@ -893,23 +881,13 @@ export function ChatMessageDisplay({
.slice(messageIndex + 1) .slice(messageIndex + 1)
.every((m) => m.role !== "user")) .every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id const isEditing = editingMessageId === message.id
// Skip animation for restored messages
// If isRestored but ref not set yet, we're in first render after restoration - treat all as restored
const isRestoredMessage =
isRestored &&
(restoredMessageIdsRef.current === null ||
restoredMessageIdsRef.current.has(message.id))
return ( return (
<div <div
key={message.id} key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} ${isRestoredMessage ? "" : "animate-message-in"}`} className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={ style={{
isRestoredMessage
? undefined
: {
animationDelay: `${messageIndex * 50}ms`, animationDelay: `${messageIndex * 50}ms`,
} }}
}
> >
{message.role === "user" && {message.role === "user" &&
userMessageText && userMessageText &&
@@ -1006,9 +984,6 @@ export function ChatMessageDisplay({
isStreaming={ isStreaming={
isStreamingReasoning isStreamingReasoning
} }
defaultOpen={
!isRestoredMessage
}
> >
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent> <ReasoningContent>

View File

@@ -10,13 +10,7 @@ import {
} from "lucide-react" } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import type React from "react" import type React from "react"
import { import { useCallback, useEffect, useRef, useState } from "react"
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { flushSync } from "react-dom" import { flushSync } from "react-dom"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip" import { ButtonWithTooltip } from "@/components/button-with-tooltip"
@@ -34,7 +28,7 @@ import { formatMessage } from "@/lib/i18n/utils"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils" import { isPdfFile, isTextFile } from "@/lib/pdf-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 } from "@/lib/utils" import { formatXML } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display" import { ChatMessageDisplay } from "./chat-message-display"
import { DevXmlSimulator } from "./dev-xml-simulator" import { DevXmlSimulator } from "./dev-xml-simulator"
@@ -207,18 +201,6 @@ export default function ChatPanel({
// Flag to track if we've restored from localStorage // Flag to track if we've restored from localStorage
const hasRestoredRef = useRef(false) const hasRestoredRef = useRef(false)
const [isRestored, setIsRestored] = useState(false)
// Track previous isVisible to only animate when toggling (not on page load)
const prevIsVisibleRef = useRef(isVisible)
const [shouldAnimatePanel, setShouldAnimatePanel] = useState(false)
useEffect(() => {
// Only animate when visibility changes from false to true (not on initial load)
if (!prevIsVisibleRef.current && isVisible) {
setShouldAnimatePanel(true)
}
prevIsVisibleRef.current = isVisible
}, [isVisible])
// Ref to track latest chartXML for use in callbacks (avoids stale closure) // Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML) const chartXMLRef = useRef(chartXML)
@@ -447,8 +429,7 @@ export default function ChatPanel({
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
// Restore messages and XML snapshots from localStorage on mount // Restore messages and XML snapshots from localStorage on mount
// useLayoutEffect runs synchronously before browser paint, so messages appear immediately useEffect(() => {
useLayoutEffect(() => {
if (hasRestoredRef.current) return if (hasRestoredRef.current) return
hasRestoredRef.current = true hasRestoredRef.current = true
@@ -476,10 +457,8 @@ export default function ChatPanel({
localStorage.removeItem(STORAGE_MESSAGES_KEY) localStorage.removeItem(STORAGE_MESSAGES_KEY)
localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY) localStorage.removeItem(STORAGE_XML_SNAPSHOTS_KEY)
toast.error(dict.errors.sessionCorrupted) toast.error(dict.errors.sessionCorrupted)
} finally {
setIsRestored(true)
} }
}, [setMessages, dict.errors.sessionCorrupted]) }, [setMessages])
// Save messages to localStorage whenever they change (debounced to prevent blocking during streaming) // Save messages to localStorage whenever they change (debounced to prevent blocking during streaming)
useEffect(() => { useEffect(() => {
@@ -936,16 +915,12 @@ export default function ChatPanel({
// Full view // Full view
return ( return (
<div <div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
className={cn(
"h-full flex flex-col bg-card shadow-soft rounded-xl border border-border/30 relative",
shouldAnimatePanel && "animate-slide-in-right",
)}
>
<Toaster <Toaster
position="bottom-left" position="bottom-center"
richColors richColors
expand expand
style={{ position: "absolute" }}
toastOptions={{ toastOptions={{
style: { style: {
maxWidth: "480px", maxWidth: "480px",
@@ -1031,7 +1006,6 @@ export default function ChatPanel({
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
status={status} status={status}
onEditMessage={handleEditMessage} onEditMessage={handleEditMessage}
isRestored={isRestored}
/> />
</main> </main>

View File

@@ -21,6 +21,7 @@ import {
Zap, Zap,
} from "lucide-react" } from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -516,6 +517,7 @@ export function ModelConfigDialog({
{/* Provider Details (Right Panel) */} {/* Provider Details (Right Panel) */}
<div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden "> <div className="flex-1 min-w-0 flex flex-col overflow-auto [&::-webkit-scrollbar]:hidden ">
{selectedProvider ? ( {selectedProvider ? (
<>
<ScrollArea className="flex-1" ref={scrollRef}> <ScrollArea className="flex-1" ref={scrollRef}>
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
{/* Provider Header */} {/* Provider Header */}
@@ -557,7 +559,10 @@ export function ModelConfigDialog({
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success"> <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-success-muted text-success">
<Check className="h-3.5 w-3.5 animate-check-pop" /> <Check className="h-3.5 w-3.5 animate-check-pop" />
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{dict.modelConfig.verified} {
dict.modelConfig
.verified
}
</span> </span>
</div> </div>
)} )}
@@ -570,13 +575,18 @@ export function ModelConfigDialog({
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:text-destructive hover:bg-destructive/10"
> >
<Trash2 className="h-4 w-4 mr-1.5" /> <Trash2 className="h-4 w-4 mr-1.5" />
{dict.modelConfig.deleteProvider} {
dict.modelConfig
.deleteProvider
}
</Button> </Button>
</div> </div>
{/* Configuration Section */} {/* Configuration Section */}
<ConfigSection <ConfigSection
title={dict.modelConfig.configuration} title={
dict.modelConfig.configuration
}
icon={Settings2} icon={Settings2}
> >
<ConfigCard> <ConfigCard>
@@ -626,7 +636,8 @@ export function ModelConfigDialog({
> >
<Key className="h-3.5 w-3.5 text-muted-foreground" /> <Key className="h-3.5 w-3.5 text-muted-foreground" />
{ {
dict.modelConfig dict
.modelConfig
.awsAccessKeyId .awsAccessKeyId
} }
</Label> </Label>
@@ -661,7 +672,8 @@ export function ModelConfigDialog({
> >
<Key className="h-3.5 w-3.5 text-muted-foreground" /> <Key className="h-3.5 w-3.5 text-muted-foreground" />
{ {
dict.modelConfig dict
.modelConfig
.awsSecretAccessKey .awsSecretAccessKey
} }
</Label> </Label>
@@ -677,10 +689,13 @@ export function ModelConfigDialog({
selectedProvider.awsSecretAccessKey || selectedProvider.awsSecretAccessKey ||
"" ""
} }
onChange={(e) => onChange={(
e,
) =>
handleProviderUpdate( handleProviderUpdate(
"awsSecretAccessKey", "awsSecretAccessKey",
e.target e
.target
.value, .value,
) )
} }
@@ -722,7 +737,8 @@ export function ModelConfigDialog({
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{ {
dict.modelConfig dict
.modelConfig
.awsRegion .awsRegion
} }
</Label> </Label>
@@ -801,7 +817,8 @@ export function ModelConfigDialog({
</SelectItem> </SelectItem>
<SelectItem value="sa-east-1"> <SelectItem value="sa-east-1">
sa-east-1 sa-east-1
(São Paulo) (São
Paulo)
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -848,7 +865,8 @@ export function ModelConfigDialog({
} }
</> </>
) : ( ) : (
dict.modelConfig dict
.modelConfig
.test .test
)} )}
</Button> </Button>
@@ -904,7 +922,8 @@ export function ModelConfigDialog({
} }
</> </>
) : ( ) : (
dict.modelConfig dict
.modelConfig
.test .test
)} )}
</Button> </Button>
@@ -930,7 +949,8 @@ export function ModelConfigDialog({
> >
<Key className="h-3.5 w-3.5 text-muted-foreground" /> <Key className="h-3.5 w-3.5 text-muted-foreground" />
{ {
dict.modelConfig dict
.modelConfig
.apiKey .apiKey
} }
</Label> </Label>
@@ -1047,7 +1067,8 @@ export function ModelConfigDialog({
> >
<Link2 className="h-3.5 w-3.5 text-muted-foreground" /> <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
{ {
dict.modelConfig dict
.modelConfig
.baseUrl .baseUrl
} }
<span className="text-muted-foreground font-normal"> <span className="text-muted-foreground font-normal">
@@ -1077,7 +1098,8 @@ export function ModelConfigDialog({
.provider .provider
] ]
.defaultBaseUrl || .defaultBaseUrl ||
dict.modelConfig dict
.modelConfig
.customEndpoint .customEndpoint
} }
className="h-9 rounded-xl font-mono text-xs" className="h-9 rounded-xl font-mono text-xs"
@@ -1100,10 +1122,13 @@ export function ModelConfigDialog({
dict.modelConfig dict.modelConfig
.customModelId .customModelId
} }
value={customModelInput} value={
customModelInput
}
onChange={(e) => { onChange={(e) => {
setCustomModelInput( setCustomModelInput(
e.target.value, e.target
.value,
) )
if ( if (
duplicateError duplicateError
@@ -1123,7 +1148,9 @@ export function ModelConfigDialog({
handleAddModel( handleAddModel(
customModelInput.trim(), customModelInput.trim(),
) )
if (success) { if (
success
) {
setCustomModelInput( setCustomModelInput(
"", "",
) )
@@ -1168,7 +1195,9 @@ export function ModelConfigDialog({
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</Button> </Button>
<Select <Select
onValueChange={(value) => { onValueChange={(
value,
) => {
if (value) { if (value) {
handleAddModel( handleAddModel(
value, value,
@@ -1204,7 +1233,9 @@ export function ModelConfigDialog({
} }
className="font-mono text-xs" className="font-mono text-xs"
> >
{modelId} {
modelId
}
</SelectItem> </SelectItem>
), ),
)} )}
@@ -1215,8 +1246,8 @@ export function ModelConfigDialog({
> >
{/* Model List */} {/* Model List */}
<div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]"> <div className="rounded-2xl border border-border-subtle bg-surface-2/30 overflow-hidden min-h-[120px]">
{selectedProvider.models.length === {selectedProvider.models
0 ? ( .length === 0 ? (
<div className="p-6 text-center h-full flex flex-col items-center justify-center"> <div className="p-6 text-center h-full flex flex-col items-center justify-center">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3"> <div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-surface-2 mb-3">
<Sparkles className="h-5 w-5 text-muted-foreground" /> <Sparkles className="h-5 w-5 text-muted-foreground" />
@@ -1233,7 +1264,9 @@ export function ModelConfigDialog({
{selectedProvider.models.map( {selectedProvider.models.map(
(model, index) => ( (model, index) => (
<div <div
key={model.id} key={
model.id
}
className={cn( className={cn(
"transition-colors duration-150 hover:bg-interactive-hover/50", "transition-colors duration-150 hover:bg-interactive-hover/50",
)} )}
@@ -1467,6 +1500,7 @@ export function ModelConfigDialog({
</ConfigSection> </ConfigSection>
</div> </div>
</ScrollArea> </ScrollArea>
</>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center p-8 text-center"> <div className="h-full flex flex-col items-center justify-center p-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-2 mb-4">

View File

@@ -11,7 +11,7 @@ services:
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
ports: ["3000:3000"] ports: ["3000:3000"]
env_file: .env env_file: .env
# environment: environment:
# # For subdirectory deployment, uncomment and set your path: # For subdirectory deployment, uncomment and set your path:
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio # NEXT_PUBLIC_BASE_PATH: /nextaidrawio
depends_on: [drawio] depends_on: [drawio]

View File

@@ -11,6 +11,7 @@ import {
flattenModels, flattenModels,
type ModelConfig, type ModelConfig,
type MultiModelConfig, type MultiModelConfig,
PROVIDER_INFO,
type ProviderConfig, type ProviderConfig,
type ProviderName, type ProviderName,
} from "@/lib/types/model-config" } from "@/lib/types/model-config"

View File

@@ -786,7 +786,7 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
`data: ${JSON.stringify(data)}\n\n`, `data: ${JSON.stringify(data)}\n\n`,
), ),
) )
} catch (_e) { } catch (e) {
// If parsing fails, forward the original message to avoid breaking the stream. // If parsing fails, forward the original message to avoid breaking the stream.
controller.enqueue( controller.enqueue(
new TextEncoder().encode( new TextEncoder().encode(

3759
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-ai-draw-io", "name": "next-ai-draw-io",
"version": "0.4.8", "version": "0.4.7",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -71,7 +71,7 @@
"motion": "^12.23.25", "motion": "^12.23.25",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"next": "^16.0.7", "next": "^16.0.7",
"ollama-ai-provider-v2": "^2.0.0", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1", "prism-react-renderer": "^2.4.1",
"react": "^19.1.2", "react": "^19.1.2",

View File

@@ -64,24 +64,6 @@ Add to Cursor MCP config (`~/.cursor/mcp.json`):
} }
``` ```
### Cline (VS Code Extension)
1. Click the **MCP Servers** icon in Cline's top menu bar
2. Select the **Configure** tab
3. Click **Configure MCP Servers** to edit `cline_mcp_settings.json`
4. Add the drawio server:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"]
}
}
}
```
### Claude Code CLI ### Claude Code CLI
```bash ```bash
@@ -108,7 +90,7 @@ Use the standard MCP configuration with:
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc. - **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions - **Edit Support**: Modify existing diagrams with natural language instructions
- **Export**: Save diagrams as `.drawio` files - **Export**: Save diagrams as `.drawio` files
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from embed.diagrams.net)
## Available Tools ## Available Tools
@@ -148,33 +130,6 @@ Use the standard MCP configuration with:
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server | | `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
### Private Deployment (Self-hosted draw.io)
For security-sensitive environments that require private deployment of draw.io:
```json
{
"mcpServers": {
"drawio": {
"command": "npx",
"args": ["@next-ai-drawio/mcp-server@latest"],
"env": {
"DRAWIO_BASE_URL": "https://drawio.your-company.com"
}
}
}
}
```
You can deploy your own draw.io instance using the official Docker image:
```bash
docker run -d -p 8080:8080 jgraph/drawio
```
Then set `DRAWIO_BASE_URL=http://localhost:8080` (or your server's URL).
## Troubleshooting ## Troubleshooting

View File

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

@@ -13,45 +13,6 @@ import {
} from "./history.js" } from "./history.js"
import { log } from "./logger.js" import { log } from "./logger.js"
// Configurable draw.io embed URL for private deployments
const DRAWIO_BASE_URL =
process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net"
// Extract origin (scheme + host + port) from URL for postMessage security check
function getOrigin(url: string): string {
try {
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.host}`
} catch {
return url // Fallback if parsing fails
}
}
const DRAWIO_ORIGIN = getOrigin(DRAWIO_BASE_URL)
// Minimal blank diagram used to bootstrap new sessions.
// This avoids the draw.io embed spinner (spin=1) getting stuck when no `load(xml)` is ever sent.
const DEFAULT_DIAGRAM_XML = `<mxfile host="app.diagrams.net"><diagram id="blank" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Normalize URL for iframe src - ensure no double slashes
function normalizeUrl(url: string): string {
// Remove trailing slash to avoid double slashes
return url.replace(/\/$/, "")
}
function isLikelyMcpSessionId(sessionId: string): boolean {
// Keep this cheap and conservative to avoid creating state for arbitrary IDs.
return sessionId.startsWith("mcp-") && sessionId.length <= 128
}
function ensureSessionStateInitialized(sessionId: string): void {
if (!sessionId) return
if (!isLikelyMcpSessionId(sessionId)) return
if (stateStore.has(sessionId)) return
setState(sessionId, DEFAULT_DIAGRAM_XML)
}
interface SessionState { interface SessionState {
xml: string xml: string
version: number version: number
@@ -166,12 +127,7 @@ function cleanupExpiredSessions(): void {
} }
} }
const cleanupIntervalId = setInterval(cleanupExpiredSessions, 5 * 60 * 1000) setInterval(cleanupExpiredSessions, 5 * 60 * 1000)
export function shutdown(): void {
clearInterval(cleanupIntervalId)
stopHttpServer()
}
export function getServerPort(): number { export function getServerPort(): number {
return serverPort return serverPort
@@ -194,11 +150,8 @@ function handleRequest(
} }
if (url.pathname === "/" || url.pathname === "/index.html") { if (url.pathname === "/" || url.pathname === "/index.html") {
const sessionId = url.searchParams.get("mcp") || ""
ensureSessionStateInitialized(sessionId)
res.writeHead(200, { "Content-Type": "text/html" }) res.writeHead(200, { "Content-Type": "text/html" })
res.end(getHtmlPage(sessionId)) res.end(getHtmlPage(url.searchParams.get("mcp") || ""))
} else if (url.pathname === "/api/state") { } else if (url.pathname === "/api/state") {
handleStateApi(req, res, url) handleStateApi(req, res, url)
} else if (url.pathname === "/api/history") { } else if (url.pathname === "/api/history") {
@@ -225,7 +178,6 @@ function handleStateApi(
res.end(JSON.stringify({ error: "sessionId required" })) res.end(JSON.stringify({ error: "sessionId required" }))
return return
} }
ensureSessionStateInitialized(sessionId)
const state = stateStore.get(sessionId) const state = stateStore.get(sessionId)
res.writeHead(200, { "Content-Type": "application/json" }) res.writeHead(200, { "Content-Type": "application/json" })
res.end( res.end(
@@ -446,7 +398,7 @@ function getHtmlPage(sessionId: string): string {
</div> </div>
<div id="status" class="status disconnected">Connecting...</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="https://embed.diagrams.net/?embed=1&proto=json&spin=1&libraries=1"></iframe>
</div> </div>
<button id="history-btn" title="History" ${sessionId ? "" : "disabled"}> <button id="history-btn" title="History" ${sessionId ? "" : "disabled"}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -476,7 +428,7 @@ function getHtmlPage(sessionId: string): string {
let pendingAiSvg = false; let pendingAiSvg = false;
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
if (e.origin !== '${DRAWIO_ORIGIN}') return; if (e.origin !== 'https://embed.diagrams.net') return;
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.event === 'init') { if (msg.event === 'init') {

View File

@@ -39,7 +39,6 @@ import {
getState, getState,
requestSync, requestSync,
setState, setState,
shutdown,
startHttpServer, startHttpServer,
waitForSync, waitForSync,
} from "./http-server.js" } from "./http-server.js"
@@ -48,7 +47,7 @@ import { validateAndFixXml } from "./xml-validation.js"
// Server configuration // Server configuration
const config = { const config = {
port: parseInt(process.env.PORT || "6002", 10), port: parseInt(process.env.PORT || "6002"),
} }
// Session state (single session for simplicity) // Session state (single session for simplicity)
@@ -619,31 +618,6 @@ server.registerTool(
}, },
) )
// Graceful shutdown handler
let isShuttingDown = false
function gracefulShutdown(reason: string) {
if (isShuttingDown) return
isShuttingDown = true
log.info(`Shutting down: ${reason}`)
shutdown()
process.exit(0)
}
// Handle stdin close (primary method - works on all platforms including Windows)
process.stdin.on("close", () => gracefulShutdown("stdin closed"))
process.stdin.on("end", () => gracefulShutdown("stdin ended"))
// Handle signals (may not work reliably on Windows)
process.on("SIGINT", () => gracefulShutdown("SIGINT"))
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"))
// Handle broken pipe (writing to closed stdout)
process.stdout.on("error", (err) => {
if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") {
gracefulShutdown("stdout error")
}
})
// Start the MCP server // Start the MCP server
async function main() { async function main() {
log.info("Starting MCP server for Next AI Draw.io (embedded mode)...") log.info("Starting MCP server for Next AI Draw.io (embedded mode)...")

View File

@@ -253,7 +253,7 @@ async function main() {
}, },
) )
console.log("👀 Watching for preset configuration changes...") console.log("👀 Watching for preset configuration changes...")
} catch (_err) { } catch (err) {
// File might not exist yet, that's ok // File might not exist yet, that's ok
setTimeout(setupConfigWatcher, 5000) setTimeout(setupConfigWatcher, 5000)
} }