Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 78 additions & 32 deletions desktop-shared/src/main/kotlin/io/askimo/ui/chat/ChatInputField.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 2 additions & 0 deletions desktop-shared/src/main/resources/i18n/messages_fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=サーバーを読み込み中...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=서버 로딩 중...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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ủ...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=正在加载服务器...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=正在載入伺服器...
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project metadata
projectGroup=io.askimo
projectVersion=1.4.12
projectVersion=1.4.13
jvmVersion=25

# About information
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -225,4 +226,93 @@ interface ChatModelFactory<T : ProviderSettings> {
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,<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
}
}
}
Loading