feat: image capability detection and user-friendly errors; warn and filter images for unsupported models

This commit is contained in:
Your Name
2025-12-19 14:12:31 +08:00
parent 0d50a179aa
commit 691c7e34bf
3 changed files with 155 additions and 22 deletions
+18 -3
View File
@@ -58,6 +58,7 @@ interface ValidationResult {
function validateFiles(
newFiles: File[],
existingCount: number,
imageSupported: boolean,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
@@ -74,7 +75,10 @@ function validateFiles(
errors.push(`Only ${availableSlots} more file(s) allowed`)
break
}
if (!isValidFileType(file)) {
if (
!isValidFileType(file) ||
(!imageSupported && file.type.startsWith("image/"))
) {
errors.push(`"${file.name}" is not a supported file type`)
continue
}
@@ -137,6 +141,7 @@ interface ChatInputProps {
error?: Error | null
minimalStyle?: boolean
onMinimalStyleChange?: (value: boolean) => void
imageSupported?: boolean
}
export function ChatInput({
@@ -154,6 +159,7 @@ export function ChatInput({
error = null,
minimalStyle = false,
onMinimalStyleChange = () => {},
imageSupported = true,
}: ChatInputProps) {
const {
diagramHistory,
@@ -224,6 +230,7 @@ export function ChatInput({
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
imageSupported,
)
showValidationErrors(errors)
if (validFiles.length > 0) {
@@ -234,7 +241,11 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(newFiles, files.length)
const { validFiles, errors } = validateFiles(
newFiles,
files.length,
imageSupported,
)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles])
@@ -428,7 +439,11 @@ export function ChatInput({
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent="Upload file (image, PDF, text)"
tooltipContent={
imageSupported
? "Upload file (image, PDF, text)"
: "Upload file (PDF, text)"
}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
+61 -19
View File
@@ -23,6 +23,10 @@ import { SettingsDialog } from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { getAIConfig } from "@/lib/ai-config"
import { findCachedResponse } from "@/lib/cached-responses"
import {
resolveImageSupport,
setCachedImageCapability,
} from "@/lib/model-capabilities"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
import { type FileData, useFileProcessor } from "@/lib/use-file-processor"
import { useQuotaManager } from "@/lib/use-quota-manager"
@@ -581,9 +585,15 @@ Continue from EXACTLY where you stopped.`,
"Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength."
}
// Translate image not supported error
if (friendlyMessage.includes("image content block")) {
if (
friendlyMessage.includes("image content block") ||
friendlyMessage.toLowerCase().includes("image_url") ||
friendlyMessage.toLowerCase().includes("unknown variant")
) {
friendlyMessage = "This model doesn't support image input."
// Cache capability as unsupported for current provider/model
const cfg = getAIConfig()
setCachedImageCapability(cfg.aiProvider, cfg.aiModel, false)
}
// Add system message for error so it can be cleared
@@ -959,7 +969,29 @@ Continue from EXACTLY where you stopped.`,
// Check all quota limits
if (!checkAllQuotaLimits()) return
sendChatMessage(parts, chartXml, previousXml, sessionId)
{
const config = getAIConfig()
const provider = config.aiProvider
const modelId = config.aiModel
const canUseImages = resolveImageSupport(provider, modelId)
const hasImage =
files.some((f) => !isPdfFile(f) && !isTextFile(f)) &&
parts.some((p) => p.type === "file")
if (hasImage && !canUseImages) {
const filtered = parts.filter((p) => p.type !== "file")
sendChatMessage(
filtered,
chartXml,
previousXml,
sessionId,
)
toast.warning(
"当前模型不支持图片输入,已忽略上传的图片",
)
} else {
sendChatMessage(parts, chartXml, previousXml, sessionId)
}
}
// Token count is tracked in onFinish with actual server usage
setInput("")
@@ -1387,22 +1419,32 @@ Continue from EXACTLY where you stopped.`,
<footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
>
<ChatInput
input={input}
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={handleNewChat}
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
/>
{(() => {
const cfg = getAIConfig()
const imgCap = resolveImageSupport(
cfg.aiProvider,
cfg.aiModel,
)
return (
<ChatInput
input={input}
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={handleNewChat}
files={files}
onFileChange={handleFileChange}
pdfData={pdfData}
showHistory={showHistory}
onToggleHistory={setShowHistory}
sessionId={sessionId}
error={error}
minimalStyle={minimalStyle}
onMinimalStyleChange={setMinimalStyle}
imageSupported={imgCap}
/>
)
})()}
</footer>
<SettingsDialog
+76
View File
@@ -0,0 +1,76 @@
export function supportsImages(provider?: string, modelId?: string): boolean {
const p = (provider || "").toLowerCase()
const m = (modelId || "").toLowerCase()
if (!p && !m) return false
if (p === "openai" && m.includes("gpt-4o")) return true
if (p === "google" || m.includes("gemini")) return true
if (p === "siliconflow") {
if (m.includes("vl")) return true
if (m.includes("glm-4v")) return true
if (m.includes("yi-vl")) return true
return false
}
if (p === "openrouter") {
if (m.includes("gpt-4o")) return true
if (m.includes("gemini")) return true
if (m.includes("vl")) return true
if (m.includes("glm-4v")) return true
if (m.includes("yi-vl")) return true
return false
}
return false
}
const LS_KEY = "next-ai-draw-io-image-capabilities"
function readCache(): Record<string, boolean> {
if (typeof window === "undefined") return {}
try {
const raw = localStorage.getItem(LS_KEY)
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
} catch {
return {}
}
}
function writeCache(cache: Record<string, boolean>) {
if (typeof window === "undefined") return
try {
localStorage.setItem(LS_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
function capabilityKey(provider?: string, modelId?: string): string {
return `${(provider || "").toLowerCase()}::${(modelId || "").toLowerCase()}`
}
export function getCachedImageCapability(
provider?: string,
modelId?: string,
): boolean | undefined {
const cache = readCache()
const key = capabilityKey(provider, modelId)
return key in cache ? cache[key] : undefined
}
export function setCachedImageCapability(
provider?: string,
modelId?: string,
supported?: boolean,
) {
if (supported === undefined) return
const cache = readCache()
cache[capabilityKey(provider, modelId)] = supported
writeCache(cache)
}
export function resolveImageSupport(
provider?: string,
modelId?: string,
): boolean {
const cached = getCachedImageCapability(provider, modelId)
if (cached !== undefined) return cached
return supportsImages(provider, modelId)
}