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
12 changed files with 868 additions and 958 deletions

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,35 +206,28 @@ 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 <DrawIoEmbed
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} key={`${drawioUi}-${darkMode}-${currentLang}`}
> ref={drawioRef}
<DrawIoEmbed onExport={handleDiagramExport}
key={`${drawioUi}-${darkMode}-${currentLang}`} onLoad={onDrawioLoad}
ref={drawioRef} onSave={handleDrawioSave}
onExport={handleDiagramExport} baseUrl={drawioBaseUrl}
onLoad={handleDrawioLoad} urlParameters={{
onSave={handleDrawioSave} ui: drawioUi,
baseUrl={drawioBaseUrl} spin: true,
urlParameters={{ libraries: false,
ui: drawioUi, saveAndExit: false,
spin: false, noExitBtn: true,
libraries: false, dark: darkMode,
saveAndExit: false, lang: currentLang,
noExitBtn: true, }}
dark: darkMode, />
lang: currentLang, ) : (
}} <div className="h-full w-full flex items-center justify-center bg-background">
/> <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
{(!isLoaded || !isDrawioReady) && (
<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 animationDelay: `${messageIndex * 50}ms`,
? undefined }}
: {
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

@@ -201,7 +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)
// 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)
@@ -458,8 +457,6 @@ 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]) }, [setMessages])
@@ -1009,7 +1006,6 @@ export default function ChatPanel({
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
status={status} status={status}
onEditMessage={handleEditMessage} onEditMessage={handleEditMessage}
isRestored={isRestored}
/> />
</main> </main>

File diff suppressed because it is too large Load Diff

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(

View File

@@ -90,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
@@ -130,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,28 +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)
// Normalize URL for iframe src - ensure no double slashes
function normalizeUrl(url: string): string {
// Remove trailing slash to avoid double slashes
return url.replace(/\/$/, "")
}
interface SessionState { interface SessionState {
xml: string xml: string
version: number version: number
@@ -149,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
@@ -425,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">
@@ -455,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)
} }