From 1f14b937e01d0b70f5b641bbd5f062014dc3ffb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 06:44:10 +0000 Subject: [PATCH 1/2] Image generation + quick pickers from top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Imagen integration: new `generate_image` tool that calls `models/{imagen}:predict` with the API key and the imagen model selected in Settings (default imagen-3.0-generate-002; picker lists 3 built-in variants + custom ID). Decoded PNGs are persisted under /attachments/imagen-*.png and returned both as text (for the model turn) and as `attachmentPaths` on the ToolCallResult, so the tool-result bubble renders them as thumbnails. - Gemini 2.5 native multimodal output: when the selected chat model's name contains "-image" (heuristic, auto-detects `gemini-2.5-flash-image-preview`), the request gets `generationConfig.responseModalities = [TEXT, IMAGE]`. Response parser now handles `inlineData` parts, persists the bytes, and attaches the paths to the live model bubble alongside streamed text. - Model + tool-result bubbles now render `AttachmentThumbnails`, same 120-dp thumbnails as the user side. - Shared persistence: `RestGeminiCore.persistAttachmentBytes(id,bytes,mime)` is the single helper used by the response parser and by the Imagen tool. - Top bar quick pickers: tapping the model name opens a DropdownMenu listing the discovered models (+ "More models…" shortcut to full settings). Tapping the workspace folder name opens a menu with "Open folder" (best-effort ACTION_VIEW on the tree/file URI, falls back to a toast) and "Change folder" (launches OpenDocumentTree). --- .../com/gemini/app/ui/chat/ChatScreen.kt | 139 ++++++++++++++++-- .../com/gemini/app/ui/chat/ChatViewModel.kt | 12 ++ .../gemini/app/ui/settings/SettingsSheet.kt | 77 ++++++++++ .../com/gemini/bridge/RestGeminiCore.kt | 107 +++++++++++++- .../com/gemini/bridge/storage/SecurePrefs.kt | 7 + .../gemini/bridge/tools/GenerateImageTool.kt | 134 +++++++++++++++++ .../com/gemini/bridge/workspace/Workspace.kt | 7 + .../kotlin/com/gemini/domain/ToolTypes.kt | 5 +- 8 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 core-bridge/src/main/kotlin/com/gemini/bridge/tools/GenerateImageTool.kt diff --git a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt index 0cf86fc..816fd56 100644 --- a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt @@ -1,8 +1,14 @@ package com.gemini.app.ui.chat +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable import androidx.compose.foundation.Image import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing @@ -130,7 +136,9 @@ fun ChatScreen( val error by viewModel.error.collectAsState() val pendingCall by viewModel.pendingCall.collectAsState() val model by viewModel.model.collectAsState() + val availableModels by viewModel.availableModels.collectAsState() val workspaceLabel by viewModel.workspaceLabel.collectAsState() + val workspaceUri by viewModel.workspaceUri.collectAsState() val thinking by viewModel.thinking.collectAsState() val tokenUsage by viewModel.tokenUsage.collectAsState() val compressing by viewModel.compressing.collectAsState() @@ -141,6 +149,14 @@ fun ChatScreen( ) { uri -> if (uri != null) viewModel.attachImageFromUri(context, uri) } + val folderPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri -> + if (uri != null) viewModel.setProjectFolder(uri.toString()) + } + + var modelMenuOpen by remember { mutableStateOf(false) } + var folderMenuOpen by remember { mutableStateOf(false) } val listState = rememberLazyListState() val isNearBottom by remember(listState) { @@ -189,16 +205,78 @@ fun ChatScreen( ) Spacer(Modifier.width(8.dp)) Column { - Text( - model, - style = MaterialTheme.typography.labelMedium - ) - Row(verticalAlignment = Alignment.CenterVertically) { + Box { Text( - workspaceLabel.substringAfterLast('/'), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + model, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .clickable { modelMenuOpen = true } + .padding(vertical = 2.dp, horizontal = 4.dp) ) + DropdownMenu( + expanded = modelMenuOpen, + onDismissRequest = { modelMenuOpen = false } + ) { + availableModels.forEach { name -> + DropdownMenuItem( + text = { + Text( + name, + style = if (name == model) + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary + ) + else MaterialTheme.typography.bodyMedium + ) + }, + onClick = { + viewModel.setModel(name) + modelMenuOpen = false + } + ) + } + if (availableModels.isNotEmpty()) { + androidx.compose.material3.HorizontalDivider() + } + DropdownMenuItem( + text = { Text("More models…") }, + onClick = { + modelMenuOpen = false + showSettings = true + } + ) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box { + Text( + workspaceLabel.substringAfterLast('/'), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clickable { folderMenuOpen = true } + .padding(vertical = 2.dp, horizontal = 4.dp) + ) + DropdownMenu( + expanded = folderMenuOpen, + onDismissRequest = { folderMenuOpen = false } + ) { + DropdownMenuItem( + text = { Text("Open folder") }, + onClick = { + folderMenuOpen = false + openWorkspaceFolder(context, workspaceUri) + } + ) + DropdownMenuItem( + text = { Text("Change folder") }, + onClick = { + folderMenuOpen = false + folderPicker.launch(null) + } + ) + } + } val tokenLabel = formatTokens(tokenUsage.total, tokenUsage.limit) if (tokenLabel != null) { Spacer(Modifier.width(6.dp)) @@ -594,7 +672,15 @@ fun MessageBubble( } } } else { - MarkdownText(text = message.text, color = content) + Column { + if (message.attachmentPaths.isNotEmpty()) { + AttachmentThumbnails(message.attachmentPaths) + if (message.text.isNotBlank()) Spacer(Modifier.height(6.dp)) + } + if (message.text.isNotBlank()) { + MarkdownText(text = message.text, color = content) + } + } } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { DropdownMenuItem( @@ -1107,6 +1193,41 @@ private fun AttachmentThumbnails(paths: List) { } } +// Best-effort "reveal workspace in a file manager". For SAF tree URIs we build +// the corresponding document URI so DocumentsUI can open it; for plain file:// +// URIs we just ACTION_VIEW. There is no universal folder-view intent on +// Android, so we gracefully fall back to a toast when no app handles it. +private fun openWorkspaceFolder(context: Context, workspaceUri: String?) { + val raw = workspaceUri?.takeIf { it.isNotBlank() } + if (raw == null) { + Toast.makeText(context, "No workspace folder set", Toast.LENGTH_SHORT).show() + return + } + val uri = runCatching { Uri.parse(raw) }.getOrNull() ?: return + val intent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val target = if (uri.scheme == "content") { + runCatching { + val id = DocumentsContract.getTreeDocumentId(uri) + DocumentsContract.buildDocumentUriUsingTree(uri, id) + }.getOrNull() ?: uri + } else uri + intent.setDataAndType(target, "vnd.android.document/directory") + runCatching { context.startActivity(intent) }.onFailure { + // Retry without mime type — some Files apps only match on the URI. + val fallback = Intent(Intent.ACTION_VIEW, target) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(fallback) }.onFailure { + Toast.makeText( + context, + "No app available to open this folder", + Toast.LENGTH_SHORT + ).show() + } + } +} + private fun decodeThumbnail(path: String): androidx.compose.ui.graphics.ImageBitmap? { val file = java.io.File(path) if (!file.exists()) return null diff --git a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt index b18b37f..e80ca38 100644 --- a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt @@ -55,9 +55,15 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { private val _workspaceReason = MutableStateFlow(core.workspace.unreachableReason()) val workspaceReason: StateFlow = _workspaceReason.asStateFlow() + private val _workspaceUri = MutableStateFlow(core.workspace.rootUri()?.toString()) + val workspaceUri: StateFlow = _workspaceUri.asStateFlow() + private val _availableModels = MutableStateFlow(core.listModels()) val availableModels: StateFlow> = _availableModels.asStateFlow() + private val _imagenModel = MutableStateFlow(core.imagenModel()) + val imagenModel: StateFlow = _imagenModel.asStateFlow() + private val _thinking = MutableStateFlow(null) val thinking: StateFlow = _thinking.asStateFlow() @@ -347,6 +353,11 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { _model.value = core.currentModel() } + fun setImagenModel(name: String) { + core.setImagenModel(name) + _imagenModel.value = core.imagenModel() + } + fun setAutoApprove(enabled: Boolean) { core.setAutoApprove(enabled) _autoApprove.value = enabled @@ -360,6 +371,7 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { _workspaceLabel.value = core.workspace.rootLabel() _workspacePath.value = core.workspace.absolutePath() _workspaceReason.value = core.workspace.unreachableReason() + _workspaceUri.value = core.workspace.rootUri()?.toString() } } } diff --git a/app/src/main/kotlin/com/gemini/app/ui/settings/SettingsSheet.kt b/app/src/main/kotlin/com/gemini/app/ui/settings/SettingsSheet.kt index e3a9000..a831a35 100644 --- a/app/src/main/kotlin/com/gemini/app/ui/settings/SettingsSheet.kt +++ b/app/src/main/kotlin/com/gemini/app/ui/settings/SettingsSheet.kt @@ -97,8 +97,10 @@ fun SettingsSheet( val compressThreshold by viewModel.autoCompressThreshold.collectAsState() val tokenUsage by viewModel.tokenUsage.collectAsState() val autoSave by viewModel.autoSaveEnabled.collectAsState() + val imagenModel by viewModel.imagenModel.collectAsState() var customModel by remember { mutableStateOf("") } + var customImagenModel by remember { mutableStateOf("") } var expanded by remember { mutableStateOf(emptySet()) } val folderLauncher = rememberLauncherForActivityResult( @@ -258,6 +260,81 @@ fun SettingsSheet( } ) { Text("Use") } } + + Spacer(Modifier.height(16.dp)) + Text( + "Image generation model (Imagen)", + style = MaterialTheme.typography.labelMedium + ) + Spacer(Modifier.height(4.dp)) + var imagenDropdownOpen by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = imagenDropdownOpen, + onExpandedChange = { imagenDropdownOpen = it } + ) { + OutlinedTextField( + value = imagenModel, + onValueChange = {}, + readOnly = true, + label = { Text("Imagen model") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = imagenDropdownOpen) + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + DropdownMenu( + expanded = imagenDropdownOpen, + onDismissRequest = { imagenDropdownOpen = false } + ) { + com.gemini.bridge.RestGeminiCore.AVAILABLE_IMAGEN_MODELS.forEach { name -> + DropdownMenuItem( + text = { + Text( + name, + style = if (name == imagenModel) + MaterialTheme.typography.bodyMedium.copy( + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + else MaterialTheme.typography.bodyMedium + ) + }, + onClick = { + viewModel.setImagenModel(name) + imagenDropdownOpen = false + } + ) + } + } + } + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = customImagenModel, + onValueChange = { customImagenModel = it }, + placeholder = { Text("e.g. imagen-4.0-generate-001") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { + if (customImagenModel.isNotBlank()) { + viewModel.setImagenModel(customImagenModel.trim()) + customImagenModel = "" + } + } + ) { Text("Use") } + } + Text( + "The model uses `generate_image` automatically when you ask for " + + "a drawing/illustration. Imagen is billed separately from Gemini.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } SettingsAccordion( diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt index 43f388d..d932930 100644 --- a/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt @@ -83,6 +83,8 @@ class RestGeminiCore( private val defaultModel: String = DEFAULT_MODEL ) : GeminiCore { + private val appContext: Context = appContext.applicationContext + private val registry = ToolRegistry().apply { register(ReadFileTool(workspace)) register(WriteFileTool(workspace)) @@ -92,6 +94,15 @@ class RestGeminiCore( register(GlobTool(workspace)) register(GrepTool(workspace)) register(RunShellCommandTool(termux, workspace)) + register( + GenerateImageTool( + getApiKey = { this@RestGeminiCore.apiKey }, + getModel = { prefs.imagenModel ?: DEFAULT_IMAGEN_MODEL }, + persist = { id, bytes, mime -> + persistAttachmentBytes(id, bytes, mime) + } + ) + ) } private var apiKey: String = "" @@ -167,6 +178,32 @@ class RestGeminiCore( fun isAutoCompressEnabled(): Boolean = prefs.autoCompressEnabled fun setAutoCompressEnabled(enabled: Boolean) { prefs.autoCompressEnabled = enabled } fun autoCompressThreshold(): Float = prefs.autoCompressThreshold + + fun imagenModel(): String = prefs.imagenModel ?: DEFAULT_IMAGEN_MODEL + fun setImagenModel(name: String) { + prefs.imagenModel = name.ifBlank { DEFAULT_IMAGEN_MODEL } + } + + /** + * Save raw image bytes to app-owned storage for later display in a chat + * bubble. Called by the response parser (for model-generated images) and + * by the GenerateImageTool (for Imagen outputs). Returns the absolute path + * that can be stored in `GeminiMessage.attachmentPaths`. + */ + fun persistAttachmentBytes(id: String, bytes: ByteArray, mime: String): String? = runCatching { + val ext = when { + mime.contains("png") -> "png" + mime.contains("webp") -> "webp" + mime.contains("gif") -> "gif" + mime.contains("heic") -> "heic" + mime.contains("heif") -> "heif" + else -> "jpg" + } + val dir = java.io.File(appContext.filesDir, "attachments").also { it.mkdirs() } + val file = java.io.File(dir, "$id.$ext") + file.writeBytes(bytes) + file.absolutePath + }.getOrNull() fun setAutoCompressThreshold(fraction: Float) { prefs.autoCompressThreshold = fraction.coerceIn(0.5f, 0.95f) } @@ -414,7 +451,8 @@ class RestGeminiCore( isUser = false, timestamp = System.currentTimeMillis(), role = MessageRole.TOOL, - toolResult = result + toolResult = result, + attachmentPaths = result.attachmentPaths ) ) _events.tryEmit(GeminiEvent.ToolCallCompleted(result)) @@ -495,6 +533,42 @@ class RestGeminiCore( val id = "$name-${System.nanoTime()}-${calls.size}" calls.add(ToolCall(id, name, jsonToMap(args))) } + part.has("inlineData") -> { + // Native multimodal output (e.g. + // `gemini-2.5-flash-image-preview`): the model + // returns image bytes alongside text parts. + // Persist to disk so the bubble can show a + // thumbnail and the file survives reload. + val inline = part.getJSONObject("inlineData") + val mime = inline.optString("mimeType", "image/png") + val b64 = inline.optString("data").orEmpty() + if (b64.isNotBlank()) { + val decoded = runCatching { + android.util.Base64.decode(b64, android.util.Base64.DEFAULT) + }.getOrNull() + val path = decoded?.let { bytes -> + persistAttachmentBytes("gen-${nextId()}", bytes, mime) + } + if (path != null) { + if (liveMessage == null) { + liveMessage = GeminiMessage( + id = nextId(), + text = accumulated.toString(), + isUser = false, + timestamp = System.currentTimeMillis(), + role = MessageRole.MODEL, + attachmentPaths = listOf(path) + ) + addUiMessage(liveMessage!!) + } else { + liveMessage = liveMessage!!.copy( + attachmentPaths = liveMessage!!.attachmentPaths + path + ) + replaceUiMessage(liveMessage!!) + } + } + } + } } } } @@ -516,11 +590,20 @@ class RestGeminiCore( private fun buildRequestBody(): String { val contents = JSONArray() turns.forEach { contents.put(it) } - return JSONObject() + val body = JSONObject() .put("systemInstruction", buildSystemInstruction()) .put("contents", contents) .put("tools", buildToolsJson()) - .toString() + if (modelEmitsImages(model)) { + body.put( + "generationConfig", + JSONObject().put( + "responseModalities", + JSONArray().put("TEXT").put("IMAGE") + ) + ) + } + return body.toString() } private fun buildSystemInstruction(): JSONObject { @@ -697,14 +780,32 @@ class RestGeminiCore( companion object { private const val TAG = "RestGeminiCore" const val DEFAULT_MODEL = "gemini-2.5-flash" + const val DEFAULT_IMAGEN_MODEL = "imagen-3.0-generate-002" // Used as a static fallback before the API model-discovery call lands. // The real model list is fetched live from /v1beta/models. val AVAILABLE_MODELS = listOf( "gemini-2.5-pro", "gemini-2.5-flash", + "gemini-2.5-flash-image-preview", "gemini-2.0-flash", "gemini-1.5-pro-latest", "gemini-1.5-flash-latest" ) + // Imagen variants we expose in the settings picker. Access depends on + // the API key's quota — Imagen is billed separately from Gemini. + val AVAILABLE_IMAGEN_MODELS = listOf( + "imagen-3.0-generate-002", + "imagen-3.0-fast-generate-001", + "imagen-4.0-generate-preview-06-06" + ) + + /** + * True when the selected chat model is known to return inline image + * data in its response (triggers `responseModalities = [TEXT, IMAGE]`). + * Kept as a name-based heuristic so newly released image-output models + * light up automatically once the user picks them. + */ + fun modelEmitsImages(modelName: String): Boolean = + modelName.contains("-image", ignoreCase = true) } } diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/storage/SecurePrefs.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/storage/SecurePrefs.kt index 7da2c83..78ffdbb 100644 --- a/core-bridge/src/main/kotlin/com/gemini/bridge/storage/SecurePrefs.kt +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/storage/SecurePrefs.kt @@ -38,6 +38,12 @@ class SecurePrefs(context: Context) { if (value.isNullOrBlank()) remove(KEY_MODEL) else putString(KEY_MODEL, value) }.apply() + var imagenModel: String? + get() = plain.getString(KEY_IMAGEN_MODEL, null) + set(value) = plain.edit().apply { + if (value.isNullOrBlank()) remove(KEY_IMAGEN_MODEL) else putString(KEY_IMAGEN_MODEL, value) + }.apply() + var workspaceUri: String? get() = plain.getString(KEY_WORKSPACE, null) set(value) = plain.edit().apply { @@ -73,6 +79,7 @@ class SecurePrefs(context: Context) { private companion object { const val KEY_API = "api_key" const val KEY_MODEL = "model" + const val KEY_IMAGEN_MODEL = "imagen_model" const val KEY_WORKSPACE = "workspace_uri" const val KEY_AUTO_APPROVE = "auto_approve" const val KEY_TERMUX_GUIDE_SHOWN = "termux_guide_shown" diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/tools/GenerateImageTool.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/tools/GenerateImageTool.kt new file mode 100644 index 0000000..91c9672 --- /dev/null +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/tools/GenerateImageTool.kt @@ -0,0 +1,134 @@ +package com.gemini.bridge.tools + +import com.gemini.domain.ToolCall +import com.gemini.domain.ToolCallResult +import com.gemini.domain.ToolCategory +import com.gemini.domain.ToolSpec +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.net.HttpURLConnection +import java.net.URL + +/** + * Calls Google's Imagen `:predict` endpoint to generate one or more images + * from a text prompt. Bytes are saved to app-owned storage via `persist`; the + * returned paths are injected into the tool result so the UI layer can pick + * them up and attach them to the model's bubble. + * + * Constructed lazily from RestGeminiCore so the tool always reads the current + * API key and Imagen model variant the user picked in settings. + */ +class GenerateImageTool( + private val getApiKey: () -> String, + private val getModel: () -> String, + private val persist: (id: String, bytes: ByteArray, mime: String) -> String? +) : Tool { + + override val spec = ToolSpec( + name = "generate_image", + description = "Generate an image from a text prompt using Google's " + + "Imagen. Returns absolute paths of PNGs saved in app storage. Use " + + "this when the user asks to draw, illustrate, or create an image.", + category = ToolCategory.FILES, + destructive = false, + parameters = mapOf( + "type" to "object", + "properties" to mapOf( + "prompt" to stringProp( + "Description of the image to generate. Detailed prompts " + + "work best; include subject, style, lighting, etc." + ), + "aspect_ratio" to mapOf( + "type" to "string", + "description" to "Aspect ratio (1:1, 3:4, 4:3, 9:16, 16:9). Defaults to 1:1.", + "enum" to listOf("1:1", "3:4", "4:3", "9:16", "16:9") + ), + "number_of_images" to mapOf( + "type" to "integer", + "description" to "How many images to generate (1–4). Default 1.", + "minimum" to 1, + "maximum" to 4 + ) + ), + "required" to listOf("prompt") + ) + ) + + override suspend fun execute(call: ToolCall): ToolCallResult = withContext(Dispatchers.IO) { + val prompt = call.arguments["prompt"] as? String + if (prompt.isNullOrBlank()) return@withContext ToolOutput.error(call.id, "prompt is required") + val aspect = call.arguments["aspect_ratio"] as? String ?: "1:1" + val count = (call.arguments["number_of_images"] as? Number)?.toInt()?.coerceIn(1, 4) ?: 1 + val apiKey = getApiKey() + if (apiKey.isBlank()) return@withContext ToolOutput.error(call.id, "Gemini API key is not configured") + + val model = getModel() + val url = URL("https://generativelanguage.googleapis.com/v1beta/models/$model:predict") + val body = JSONObject() + .put( + "instances", + JSONArray().put(JSONObject().put("prompt", prompt)) + ) + .put( + "parameters", + JSONObject() + .put("sampleCount", count) + .put("aspectRatio", aspect) + ).toString() + + val conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 60_000 + readTimeout = 120_000 + setRequestProperty("Content-Type", "application/json; charset=utf-8") + setRequestProperty("x-goog-api-key", apiKey) + } + try { + conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val raw = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() + if (code !in 200..299) { + return@withContext ToolOutput.error( + call.id, + "Imagen $code: ${raw.take(400)}" + ) + } + val predictions = JSONObject(raw).optJSONArray("predictions") + ?: return@withContext ToolOutput.error(call.id, "No predictions in Imagen response") + val paths = mutableListOf() + for (i in 0 until predictions.length()) { + val pred = predictions.getJSONObject(i) + val b64 = pred.optString("bytesBase64Encoded").orEmpty() + if (b64.isBlank()) continue + val mime = pred.optString("mimeType", "image/png") + val bytes = runCatching { + android.util.Base64.decode(b64, android.util.Base64.DEFAULT) + }.getOrNull() ?: continue + val path = persist("imagen-${System.nanoTime()}-$i", bytes, mime) + if (path != null) paths.add(path) + } + if (paths.isEmpty()) { + return@withContext ToolOutput.error(call.id, "Imagen returned no decodable image") + } + val out = buildString { + append("Generated ${paths.size} image").append(if (paths.size > 1) "s" else "") + append(" with $model:\n") + paths.forEach { append(" • ").append(it).append('\n') } + } + ToolCallResult( + callId = call.id, + ok = true, + output = if (out.length > ToolOutput.MAX) out.take(ToolOutput.MAX) else out, + truncated = out.length > ToolOutput.MAX, + attachmentPaths = paths + ) + } finally { + runCatching { conn.disconnect() } + } + } +} diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/workspace/Workspace.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/workspace/Workspace.kt index 1facfcc..b3d21a6 100644 --- a/core-bridge/src/main/kotlin/com/gemini/bridge/workspace/Workspace.kt +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/workspace/Workspace.kt @@ -29,6 +29,13 @@ class Workspace(private val context: Context) { fun rootLabel(): String = label + /** + * URI of the current root so the UI can offer "open in files app". + * `file://...` for the default internal folder, `content://...` (tree) for + * a user-picked SAF location. Null only before init() has run. + */ + fun rootUri(): Uri? = root?.uri + fun setFile(file: File) { val created = file.apply { if (!exists()) mkdirs() } root = DocumentFile.fromFile(created) diff --git a/domain/src/main/kotlin/com/gemini/domain/ToolTypes.kt b/domain/src/main/kotlin/com/gemini/domain/ToolTypes.kt index 53c8516..8ba9be2 100644 --- a/domain/src/main/kotlin/com/gemini/domain/ToolTypes.kt +++ b/domain/src/main/kotlin/com/gemini/domain/ToolTypes.kt @@ -25,7 +25,10 @@ data class ToolCallResult( val callId: String, val ok: Boolean, val output: String, - val truncated: Boolean = false + val truncated: Boolean = false, + // Paths of image files produced by the tool (e.g. generate_image). The + // chat bubble for the tool-result message renders them as thumbnails. + val attachmentPaths: List = emptyList() ) /** User-level decision for a pending tool call surfaced by the model. */ From 55f2b47c31b9a54aed603c67e61bc4752c1b033c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 06:47:02 +0000 Subject: [PATCH 2/2] Fix missing import for GenerateImageTool in RestGeminiCore --- core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt index d932930..2a48eb7 100644 --- a/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt @@ -7,6 +7,7 @@ import android.util.Log import com.gemini.bridge.termux.TermuxBridge import com.gemini.bridge.tools.DeleteFileTool import com.gemini.bridge.tools.EditFileTool +import com.gemini.bridge.tools.GenerateImageTool import com.gemini.bridge.tools.GlobTool import com.gemini.bridge.tools.GrepTool import com.gemini.bridge.tools.ListDirTool