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
10 changed files with 843 additions and 839 deletions

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

@@ -283,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

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,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

@@ -1,18 +1,18 @@
{ {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.11", "version": "0.1.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@next-ai-drawio/mcp-server", "name": "@next-ai-drawio/mcp-server",
"version": "0.1.11", "version": "0.1.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^4.0.0" "zod": "^3.24.0"
}, },
"bin": { "bin": {
"next-ai-drawio-mcp": "dist/index.js" "next-ai-drawio-mcp": "dist/index.js"
@@ -2051,9 +2051,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.4", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {

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",
@@ -39,7 +39,7 @@
"@modelcontextprotocol/sdk": "^1.0.4", "@modelcontextprotocol/sdk": "^1.0.4",
"linkedom": "^0.18.0", "linkedom": "^0.18.0",
"open": "^11.0.0", "open": "^11.0.0",
"zod": "^4.0.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",

View File

@@ -127,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

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)
} }