From d5f723da1bacbd8e7f32394a8b2dfbc72aa4626f Mon Sep 17 00:00:00 2001 From: Hai Nguyen <3423575+haiphucnguyen@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:29:48 -0700 Subject: [PATCH] feat(image-generation): detect native image support automatically - Probe chat models once to identify native in-chat image generation - Cache image capability results and notify the UI when detection completes - Hide explicit image mode controls for models with native image support - Warn users when image generation is requested without a configured model - Add localized missing image model messages - Bump project version to 1.4.13 --- .../io/askimo/ui/chat/ChatInputField.kt | 110 +++++++++++++----- .../main/resources/i18n/messages.properties | 2 + .../resources/i18n/messages_de.properties | 2 + .../resources/i18n/messages_es.properties | 2 + .../resources/i18n/messages_fr.properties | 2 + .../resources/i18n/messages_ja_JP.properties | 2 + .../resources/i18n/messages_ko_KR.properties | 2 + .../resources/i18n/messages_pt_BR.properties | 2 + .../resources/i18n/messages_vi_VN.properties | 2 + .../resources/i18n/messages_zh_CN.properties | 2 + .../resources/i18n/messages_zh_TW.properties | 2 + gradle.properties | 2 +- .../internal/ImageCapabilityDetectedEvent.kt | 31 +++++ .../askimo/core/providers/ChatModelFactory.kt | 90 ++++++++++++++ .../core/providers/ModelCapabilitiesCache.kt | 49 ++++++++ .../OpenAiCompatibleChatModelFactory.kt | 6 + .../anthropic/AnthropicModelFactory.kt | 6 + .../providers/gemini/GeminiModelFactory.kt | 6 + 18 files changed, 287 insertions(+), 33 deletions(-) create mode 100644 shared/src/main/kotlin/io/askimo/core/event/internal/ImageCapabilityDetectedEvent.kt diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt index 19361479b..61375869a 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt @@ -91,6 +91,7 @@ import io.askimo.core.config.AppConfig import io.askimo.core.context.AppContext import io.askimo.core.event.EventBus import io.askimo.core.event.error.AppErrorEvent +import io.askimo.core.event.internal.ImageCapabilityDetectedEvent import io.askimo.core.event.internal.ReasoningEffortChangedEvent import io.askimo.core.event.internal.ThinkingSupportDetectedEvent import io.askimo.core.i18n.LocalizationManager @@ -200,6 +201,36 @@ fun chatInputField( } } + // Whether the current model supports native image generation (in-chat). + var supportsNativeImageGeneration by remember(resolvedProvider, currentModel) { + mutableStateOf( + if (resolvedProvider != null && currentModel.isNotBlank()) { + ModelCapabilitiesCache.supportsImage(resolvedProvider, currentModel) + } else { + false + }, + ) + } + + val configuredImageModel = resolvedProvider?.let { provider -> + AppConfig.models[provider].imageModel + }.orEmpty() + val hasConfiguredImageModel = configuredImageModel.isNotBlank() + val providerNotSetLabel = stringResource("provider.not.set") + val missingImageModelTitle = stringResource("chat.image.model.missing.title") + + // Listen for image capability probe results and update when it arrives for the active model. + LaunchedEffect(resolvedProvider, currentModel) { + EventBus.internalEvents.collect { event -> + if (event is ImageCapabilityDetectedEvent && + event.provider == resolvedProvider && + event.model == currentModel + ) { + supportsNativeImageGeneration = event.supportsNativeImage + } + } + } + // Reasoning effort — state created once; synced from cache via LaunchedEffect when // provider/model changes so the remember block itself never re-runs on dropdown clicks. var reasoningEffort by remember { mutableStateOf(ReasoningEffort.DEFAULT) } @@ -623,41 +654,55 @@ fun chatInputField( Spacer(modifier = Modifier.width(2.dp)) - themedTooltip( - text = stringResource("chat.create.image.menu"), - ) { - IconButton( - onClick = { - creationMode = if (creationMode is CreationMode.Image) { - CreationMode.Chat - } else { - CreationMode.Image - } - }, - enabled = !isLoading, - colors = if (creationMode is CreationMode.Image) { - AppComponents.primaryIconButtonColors() - } else { - IconButtonDefaults.iconButtonColors() - }, - modifier = Modifier - .size(28.dp) - .pointerHoverIcon(PointerIcon.Hand), + // Image button — only show if model requires explicit toggle mode + // For multi-modal models (native image generation), hide this button + if (!supportsNativeImageGeneration) { + themedTooltip( + text = stringResource("chat.create.image.menu"), ) { - Icon( - Icons.Default.Image, - contentDescription = stringResource("chat.create.image.menu"), - tint = if (creationMode is CreationMode.Image) { - MaterialTheme.colorScheme.primary + IconButton( + onClick = { + if (creationMode is CreationMode.Image) { + creationMode = CreationMode.Chat + } else if (!hasConfiguredImageModel) { + // Keep icon visible but prevent toggling into image mode + creationMode = CreationMode.Chat + val providerName = resolvedProvider?.name ?: providerNotSetLabel + EventBus.post( + AppErrorEvent( + title = missingImageModelTitle, + message = LocalizationManager.getString("chat.image.model.missing.message", providerName), + ), + ) + } else { + creationMode = CreationMode.Image + } + }, + enabled = !isLoading, + colors = if (creationMode is CreationMode.Image) { + AppComponents.primaryIconButtonColors() } else { - MaterialTheme.colorScheme.onSurface + IconButtonDefaults.iconButtonColors() }, - modifier = Modifier.size(16.dp), - ) + modifier = Modifier + .size(28.dp) + .pointerHoverIcon(PointerIcon.Hand), + ) { + Icon( + Icons.Default.Image, + contentDescription = stringResource("chat.create.image.menu"), + tint = if (creationMode is CreationMode.Image) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.size(16.dp), + ) + } } - } - Spacer(modifier = Modifier.width(2.dp)) + Spacer(modifier = Modifier.width(2.dp)) + } toolsIndicatorButton( sessionId = sessionId, @@ -670,8 +715,9 @@ fun chatInputField( iconSize = 28.dp, ) - // Image mode chip — contextually tied to the image toggle above - if (creationMode is CreationMode.Image) { + // Image mode chip — only show when user explicitly toggles to Image mode + // and the model requires explicit toggle (not native image generation) + if (creationMode is CreationMode.Image && !supportsNativeImageGeneration) { Spacer(modifier = Modifier.width(4.dp)) Surface( shape = RoundedCornerShape(12.dp), diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index fb75d5368..31135135d 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -642,6 +642,8 @@ chat.drop.files=Drop files to attach to the conversation chat.create.image.menu=Create Image chat.create.image.mode=Image chat.create.image.mode.cancel=Cancel image creation mode +chat.image.model.missing.title=Image model not configured +chat.image.model.missing.message=No image model is set for {0}. Open Settings > AI Provider > Image Generation Models and set a model name first. chat.tools.button=Available Tools chat.tools.popup.title=Available Tools chat.tools.popup.loading=Loading servers... diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 4f4fedd80..e1f40e942 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -620,6 +620,8 @@ chat.drop.files=Dateien zum Anhängen an die Unterhaltung hierher ziehen chat.create.image.menu=Bild erstellen chat.create.image.mode=Bild chat.create.image.mode.cancel=Bild-Erstellungsmodus beenden +chat.image.model.missing.title=Bildmodell nicht konfiguriert +chat.image.model.missing.message=Für {0} ist kein Bildmodell festgelegt. Öffnen Sie Einstellungen > KI-Anbieter > Modelle zur Bilderzeugung und legen Sie zuerst einen Modellnamen fest. chat.tools.button=Verfügbare Tools chat.tools.popup.title=Verfügbare Tools chat.tools.popup.loading=Server werden geladen... diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index b79465206..b03537ff7 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -619,6 +619,8 @@ chat.drop.files=Arrastra archivos para adjuntarlos a la conversación chat.create.image.menu=Crear imagen chat.create.image.mode=Imagen chat.create.image.mode.cancel=Cancelar modo de creación de imagen +chat.image.model.missing.title=Modelo de imagen no configurado +chat.image.model.missing.message=No hay ningún modelo de imagen configurado para {0}. Abre Configuración > Proveedor de IA > Modelos de generación de imágenes y establece primero un nombre de modelo. chat.tools.button=Herramientas disponibles chat.tools.popup.title=Herramientas disponibles chat.tools.popup.loading=Cargando servidores... diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 4c1c934aa..c535d7ed8 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -619,6 +619,8 @@ chat.drop.files=Glissez-déposez des fichiers pour les joindre à la conversatio chat.create.image.menu=Créer une image chat.create.image.mode=Image chat.create.image.mode.cancel=Annuler le mode de création d’image +chat.image.model.missing.title=Modèle d'image non configuré +chat.image.model.missing.message=Aucun modèle d'image n'est défini pour {0}. Ouvrez Paramètres > Fournisseur d'IA > Modèles de génération d'images et définissez d'abord un nom de modèle. chat.tools.button=Outils disponibles chat.tools.popup.title=Outils disponibles chat.tools.popup.loading=Chargement des serveurs... diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index 0a516ad10..f91bd3e70 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -618,6 +618,8 @@ chat.drop.files=ファイルをドラッグ&ドロップして会話に添付 chat.create.image.menu=画像を作成 chat.create.image.mode=画像 chat.create.image.mode.cancel=画像作成モードをキャンセル +chat.image.model.missing.title=画像モデルが設定されていません +chat.image.model.missing.message={0} に画像モデルが設定されていません。設定 > AI プロバイダー > 画像生成モデルを開き、まずモデル名を設定してください。 chat.tools.button=利用可能なツール chat.tools.popup.title=利用可能なツール chat.tools.popup.loading=サーバーを読み込み中... diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index 272aa288e..ae40fb2f5 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -619,6 +619,8 @@ chat.drop.files=파일을 드래그하여 대화에 첨부하세요 chat.create.image.menu=이미지 생성 chat.create.image.mode=이미지 chat.create.image.mode.cancel=이미지 생성 모드 취소 +chat.image.model.missing.title=이미지 모델이 구성되지 않았습니다 +chat.image.model.missing.message={0}에 설정된 이미지 모델이 없습니다. 설정 > AI 제공업체 > 이미지 생성 모델을 열고 먼저 모델 이름을 설정하세요. chat.tools.button=사용 가능한 도구 chat.tools.popup.title=사용 가능한 도구 chat.tools.popup.loading=서버 로딩 중... diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index 65ddfc0cc..b49013dfe 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -621,6 +621,8 @@ chat.drop.files=Arraste arquivos para anexar à conversa chat.create.image.menu=Criar imagem chat.create.image.mode=Imagem chat.create.image.mode.cancel=Cancelar modo de criação de imagem +chat.image.model.missing.title=Modelo de imagem não configurado +chat.image.model.missing.message=Nenhum modelo de imagem está definido para {0}. Abra Configurações > Provedor de IA > Modelos de geração de imagens e defina primeiro um nome de modelo. chat.tools.button=Ferramentas disponíveis chat.tools.popup.title=Ferramentas disponíveis chat.tools.popup.loading=Carregando servidores... diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 4e516f8d7..6ea05c9be 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -626,6 +626,8 @@ chat.drop.files=Kéo thả tệp để đính kèm vào cuộc trò chuyện chat.create.image.menu=Tạo hình ảnh chat.create.image.mode=Hình ảnh chat.create.image.mode.cancel=Hủy chế độ tạo hình ảnh +chat.image.model.missing.title=Chưa cấu hình mô hình hình ảnh +chat.image.model.missing.message=Chưa đặt mô hình hình ảnh cho {0}. Mở Cài đặt > Nhà cung cấp AI > Mô hình tạo hình ảnh và đặt tên mô hình trước. chat.tools.button=Công cụ khả dụng chat.tools.popup.title=Công cụ khả dụng chat.tools.popup.loading=Đang tải máy chủ... diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index 186248a93..bce53fb27 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -619,6 +619,8 @@ chat.drop.files=拖拽文件以将其附加到对话中 chat.create.image.menu=创建图像 chat.create.image.mode=图像 chat.create.image.mode.cancel=取消图像创建模式 +chat.image.model.missing.title=未配置图像模型 +chat.image.model.missing.message=未为 {0} 设置图像模型。打开 设置 > AI 提供商 > 图像生成模型,并先设置模型名称。 chat.tools.button=可用工具 chat.tools.popup.title=可用工具 chat.tools.popup.loading=正在加载服务器... diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index 1a3792757..dd27c9b3a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -618,6 +618,8 @@ chat.drop.files=拖曳檔案以將其附加到對話中 chat.create.image.menu=建立影像 chat.create.image.mode=影像 chat.create.image.mode.cancel=取消影像建立模式 +chat.image.model.missing.title=尚未設定圖片模型 +chat.image.model.missing.message=尚未為 {0} 設定圖片模型。開啟 設定 > AI 提供者 > 圖片生成模型,並先設定模型名稱。 chat.tools.button=可用工具 chat.tools.popup.title=可用工具 chat.tools.popup.loading=正在載入伺服器... diff --git a/gradle.properties b/gradle.properties index f576ba4a8..720b81f04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ # Project metadata projectGroup=io.askimo -projectVersion=1.4.12 +projectVersion=1.4.13 jvmVersion=25 # About information diff --git a/shared/src/main/kotlin/io/askimo/core/event/internal/ImageCapabilityDetectedEvent.kt b/shared/src/main/kotlin/io/askimo/core/event/internal/ImageCapabilityDetectedEvent.kt new file mode 100644 index 000000000..49d8e31ef --- /dev/null +++ b/shared/src/main/kotlin/io/askimo/core/event/internal/ImageCapabilityDetectedEvent.kt @@ -0,0 +1,31 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.core.event.internal + +import io.askimo.core.event.Event +import io.askimo.core.event.EventSource +import io.askimo.core.event.EventType +import io.askimo.core.providers.ModelProvider +import java.time.Instant + +/** + * Fired by [io.askimo.core.providers.ModelCapabilitiesCache] after the image-generation capability + * probe completes for a model. UI components (e.g. ChatInputField) can listen for this to + * reactively show/hide/enable the image generation button. + * + * @param supportsNativeImage true if model can generate images natively in-chat (multi-modal), + * false if requires explicit toggle mode + */ +data class ImageCapabilityDetectedEvent( + val provider: ModelProvider, + val model: String, + val supportsNativeImage: Boolean, + override val timestamp: Instant = Instant.now(), + override val source: EventSource = EventSource.SYSTEM, +) : Event { + override val type = EventType.INTERNAL + + override fun getDetails() = "Image capability for $model ($provider): native=$supportsNativeImage" +} diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ChatModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/ChatModelFactory.kt index 4dba85812..8453c643c 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ChatModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ChatModelFactory.kt @@ -17,6 +17,7 @@ import dev.langchain4j.service.tool.ToolProvider import io.askimo.core.context.ExecutionMode import io.askimo.tools.fs.LocalFsTools import org.slf4j.LoggerFactory +import java.util.Base64 /** * Factory interface for creating chat model instances for a specific AI provider. @@ -225,4 +226,93 @@ interface ChatModelFactory { false } } + + /** + * Probes whether a model supports native image generation (in-chat). + * Tests by asking the chat model directly to generate an image. + * + * - Success: Model supports native image generation (multi-modal) + * - Failure: Model requires explicit toggle to image mode (DALL-E, Stable Diffusion, etc.) + * + * @param provider The model provider + * @param modelName The model name/identifier + * @param streamingChatModel The streaming model instance to probe + * @return true if model supports native image generation, false otherwise + */ + fun probeImageCapability( + provider: ModelProvider, + modelName: String, + streamingChatModel: StreamingChatModel, + ): Boolean { + val log = LoggerFactory.getLogger(this::class.java) + return try { + val testClient = AiServices.builder(ChatClient::class.java) + .streamingChatModel(streamingChatModel) + .build() + + // Ask for a strict data URI response so plain SVG/text does not pass. + val testPrompt = + "Generate a tiny 64x64 PNG image of a red circle on white background. " + + "Return ONLY a single data URI in this exact format: data:image/png;base64,. " + + "Do not return SVG, markdown, code blocks, or explanations." + val response = testClient.sendStreamingMessageWithCallback(null, UserMessage(testPrompt)).trim() + + // Extract first data:image/*;base64,... token from raw or markdown text. + val dataUriRegex = Regex("""data:image/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\r\n]+)""") + val match = dataUriRegex.find(response) + if (match == null) { + log.info( + "Model '{}' on provider '{}' returned no valid image data URI in probe; treating as non-native image model", + modelName, + provider.providerKey(), + ) + return false + } + + val mimeSubtype = match.groupValues[1].lowercase() + val base64Payload = match.groupValues[2].replace("\n", "").replace("\r", "") + val decoded = try { + Base64.getDecoder().decode(base64Payload) + } catch (_: IllegalArgumentException) { + ByteArray(0) + } + + fun startsWith(bytes: ByteArray, signature: IntArray): Boolean = bytes.size >= signature.size && signature.indices.all { i -> (bytes[i].toInt() and 0xFF) == signature[i] } + + // Verify real binary image signatures to avoid text-only hallucinations. + val matchesSignature = when { + mimeSubtype.contains("png") -> startsWith(decoded, intArrayOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) + + mimeSubtype.contains("jpeg") || mimeSubtype.contains("jpg") -> startsWith(decoded, intArrayOf(0xFF, 0xD8, 0xFF)) + + mimeSubtype.contains("gif") -> startsWith(decoded, intArrayOf(0x47, 0x49, 0x46, 0x38)) + + mimeSubtype.contains("webp") -> + startsWith(decoded, intArrayOf(0x52, 0x49, 0x46, 0x46)) && + decoded.size >= 12 && + (decoded[8].toInt() and 0xFF) == 0x57 && + (decoded[9].toInt() and 0xFF) == 0x45 && + (decoded[10].toInt() and 0xFF) == 0x42 && + (decoded[11].toInt() and 0xFF) == 0x50 + + else -> false + } + + if (matchesSignature) { + log.info("Model '{}' on provider '{}' supports native in-chat image generation", modelName, provider.providerKey()) + true + } else { + log.info( + "Model '{}' on provider '{}' returned non-image/invalid binary payload in probe; treating as non-native image model", + modelName, + provider.providerKey(), + ) + false + } + } catch (e: Exception) { + // Any exception means model doesn't support native generation + log.debug("Model '{}' on provider '{}' does not support native image generation: {}", modelName, provider.providerKey(), e.message) + false + } + } } diff --git a/shared/src/main/kotlin/io/askimo/core/providers/ModelCapabilitiesCache.kt b/shared/src/main/kotlin/io/askimo/core/providers/ModelCapabilitiesCache.kt index d10588b55..8bd3127cd 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/ModelCapabilitiesCache.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/ModelCapabilitiesCache.kt @@ -5,6 +5,7 @@ package io.askimo.core.providers import io.askimo.core.event.EventBus +import io.askimo.core.event.internal.ImageCapabilityDetectedEvent import io.askimo.core.event.internal.ThinkingSupportDetectedEvent import io.askimo.core.event.system.InvalidateCacheEvent import io.askimo.core.logging.logger @@ -314,6 +315,53 @@ object ModelCapabilitiesCache { EventBus.post(ThinkingSupportDetectedEvent(provider = provider, model = model, supportsThinking = supported)) } + /** + * Check if a model supports native image generation (in-chat). + * Returns false if not yet tested (null), indicating the client should test. + * + * @param provider The model provider + * @param model The model name/identifier + * @return true if native image generation supported, false if explicit toggle required OR not yet tested + */ + fun supportsImage(provider: ModelProvider, model: String): Boolean { + val modelKey = modelKey(provider, model) + return get(modelKey).supportsImage ?: false // null means not tested yet, return false + } + + /** + * Check if image support has been tested for a model. + * + * @param provider The model provider + * @param model The model name/identifier + * @return true if tested (value is true or false), false if not yet tested (null) + */ + fun hasTestedImageSupport(provider: ModelProvider, model: String): Boolean { + val modelKey = modelKey(provider, model) + return get(modelKey).supportsImage != null + } + + /** + * Update the cache with image generation support information. + * Typically called after probing: + * - Successfully generating image directly in model (supported = true) + * - Getting API error about unsupported generation (supported = false) + * + * @param provider The model provider + * @param model The model name/identifier + * @param supportsNativeImage true = native in-chat generation, false = requires explicit toggle + */ + fun setImageSupport(provider: ModelProvider, model: String, supportsNativeImage: Boolean) { + if (model.isBlank()) { + log.warn("Cannot set image support for empty model name (provider: ${provider.providerKey()})") + return + } + val modelKey = modelKey(provider, model) + update(modelKey) { it.copy(supportsImage = supportsNativeImage) } + log.debug("Updated image support for $modelKey: $supportsNativeImage (native=$supportsNativeImage)") + // Broadcast so UI components (e.g. ChatInputField) can reactively update + EventBus.post(ImageCapabilityDetectedEvent(provider = provider, model = model, supportsNativeImage = supportsNativeImage)) + } + /** * Get the reasoning effort level for a model. * Returns the cached reasoning level, or MEDIUM if not yet set. @@ -446,6 +494,7 @@ data class ModelCapabilities( val supportsStreaming: Boolean = true, // Non-nullable - always known val supportsSampling: Boolean = true, // Non-nullable - always known val supportsThinking: Boolean? = null, // null = not tested yet + val supportsImage: Boolean? = null, // null = not tested yet; true = native in-chat, false = explicit toggle val reasoningLevel: ReasoningEffort = ReasoningEffort.MEDIUM, // Default reasoning effort // For future extensibility val customAttributes: Map = emptyMap(), diff --git a/shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt index 5a66db110..8a386b4d7 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/OpenAiCompatibleChatModelFactory.kt @@ -197,6 +197,12 @@ abstract class OpenAiCompatibleChatModelFactory : ChatModelFactory ModelCapabilitiesCache.setToolSupport(getProvider(), settings.defaultModel, supportsTools) } + // Probe image generation capability once — result is persisted in ModelCapabilitiesCache + if (!ModelCapabilitiesCache.hasTestedImageSupport(getProvider(), settings.defaultModel)) { + val supportsImage = probeImageCapability(getProvider(), settings.defaultModel, streamingModel) + ModelCapabilitiesCache.setImageSupport(getProvider(), settings.defaultModel, supportsImage) + } + return AiServiceBuilder.buildChatClient( sessionId = sessionId, settings = settings, diff --git a/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt index 1791f2357..6fd870cca 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/anthropic/AnthropicModelFactory.kt @@ -87,6 +87,12 @@ class AnthropicModelFactory : ChatModelFactory { ModelCapabilitiesCache.setToolSupport(ANTHROPIC, settings.defaultModel, supportsTools) } + // Probe image generation capability once — result is persisted in ModelCapabilitiesCache + if (!ModelCapabilitiesCache.hasTestedImageSupport(ANTHROPIC, settings.defaultModel)) { + val supportsImage = probeImageCapability(ANTHROPIC, settings.defaultModel, streamingModel) + ModelCapabilitiesCache.setImageSupport(ANTHROPIC, settings.defaultModel, supportsImage) + } + return AiServiceBuilder.buildChatClient( sessionId = sessionId, settings = settings, diff --git a/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt b/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt index 95bffc37f..ba89fc931 100644 --- a/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt +++ b/shared/src/main/kotlin/io/askimo/core/providers/gemini/GeminiModelFactory.kt @@ -86,6 +86,12 @@ class GeminiModelFactory : ChatModelFactory { ModelCapabilitiesCache.setToolSupport(GEMINI, settings.defaultModel, supportsTools) } + // Probe image generation capability once — result is persisted in ModelCapabilitiesCache + if (!ModelCapabilitiesCache.hasTestedImageSupport(GEMINI, settings.defaultModel)) { + val supportsImage = probeImageCapability(GEMINI, settings.defaultModel, streamingModel) + ModelCapabilitiesCache.setImageSupport(GEMINI, settings.defaultModel, supportsImage) + } + return AiServiceBuilder.buildChatClient( sessionId = sessionId, settings = settings,