From 4c0b76a9b36711061c1669d51571015df78b5acf Mon Sep 17 00:00:00 2001 From: Garfie Date: Sun, 5 Apr 2026 23:56:54 -0500 Subject: [PATCH] fix: iOS image rendering and consolidate content part dispatch to commonMain - Configure Coil ImageLoader with authenticated Ktor HttpClient on iOS - Extract ContentPartRenderer sub-composables and tool call dispatch into shared commonMain (SharedContentParts.kt, ToolCallParsing.kt) - Add missing iOS handlers: AGENT_UPDATE, VIDEO_URL null fallback - Fix error text fallback inconsistency across platforms - Add memory cache to iOS ImageLoader --- .../chat/components/ContentPartRenderer.kt | 891 +----------------- .../chat/components/SharedContentParts.kt | 465 +++++++++ .../chat/components/ToolCallParsing.kt | 303 ++++++ .../components/PlatformChatComponents.ios.kt | 170 +--- shared/build.gradle.kts | 2 + .../librechat/shared/app/LibreChatApp.kt | 25 + 6 files changed, 818 insertions(+), 1038 deletions(-) create mode 100644 feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt create mode 100644 feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ToolCallParsing.kt diff --git a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ContentPartRenderer.kt b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ContentPartRenderer.kt index 131e553..82017f6 100644 --- a/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ContentPartRenderer.kt +++ b/feature/chat/src/androidMain/kotlin/com/garfiec/librechat/feature/chat/components/ContentPartRenderer.kt @@ -1,73 +1,9 @@ package com.garfiec.librechat.feature.chat.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BrokenImage -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates -import org.jetbrains.compose.resources.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import coil3.compose.SubcomposeAsyncImage -import com.garfiec.librechat.core.common.ToolConstants -import com.garfiec.librechat.core.model.AgentToolCall -import com.garfiec.librechat.core.model.ContentType import com.garfiec.librechat.core.model.MessageContentPart -import librechat_mobile.feature.chat.generated.resources.Res -import librechat_mobile.feature.chat.generated.resources.* -import com.garfiec.librechat.feature.chat.components.artifact.ArtifactButton -import com.garfiec.librechat.feature.chat.components.artifact.ArtifactPanel -import com.garfiec.librechat.feature.chat.components.artifact.ArtifactSegment -import com.garfiec.librechat.feature.chat.components.artifact.detectArtifacts -import com.garfiec.librechat.feature.chat.components.artifact.groupArtifactVersions -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } @Composable actual fun ContentPartRenderer( @@ -82,821 +18,16 @@ actual fun ContentPartRenderer( searchFocusedOccurrence: Int, onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?, ) { - val constrainedModifier = modifier.fillMaxWidth() - when (part.type) { - ContentType.TEXT, ContentType.TEXT_DELTA -> { - TextContentPart( - text = part.text.orEmpty(), - fontSizeMultiplier = fontSizeMultiplier, - useKatex = useKatex, - searchQuery = searchQuery, - searchFocusedOccurrence = searchFocusedOccurrence, - onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, - modifier = constrainedModifier, - ) - } - ContentType.THINK -> { - ThinkingContentPart( - thinkingText = part.think.orEmpty(), - fontSizeMultiplier = fontSizeMultiplier, - useKatex = useKatex, - searchQuery = searchQuery, - searchFocusedOccurrence = searchFocusedOccurrence, - onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, - modifier = constrainedModifier, - ) - } - ContentType.TOOL_CALL -> { - val toolCall = part.toolCall - val toolName = toolCall?.name ?: toolCall?.function?.name ?: "Tool Call" - val toolNameLower = toolName.lowercase() - val output = toolCall?.output ?: toolCall?.function?.output - - when { - toolNameLower.contains("search") || toolNameLower.contains(ToolConstants.WEB_SEARCH) -> { - val results = remember(output) { parseWebSearchResults(output) } - if (results.isNotEmpty()) { - WebSearchResultList( - results = results, - modifier = constrainedModifier, - ) - } else { - ToolCallContentPart( - toolName = toolName, - args = toolCall?.function?.arguments, - output = output, - modifier = constrainedModifier, - ) - } - } - toolNameLower.contains(ToolConstants.CODE_INTERPRETER) || toolNameLower.contains("execute") -> { - val result = remember(toolCall) { parseCodeExecution(toolCall) } - if (result != null) { - CodeExecutionCard( - result = result, - modifier = constrainedModifier, - ) - } else { - ToolCallContentPart( - toolName = toolName, - args = toolCall?.function?.arguments, - output = output, - modifier = constrainedModifier, - ) - } - } - toolNameLower.contains("memory") -> { - val artifact = remember(output) { parseMemoryArtifact(output) } - if (artifact != null) { - MemoryArtifactCard( - artifact = artifact, - modifier = constrainedModifier, - ) - } else { - ToolCallContentPart( - toolName = toolName, - args = toolCall?.function?.arguments, - output = output, - modifier = constrainedModifier, - ) - } - } - toolNameLower.contains("mcp") -> { - val resources = remember(output) { parseMcpResources(output) } - if (resources.isNotEmpty()) { - McpResourceCarousel( - resources = resources, - modifier = constrainedModifier, - ) - } else { - ToolCallContentPart( - toolName = toolName, - args = toolCall?.function?.arguments, - output = output, - modifier = constrainedModifier, - ) - } - } - isImageGenToolCall(toolNameLower) -> { - val imageResult = remember(toolCall, baseUrl, attachments) { - parseImageGenResult(toolCall, baseUrl, attachments) - } - ImageGenCard( - result = imageResult, - showDescription = showImageDescriptions, - modifier = constrainedModifier, - ) - } - toolNameLower.contains("log") -> { - val logContent = remember(toolCall) { parseLogContent(toolCall) } - LogContentCard( - log = logContent, - modifier = constrainedModifier, - ) - } - else -> { - ToolCallContentPart( - toolName = toolName, - args = toolCall?.function?.arguments, - output = output, - modifier = constrainedModifier, - ) - } - } - } - ContentType.ERROR -> { - ErrorContentPart( - errorText = part.error.orEmpty(), - modifier = constrainedModifier, - ) - } - ContentType.IMAGE_FILE -> { - val imageUrl = part.imageFile?.filepath?.let { filepath -> - when { - filepath.startsWith("http") -> filepath - filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" - baseUrl.isNotBlank() -> "$baseUrl/api/files/$filepath" - else -> filepath - } - } ?: part.imageFile?.fileId?.let { fileId -> - if (baseUrl.isNotBlank()) "$baseUrl/api/files/$fileId" else null - } - ImageContentPart( - imageUrl = imageUrl, - modifier = constrainedModifier, - ) - } - ContentType.IMAGE_URL -> { - ImageContentPart( - imageUrl = part.imageUrl?.url, - modifier = constrainedModifier, - ) - } - ContentType.VIDEO_URL -> { - val videoUrl = part.videoUrl?.url - if (videoUrl != null) { - VideoContent( - url = videoUrl, - modifier = constrainedModifier, - ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = constrainedModifier, - ) { - Icon( - imageVector = Icons.Filled.Videocam, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = stringResource(Res.string.video_not_supported), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - ContentType.INPUT_AUDIO -> { - AudioContent( - data = part.inputAudio?.data, - format = part.inputAudio?.format, - modifier = constrainedModifier, - ) - } - ContentType.AGENT_UPDATE -> { - val agentUpdate = part.agentUpdate - AgentHandoffCard( - handoff = AgentHandoff( - fromAgent = agentUpdate?.agentId?.let { stringResource(Res.string.cd_agent_handoff).format(it, "") }, - toAgent = part.agentId?.let { "Agent $it" }, - reason = agentUpdate?.runId?.let { "Run: $it" }, - ), - modifier = constrainedModifier, - ) - } - } -} - -@Composable -private fun TextContentPart( - text: String, - modifier: Modifier, - fontSizeMultiplier: Float = 1.0f, - useKatex: Boolean = false, - searchQuery: String? = null, - searchFocusedOccurrence: Int = -1, - onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, -) { - if (text.isBlank()) return - - val segments = remember(text) { detectArtifacts(text) } - val hasArtifacts = remember(segments) { segments.any { it is ArtifactSegment.ArtifactReference } } - - Column(modifier = modifier) { - if (!hasArtifacts) { - MarkdownContent( - text = text, - fontSizeMultiplier = fontSizeMultiplier, - useKatex = useKatex, - searchQuery = searchQuery, - searchFocusedOccurrence = searchFocusedOccurrence, - onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, - ) - } else { - val versionMap = remember(segments) { groupArtifactVersions(segments) } - var activeArtifact by remember { - mutableStateOf(null) - } - - segments.forEach { segment -> - when (segment) { - is ArtifactSegment.Text -> { - MarkdownContent( - text = segment.text, - fontSizeMultiplier = fontSizeMultiplier, - useKatex = useKatex, - searchQuery = searchQuery, - searchFocusedOccurrence = searchFocusedOccurrence, - onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, - ) - } - is ArtifactSegment.ArtifactReference -> { - val versions = versionMap[segment.artifact.identifier] ?: listOf(segment.artifact) - Spacer(modifier = Modifier.height(8.dp)) - ArtifactButton( - artifact = segment.artifact, - onClick = { activeArtifact = segment.artifact }, - versionCount = versions.size, - ) - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - - activeArtifact?.let { artifact -> - val versions = versionMap[artifact.identifier] ?: listOf(artifact) - ArtifactPanel( - artifact = artifact, - onDismiss = { activeArtifact = null }, - versions = versions, - ) - } - } - } -} - -@Composable -private fun ThinkingContentPart( - thinkingText: String, - modifier: Modifier, - fontSizeMultiplier: Float = 1.0f, - useKatex: Boolean = false, - searchQuery: String? = null, - searchFocusedOccurrence: Int = -1, - onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, -) { - var isExpanded by remember { mutableStateOf(false) } - val thinkingToggleCd = - stringResource(if (isExpanded) Res.string.cd_collapse_thinking else Res.string.cd_expand_thinking) - - Column( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .clickable { isExpanded = !isExpanded } - .padding(12.dp) - .semantics { - role = Role.Button - contentDescription = thinkingToggleCd - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Psychology, - contentDescription = stringResource(Res.string.cd_thinking_indicator), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(Res.string.label_thinking), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Spacer(modifier = Modifier.height(8.dp)) - MarkdownContent( - text = thinkingText, - fontSizeMultiplier = fontSizeMultiplier, - useKatex = useKatex, - searchQuery = searchQuery, - searchFocusedOccurrence = searchFocusedOccurrence, - onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, - ) - } - } - } -} - -@Composable -private fun ToolCallContentPart( - toolName: String, - args: String?, - output: String?, - modifier: Modifier, -) { - var isExpanded by remember { mutableStateOf(false) } - - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - shape = RoundedCornerShape(8.dp), - ) { - Column(modifier = Modifier.padding(12.dp)) { - val toolCallCd = - stringResource(if (isExpanded) Res.string.cd_collapse_tool_call else Res.string.cd_expand_tool_call, toolName) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { isExpanded = !isExpanded } - .semantics { - role = Role.Button - contentDescription = toolCallCd - }, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Build, - contentDescription = stringResource(Res.string.cd_tool_call), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = toolName, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column { - if (!args.isNullOrBlank()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(Res.string.label_input), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - CodeBlock(code = args, language = "json") - } - if (!output.isNullOrBlank()) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(Res.string.label_output), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - CodeBlock(code = output, language = null) - } - } - } - } - } -} - -@Composable -private fun ImageContentPart( - imageUrl: String?, - modifier: Modifier, -) { - if (imageUrl == null) return - - var showFullscreen by remember { mutableStateOf(false) } - - SubcomposeAsyncImage( - model = imageUrl, - contentDescription = stringResource(Res.string.cd_embedded_image), - contentScale = ContentScale.FillWidth, - modifier = modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable { showFullscreen = true } - .semantics { - role = Role.Image - }, - loading = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(12.dp), - ), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - }, - error = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(12.dp), - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.BrokenImage, - contentDescription = stringResource(Res.string.cd_failed_to_load_image), - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - ) - - if (showFullscreen) { - FullscreenImageViewer( - imageUrl = imageUrl, - onDismiss = { showFullscreen = false }, - ) - } -} - -@Composable -private fun ErrorContentPart( - errorText: String, - modifier: Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.errorContainer) - .padding(12.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - text = errorText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } -} - -// --- Tool name classification helpers --- - -/** - * Set of exact tool names that are image-generation tools, matching the - * official LibreChat web app's `Part.tsx` routing logic. - */ -private val IMAGE_GEN_EXACT_NAMES = setOf( - "image_gen_oai", - "image_edit_oai", - "gemini_image_gen", -) - -/** - * Set of legacy tool names from the `imageGenTools` set in the official repo - * (`data-provider/src/config.ts`). These are matched by containment. - */ -private val IMAGE_GEN_CONTAINS = listOf( - "dall", - "image_gen", - "stable-diffusion", - "flux", -) - -/** - * Returns true if the given (lowercased) tool name represents an image - * generation or editing tool that should be rendered with [ImageGenCard]. - */ -private fun isImageGenToolCall(toolNameLower: String): Boolean { - if (toolNameLower in IMAGE_GEN_EXACT_NAMES) return true - return IMAGE_GEN_CONTAINS.any { toolNameLower.contains(it) } -} - -// --- JSON parsing helpers for specialized tool call cards --- - -/** - * Attempts to parse web search results from tool call output JSON. - * Supports both a JSON array of results and a JSON object with a "results" array field. - */ -private fun parseWebSearchResults(output: String?): List { - if (output.isNullOrBlank()) return emptyList() - return try { - val element = lenientJson.parseToJsonElement(output) - val resultsArray = when (element) { - is JsonArray -> element - is JsonObject -> element["results"]?.jsonArray ?: return emptyList() - else -> return emptyList() - } - resultsArray.mapNotNull { item -> - try { - val obj = item.jsonObject - WebSearchResult( - title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null, - url = obj["url"]?.jsonPrimitive?.contentOrNull - ?: obj["link"]?.jsonPrimitive?.contentOrNull - ?: return@mapNotNull null, - snippet = obj["snippet"]?.jsonPrimitive?.contentOrNull - ?: obj["description"]?.jsonPrimitive?.contentOrNull - ?: obj["content"]?.jsonPrimitive?.contentOrNull - ?: "", - favicon = obj["favicon"]?.jsonPrimitive?.contentOrNull, - ) - } catch (_: Exception) { - null - } - } - } catch (_: Exception) { - emptyList() - } -} - -/** - * Attempts to parse code execution result from a tool call. - * Looks at the tool call args for code/language and output for results. - */ -private fun parseCodeExecution(toolCall: AgentToolCall?): CodeExecutionResult? { - if (toolCall == null) return null - return try { - val argsElement = toolCall.args - val argsObj = argsElement?.jsonObject - val code = argsObj?.get("code")?.jsonPrimitive?.contentOrNull - ?: toolCall.function?.arguments?.let { argsStr -> - try { - lenientJson.parseToJsonElement(argsStr).jsonObject["code"]?.jsonPrimitive?.contentOrNull - } catch (_: Exception) { - null - } - } - val language = argsObj?.get("language")?.jsonPrimitive?.contentOrNull - ?: argsObj?.get("lang")?.jsonPrimitive?.contentOrNull - ?: toolCall.function?.arguments?.let { argsStr -> - try { - val parsed = lenientJson.parseToJsonElement(argsStr).jsonObject - parsed["language"]?.jsonPrimitive?.contentOrNull - ?: parsed["lang"]?.jsonPrimitive?.contentOrNull - } catch (_: Exception) { - null - } - } - - val outputStr = toolCall.output ?: toolCall.function?.output - var outputText: String? = null - var errorText: String? = null - var exitCode: Int? = null - - if (!outputStr.isNullOrBlank()) { - try { - val outputObj = lenientJson.parseToJsonElement(outputStr).jsonObject - outputText = outputObj["output"]?.jsonPrimitive?.contentOrNull - ?: outputObj["stdout"]?.jsonPrimitive?.contentOrNull - errorText = outputObj["error"]?.jsonPrimitive?.contentOrNull - ?: outputObj["stderr"]?.jsonPrimitive?.contentOrNull - exitCode = outputObj["exit_code"]?.jsonPrimitive?.intOrNull - ?: outputObj["exitCode"]?.jsonPrimitive?.intOrNull - ?: outputObj["status"]?.jsonPrimitive?.intOrNull - } catch (_: Exception) { - // Output is plain text, not JSON - outputText = outputStr - } - } - - CodeExecutionResult( - language = language, - code = code, - output = outputText, - error = errorText, - exitCode = exitCode, - ) - } catch (_: Exception) { - null - } -} - -/** - * Attempts to parse a memory artifact from tool call output JSON. - */ -private fun parseMemoryArtifact(output: String?): MemoryArtifact? { - if (output.isNullOrBlank()) return null - return try { - val obj = lenientJson.parseToJsonElement(output).jsonObject - MemoryArtifact( - title = obj["title"]?.jsonPrimitive?.contentOrNull - ?: obj["key"]?.jsonPrimitive?.contentOrNull, - content = obj["content"]?.jsonPrimitive?.contentOrNull - ?: obj["value"]?.jsonPrimitive?.contentOrNull - ?: obj["text"]?.jsonPrimitive?.contentOrNull, - key = obj["key"]?.jsonPrimitive?.contentOrNull, - ) - } catch (_: Exception) { - // Fall back to treating the whole output as content - MemoryArtifact( - title = null, - content = output, - ) - } -} - -/** - * Attempts to parse MCP resources from tool call output JSON. - */ -private fun parseMcpResources(output: String?): List { - if (output.isNullOrBlank()) return emptyList() - return try { - val element = lenientJson.parseToJsonElement(output) - val resourcesArray = when (element) { - is JsonArray -> element - is JsonObject -> element["resources"]?.jsonArray - ?: element["contents"]?.jsonArray - ?: return emptyList() - else -> return emptyList() - } - resourcesArray.mapNotNull { item -> - try { - val obj = item.jsonObject - McpResource( - title = obj["title"]?.jsonPrimitive?.contentOrNull - ?: obj["name"]?.jsonPrimitive?.contentOrNull - ?: "Resource", - uri = obj["uri"]?.jsonPrimitive?.contentOrNull - ?: obj["url"]?.jsonPrimitive?.contentOrNull - ?: return@mapNotNull null, - preview = obj["preview"]?.jsonPrimitive?.contentOrNull - ?: obj["description"]?.jsonPrimitive?.contentOrNull - ?: obj["text"]?.jsonPrimitive?.contentOrNull, - ) - } catch (_: Exception) { - null - } - } - } catch (_: Exception) { - emptyList() - } -} - -/** - * Parses image generation result from a DALL-E or image_gen tool call. - * Handles various backend response formats: url, image_url, result, filepath, file_id. - */ -private fun parseImageGenResult( - toolCall: AgentToolCall?, - baseUrl: String = "", - attachments: List = emptyList(), -): ImageGenResult { - if (toolCall == null) return ImageGenResult() - - // Extract prompt from args — args may be a JsonObject or a JsonPrimitive string - val prompt = try { - val argsElement = toolCall.args - when { - argsElement is kotlinx.serialization.json.JsonObject -> - argsElement["prompt"]?.jsonPrimitive?.contentOrNull - argsElement is kotlinx.serialization.json.JsonPrimitive && argsElement.isString -> - lenientJson.parseToJsonElement(argsElement.content).jsonObject["prompt"]?.jsonPrimitive?.contentOrNull - else -> null - } ?: toolCall.function?.arguments?.let { argsStr -> - try { - lenientJson.parseToJsonElement(argsStr).jsonObject["prompt"]?.jsonPrimitive?.contentOrNull - } catch (_: Exception) { null } - } - } catch (_: Exception) { null } - - val outputStr = toolCall.output ?: toolCall.function?.output - var imageUrl: String? = null - - // Primary source: match attachment by toolCallId — this is where - // the backend stores the actual generated image filepath. - val toolCallId = toolCall.id - if (toolCallId != null) { - val attachment = attachments.firstOrNull { it.toolCallId == toolCallId } - val filepath = attachment?.filepath - if (filepath != null) { - imageUrl = when { - filepath.startsWith("http") -> filepath - filepath.startsWith("/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" - baseUrl.isNotBlank() -> "$baseUrl/$filepath" - else -> filepath - } - } - if (imageUrl == null) { - val fileId = attachment?.fileId - if (fileId != null && baseUrl.isNotBlank()) { - imageUrl = "$baseUrl/api/files/$fileId" - } - } - } - - // Fallback: try parsing the tool output JSON for url/filepath/file_id fields - if (imageUrl == null && !outputStr.isNullOrBlank()) { - try { - val outputObj = lenientJson.parseToJsonElement(outputStr).jsonObject - imageUrl = outputObj["url"]?.jsonPrimitive?.contentOrNull - ?: outputObj["image_url"]?.jsonPrimitive?.contentOrNull - ?: outputObj["result"]?.jsonPrimitive?.contentOrNull - if (imageUrl == null) { - val filepath = outputObj["filepath"]?.jsonPrimitive?.contentOrNull - if (filepath != null) { - imageUrl = when { - filepath.startsWith("http") -> filepath - filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" - baseUrl.isNotBlank() -> "$baseUrl/images/$filepath" - else -> filepath - } - } - } - if (imageUrl == null) { - val fileId = outputObj["file_id"]?.jsonPrimitive?.contentOrNull - if (fileId != null && baseUrl.isNotBlank()) { - imageUrl = "$baseUrl/api/files/$fileId" - } - } - } catch (_: Exception) { - // Output might be a plain URL - if (outputStr.startsWith("http")) { - imageUrl = outputStr.trim() - } - } - } - - return ImageGenResult( - imageUrl = imageUrl, - prompt = prompt, - isGenerating = outputStr.isNullOrBlank() && imageUrl == null, + ContentPartDispatcher( + part = part, + modifier = modifier, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + attachments = attachments, + showImageDescriptions = showImageDescriptions, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, ) } - -/** - * Parses log content from a log-type tool call. - */ -private fun parseLogContent(toolCall: AgentToolCall?): LogContent { - if (toolCall == null) return LogContent() - return try { - val outputStr = toolCall.output ?: toolCall.function?.output - val toolName = toolCall.name ?: toolCall.function?.name ?: "Log Output" - - if (!outputStr.isNullOrBlank()) { - try { - val outputObj = lenientJson.parseToJsonElement(outputStr).jsonObject - val title = outputObj["title"]?.jsonPrimitive?.contentOrNull ?: toolName - val content = outputObj["content"]?.jsonPrimitive?.contentOrNull - ?: outputObj["log"]?.jsonPrimitive?.contentOrNull - ?: outputObj["text"]?.jsonPrimitive?.contentOrNull - ?: outputStr - LogContent(title = title, content = content) - } catch (_: Exception) { - LogContent(title = toolName, content = outputStr) - } - } else { - LogContent(title = toolName) - } - } catch (_: Exception) { - LogContent() - } -} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt new file mode 100644 index 0000000..652bd48 --- /dev/null +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/SharedContentParts.kt @@ -0,0 +1,465 @@ +package com.garfiec.librechat.feature.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import com.garfiec.librechat.core.common.ToolConstants +import com.garfiec.librechat.core.model.Attachment +import com.garfiec.librechat.core.model.ContentType +import com.garfiec.librechat.core.model.MessageContentPart +import com.garfiec.librechat.feature.chat.components.artifact.Artifact +import com.garfiec.librechat.feature.chat.components.artifact.ArtifactButton +import com.garfiec.librechat.feature.chat.components.artifact.ArtifactPanel +import com.garfiec.librechat.feature.chat.components.artifact.ArtifactSegment +import com.garfiec.librechat.feature.chat.components.artifact.detectArtifacts +import com.garfiec.librechat.feature.chat.components.artifact.groupArtifactVersions +import librechat_mobile.feature.chat.generated.resources.Res +import librechat_mobile.feature.chat.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +// ─── ContentPartDispatcher ────────────────────────────────────────── + +/** + * Shared content part dispatch logic used by both Android and iOS + * [ContentPartRenderer] implementations. Platform-specific renderers + * delegate here for all content types. + */ +@Composable +internal fun ContentPartDispatcher( + part: MessageContentPart, + modifier: Modifier = Modifier, + baseUrl: String = "", + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + attachments: List = emptyList(), + showImageDescriptions: Boolean = true, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, +) { + val mod = modifier.fillMaxWidth() + when (part.type) { + ContentType.TEXT, ContentType.TEXT_DELTA -> { + TextContentPart( + text = part.text.orEmpty(), + modifier = mod, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + ) + } + ContentType.THINK -> { + ThinkingContentPart( + thinkingText = part.think.orEmpty(), + modifier = mod, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + ) + } + ContentType.TOOL_CALL -> { + ToolCallDispatcher( + part = part, + modifier = mod, + baseUrl = baseUrl, + attachments = attachments, + showImageDescriptions = showImageDescriptions, + ) + } + ContentType.IMAGE_FILE -> { + val imageUrl = part.imageFile?.filepath?.let { filepath -> + when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/api/files/$filepath" + else -> filepath + } + } ?: part.imageFile?.fileId?.let { fileId -> + if (baseUrl.isNotBlank()) "$baseUrl/api/files/$fileId" else null + } + ImageContentPart(imageUrl = imageUrl, modifier = mod) + } + ContentType.IMAGE_URL -> { + ImageContentPart(imageUrl = part.imageUrl?.url, modifier = mod) + } + ContentType.VIDEO_URL -> { + val videoUrl = part.videoUrl?.url + if (videoUrl != null) { + VideoContent(url = videoUrl, modifier = mod) + } else { + Row(verticalAlignment = Alignment.CenterVertically, modifier = mod) { + Icon(Icons.Filled.Videocam, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(Res.string.video_not_supported), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + ContentType.INPUT_AUDIO -> { + AudioContent(data = part.inputAudio?.data, format = part.inputAudio?.format, modifier = mod) + } + ContentType.ERROR -> { + ErrorContentPart(errorText = part.error ?: part.text.orEmpty(), modifier = mod) + } + ContentType.AGENT_UPDATE -> { + val agentUpdate = part.agentUpdate + AgentHandoffCard( + handoff = AgentHandoff( + fromAgent = agentUpdate?.agentId, + toAgent = part.agentId?.let { "Agent $it" }, + reason = agentUpdate?.runId?.let { "Run: $it" }, + ), + modifier = mod, + ) + } + else -> { + if (!part.text.isNullOrEmpty()) { + MarkdownContent(part.text.orEmpty(), mod, fontSizeMultiplier, useKatex) + } + } + } +} + +// ─── ToolCallDispatcher ───────────────────────────────────────────── + +@Composable +private fun ToolCallDispatcher( + part: MessageContentPart, + modifier: Modifier, + baseUrl: String, + attachments: List, + showImageDescriptions: Boolean, +) { + val toolCall = part.toolCall + val toolName = toolCall?.name ?: toolCall?.function?.name ?: "Tool Call" + val toolNameLower = toolName.lowercase() + val output = toolCall?.output ?: toolCall?.function?.output + + when { + toolNameLower.contains("search") -> { + val results = remember(output) { parseWebSearchResults(output) } + if (results.isNotEmpty()) { + WebSearchResultList(results = results, modifier = modifier) + } else { + GenericToolCallCard(toolName, toolCall?.function?.arguments, output, modifier) + } + } + toolNameLower.contains(ToolConstants.CODE_INTERPRETER) || toolNameLower.contains("execute") -> { + val result = remember(toolCall) { parseCodeExecution(toolCall) } + if (result != null) { + CodeExecutionCard(result = result, modifier = modifier) + } else { + GenericToolCallCard(toolName, toolCall?.function?.arguments, output, modifier) + } + } + toolNameLower.contains("memory") -> { + val artifact = remember(output) { parseMemoryArtifact(output) } + if (artifact != null) { + MemoryArtifactCard(artifact = artifact, modifier = modifier) + } else { + GenericToolCallCard(toolName, toolCall?.function?.arguments, output, modifier) + } + } + toolNameLower.contains("mcp") -> { + val resources = remember(output) { parseMcpResources(output) } + if (resources.isNotEmpty()) { + McpResourceCarousel(resources = resources, modifier = modifier) + } else { + GenericToolCallCard(toolName, toolCall?.function?.arguments, output, modifier) + } + } + isImageGenToolCall(toolNameLower) -> { + val imageResult = remember(toolCall, baseUrl, attachments) { + parseImageGenResult(toolCall, baseUrl, attachments) + } + ImageGenCard(result = imageResult, showDescription = showImageDescriptions, modifier = modifier) + } + toolNameLower.contains("log") -> { + val logContent = remember(toolCall) { parseLogContent(toolCall) } + LogContentCard(log = logContent, modifier = modifier) + } + else -> { + GenericToolCallCard(toolName, toolCall?.function?.arguments, output, modifier) + } + } +} + +// ─── TextContentPart ──────────────────────────────────────────────── + +@Composable +private fun TextContentPart( + text: String, + modifier: Modifier, + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, +) { + if (text.isBlank()) return + + val segments = remember(text) { detectArtifacts(text) } + val hasArtifacts = remember(segments) { segments.any { it is ArtifactSegment.ArtifactReference } } + + if (!hasArtifacts) { + MarkdownContent(text, modifier, fontSizeMultiplier, useKatex, searchQuery, searchFocusedOccurrence, onFocusedOccurrencePositioned) + } else { + val versionMap = remember(segments) { groupArtifactVersions(segments) } + var activeArtifact by remember { + mutableStateOf(null) + } + Column(modifier = modifier) { + segments.forEach { segment -> + when (segment) { + is ArtifactSegment.Text -> { + MarkdownContent(segment.text, Modifier.fillMaxWidth(), fontSizeMultiplier, useKatex, searchQuery, searchFocusedOccurrence, onFocusedOccurrencePositioned) + } + is ArtifactSegment.ArtifactReference -> { + val versions = versionMap[segment.artifact.identifier] ?: listOf(segment.artifact) + Spacer(modifier = Modifier.height(8.dp)) + ArtifactButton( + artifact = segment.artifact, + onClick = { activeArtifact = segment.artifact }, + versionCount = versions.size, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + activeArtifact?.let { artifact -> + val versions = versionMap[artifact.identifier] ?: listOf(artifact) + ArtifactPanel( + artifact = artifact, + onDismiss = { activeArtifact = null }, + versions = versions, + ) + } + } +} + +// ─── ThinkingContentPart ──────────────────────────────────────────── + +@Composable +private fun ThinkingContentPart( + thinkingText: String, + modifier: Modifier, + fontSizeMultiplier: Float = 1.0f, + useKatex: Boolean = false, + searchQuery: String? = null, + searchFocusedOccurrence: Int = -1, + onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)? = null, +) { + var isExpanded by remember { mutableStateOf(false) } + val thinkingToggleCd = + stringResource(if (isExpanded) Res.string.cd_collapse_thinking else Res.string.cd_expand_thinking) + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable { isExpanded = !isExpanded } + .padding(12.dp) + .semantics { + role = Role.Button + contentDescription = thinkingToggleCd + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Psychology, stringResource(Res.string.cd_thinking_indicator), Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(Res.string.label_thinking), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f)) + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), + Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { + Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { + Spacer(modifier = Modifier.height(8.dp)) + MarkdownContent(thinkingText, fontSizeMultiplier = fontSizeMultiplier, useKatex = useKatex, searchQuery = searchQuery, searchFocusedOccurrence = searchFocusedOccurrence, onFocusedOccurrencePositioned = onFocusedOccurrencePositioned) + } + } + } +} + +// ─── GenericToolCallCard ──────────────────────────────────────────── + +@Composable +internal fun GenericToolCallCard( + toolName: String, + args: String?, + output: String?, + modifier: Modifier = Modifier, +) { + var isExpanded by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + shape = RoundedCornerShape(8.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + val toolCallCd = + stringResource(if (isExpanded) Res.string.cd_collapse_tool_call else Res.string.cd_expand_tool_call, toolName) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .semantics { + role = Role.Button + contentDescription = toolCallCd + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Build, stringResource(Res.string.cd_tool_call), Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(8.dp)) + Text(toolName, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + stringResource(if (isExpanded) Res.string.cd_collapse else Res.string.cd_expand), + Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { + Column { + if (!args.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(Res.string.label_input), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(4.dp)) + CodeBlock(code = args, language = "json") + } + if (!output.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(Res.string.label_output), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.height(4.dp)) + CodeBlock(code = output, language = null) + } + } + } + } + } +} + +// ─── ImageContentPart ─────────────────────────────────────────────── + +@Composable +internal fun ImageContentPart( + imageUrl: String?, + modifier: Modifier = Modifier, +) { + if (imageUrl == null) return + + var showFullscreen by remember { mutableStateOf(false) } + + SubcomposeAsyncImage( + model = imageUrl, + contentDescription = stringResource(Res.string.cd_embedded_image), + contentScale = ContentScale.FillWidth, + modifier = modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { showFullscreen = true } + .semantics { role = Role.Image }, + loading = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + }, + error = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.BrokenImage, stringResource(Res.string.cd_failed_to_load_image), Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + ) + + if (showFullscreen) { + FullscreenImageViewer(imageUrl = imageUrl, onDismiss = { showFullscreen = false }) + } +} + +// ─── ErrorContentPart ─────────────────────────────────────────────── + +@Composable +internal fun ErrorContentPart( + errorText: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + Text(errorText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) + } +} diff --git a/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ToolCallParsing.kt b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ToolCallParsing.kt new file mode 100644 index 0000000..edf08ad --- /dev/null +++ b/feature/chat/src/commonMain/kotlin/com/garfiec/librechat/feature/chat/components/ToolCallParsing.kt @@ -0,0 +1,303 @@ +package com.garfiec.librechat.feature.chat.components + +import com.garfiec.librechat.core.model.AgentToolCall +import com.garfiec.librechat.core.model.Attachment +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import co.touchlab.kermit.Logger + +private val log = Logger.withTag("ToolCallParsing") + +internal val toolCallJson = Json { ignoreUnknownKeys = true; isLenient = true } + +// --- Tool name classification --- + +private val IMAGE_GEN_EXACT_NAMES = setOf( + "image_gen_oai", + "image_edit_oai", + "gemini_image_gen", +) + +private val IMAGE_GEN_CONTAINS = listOf( + "dall", + "image_gen", + "stable-diffusion", + "flux", +) + +internal fun isImageGenToolCall(toolNameLower: String): Boolean { + if (toolNameLower in IMAGE_GEN_EXACT_NAMES) return true + return IMAGE_GEN_CONTAINS.any { toolNameLower.contains(it) } +} + +// --- Parsing helpers --- + +internal fun parseWebSearchResults(output: String?): List { + if (output.isNullOrBlank()) return emptyList() + return try { + val element = toolCallJson.parseToJsonElement(output) + val resultsArray = when (element) { + is JsonArray -> element + is JsonObject -> element["results"]?.jsonArray ?: return emptyList() + else -> return emptyList() + } + resultsArray.mapNotNull { item -> + try { + val obj = item.jsonObject + WebSearchResult( + title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null, + url = obj["url"]?.jsonPrimitive?.contentOrNull + ?: obj["link"]?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null, + snippet = obj["snippet"]?.jsonPrimitive?.contentOrNull + ?: obj["description"]?.jsonPrimitive?.contentOrNull + ?: obj["content"]?.jsonPrimitive?.contentOrNull + ?: "", + favicon = obj["favicon"]?.jsonPrimitive?.contentOrNull, + ) + } catch (e: Exception) { + log.d(e) { "Skipping malformed web search result item" } + null + } + } + } catch (e: Exception) { + log.d(e) { "Failed to parse web search results" } + emptyList() + } +} + +internal fun parseCodeExecution(toolCall: AgentToolCall?): CodeExecutionResult? { + if (toolCall == null) return null + return try { + val argsElement = toolCall.args + val argsObj = argsElement?.jsonObject + val code = argsObj?.get("code")?.jsonPrimitive?.contentOrNull + ?: toolCall.function?.arguments?.let { argsStr -> + try { + toolCallJson.parseToJsonElement(argsStr).jsonObject["code"]?.jsonPrimitive?.contentOrNull + } catch (e: Exception) { + log.d(e) { "Failed to parse code execution args string" } + null + } + } + val language = argsObj?.get("language")?.jsonPrimitive?.contentOrNull + ?: argsObj?.get("lang")?.jsonPrimitive?.contentOrNull + ?: toolCall.function?.arguments?.let { argsStr -> + try { + val parsed = toolCallJson.parseToJsonElement(argsStr).jsonObject + parsed["language"]?.jsonPrimitive?.contentOrNull + ?: parsed["lang"]?.jsonPrimitive?.contentOrNull + } catch (e: Exception) { + log.d(e) { "Failed to parse code execution language from args" } + null + } + } + + val outputStr = toolCall.output ?: toolCall.function?.output + var outputText: String? = null + var errorText: String? = null + var exitCode: Int? = null + + if (!outputStr.isNullOrBlank()) { + try { + val outputObj = toolCallJson.parseToJsonElement(outputStr).jsonObject + outputText = outputObj["output"]?.jsonPrimitive?.contentOrNull + ?: outputObj["stdout"]?.jsonPrimitive?.contentOrNull + errorText = outputObj["error"]?.jsonPrimitive?.contentOrNull + ?: outputObj["stderr"]?.jsonPrimitive?.contentOrNull + exitCode = outputObj["exit_code"]?.jsonPrimitive?.intOrNull + ?: outputObj["exitCode"]?.jsonPrimitive?.intOrNull + ?: outputObj["status"]?.jsonPrimitive?.intOrNull + } catch (e: Exception) { + log.d(e) { "Code execution output is not structured JSON, using raw string" } + outputText = outputStr + } + } + + CodeExecutionResult( + language = language, code = code, output = outputText, + error = errorText, exitCode = exitCode, + ) + } catch (e: Exception) { + log.d(e) { "Failed to parse code execution tool call" } + null + } +} + +internal fun parseMemoryArtifact(output: String?): MemoryArtifact? { + if (output.isNullOrBlank()) return null + return try { + val obj = toolCallJson.parseToJsonElement(output).jsonObject + MemoryArtifact( + title = obj["title"]?.jsonPrimitive?.contentOrNull + ?: obj["key"]?.jsonPrimitive?.contentOrNull, + content = obj["content"]?.jsonPrimitive?.contentOrNull + ?: obj["value"]?.jsonPrimitive?.contentOrNull + ?: obj["text"]?.jsonPrimitive?.contentOrNull, + key = obj["key"]?.jsonPrimitive?.contentOrNull, + ) + } catch (e: Exception) { + log.d(e) { "Failed to parse memory artifact as JSON, using raw output" } + MemoryArtifact(title = null, content = output) + } +} + +internal fun parseMcpResources(output: String?): List { + if (output.isNullOrBlank()) return emptyList() + return try { + val element = toolCallJson.parseToJsonElement(output) + val resourcesArray = when (element) { + is JsonArray -> element + is JsonObject -> element["resources"]?.jsonArray + ?: element["contents"]?.jsonArray + ?: return emptyList() + else -> return emptyList() + } + resourcesArray.mapNotNull { item -> + try { + val obj = item.jsonObject + McpResource( + title = obj["title"]?.jsonPrimitive?.contentOrNull + ?: obj["name"]?.jsonPrimitive?.contentOrNull + ?: "Resource", + uri = obj["uri"]?.jsonPrimitive?.contentOrNull + ?: obj["url"]?.jsonPrimitive?.contentOrNull + ?: return@mapNotNull null, + preview = obj["preview"]?.jsonPrimitive?.contentOrNull + ?: obj["description"]?.jsonPrimitive?.contentOrNull + ?: obj["text"]?.jsonPrimitive?.contentOrNull, + ) + } catch (e: Exception) { + log.d(e) { "Skipping malformed MCP resource item" } + null + } + } + } catch (e: Exception) { + log.d(e) { "Failed to parse MCP resources" } + emptyList() + } +} + +internal fun parseImageGenResult( + toolCall: AgentToolCall?, + baseUrl: String = "", + attachments: List = emptyList(), +): ImageGenResult { + if (toolCall == null) return ImageGenResult() + + val prompt = try { + val argsElement = toolCall.args + when { + argsElement is JsonObject -> + argsElement["prompt"]?.jsonPrimitive?.contentOrNull + argsElement is JsonPrimitive && argsElement.isString -> + toolCallJson.parseToJsonElement(argsElement.content).jsonObject["prompt"]?.jsonPrimitive?.contentOrNull + else -> null + } ?: toolCall.function?.arguments?.let { argsStr -> + try { + toolCallJson.parseToJsonElement(argsStr).jsonObject["prompt"]?.jsonPrimitive?.contentOrNull + } catch (e: Exception) { + log.d(e) { "Failed to parse image gen prompt from function args" } + null + } + } + } catch (e: Exception) { + log.d(e) { "Failed to parse image gen prompt" } + null + } + + val outputStr = toolCall.output ?: toolCall.function?.output + var imageUrl: String? = null + + val toolCallId = toolCall.id + if (toolCallId != null) { + val attachment = attachments.firstOrNull { it.toolCallId == toolCallId } + val filepath = attachment?.filepath + if (filepath != null) { + imageUrl = when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/$filepath" + else -> filepath + } + } + if (imageUrl == null) { + val fileId = attachment?.fileId + if (fileId != null && baseUrl.isNotBlank()) { + imageUrl = "$baseUrl/api/files/$fileId" + } + } + } + + if (imageUrl == null && !outputStr.isNullOrBlank()) { + try { + val outputObj = toolCallJson.parseToJsonElement(outputStr).jsonObject + imageUrl = outputObj["url"]?.jsonPrimitive?.contentOrNull + ?: outputObj["image_url"]?.jsonPrimitive?.contentOrNull + ?: outputObj["result"]?.jsonPrimitive?.contentOrNull + if (imageUrl == null) { + val filepath = outputObj["filepath"]?.jsonPrimitive?.contentOrNull + if (filepath != null) { + imageUrl = when { + filepath.startsWith("http") -> filepath + filepath.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$filepath" + baseUrl.isNotBlank() -> "$baseUrl/images/$filepath" + else -> filepath + } + } + } + if (imageUrl == null) { + val fileId = outputObj["file_id"]?.jsonPrimitive?.contentOrNull + if (fileId != null && baseUrl.isNotBlank()) { + imageUrl = "$baseUrl/api/files/$fileId" + } + } + } catch (e: Exception) { + log.d(e) { "Image gen output is not structured JSON, checking for raw URL" } + if (outputStr.startsWith("http")) { + imageUrl = outputStr.trim() + } + } + } + + return ImageGenResult( + imageUrl = imageUrl, + prompt = prompt, + isGenerating = outputStr.isNullOrBlank() && imageUrl == null, + ) +} + +internal fun parseLogContent(toolCall: AgentToolCall?): LogContent { + if (toolCall == null) return LogContent() + return try { + val outputStr = toolCall.output ?: toolCall.function?.output + val toolName = toolCall.name ?: toolCall.function?.name ?: "Log Output" + + if (!outputStr.isNullOrBlank()) { + try { + val outputObj = toolCallJson.parseToJsonElement(outputStr).jsonObject + val title = outputObj["title"]?.jsonPrimitive?.contentOrNull ?: toolName + val content = outputObj["content"]?.jsonPrimitive?.contentOrNull + ?: outputObj["log"]?.jsonPrimitive?.contentOrNull + ?: outputObj["text"]?.jsonPrimitive?.contentOrNull + ?: outputStr + LogContent(title = title, content = content) + } catch (e: Exception) { + log.d(e) { "Log output is not structured JSON, using raw string" } + LogContent(title = toolName, content = outputStr) + } + } else { + LogContent(title = toolName) + } + } catch (e: Exception) { + log.d(e) { "Failed to parse log content tool call" } + LogContent() + } +} diff --git a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt index b1f9c2c..5b3edce 100644 --- a/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt +++ b/feature/chat/src/iosMain/kotlin/com/garfiec/librechat/feature/chat/components/PlatformChatComponents.ios.kt @@ -1,8 +1,5 @@ package com.garfiec.librechat.feature.chat.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -16,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -24,17 +20,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.BrokenImage -import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Psychology -import androidx.compose.material.icons.filled.Videocam -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -56,7 +43,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily @@ -71,9 +57,7 @@ import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import coil3.compose.SubcomposeAsyncImage import com.garfiec.librechat.core.common.ChatLayoutConstants -import com.garfiec.librechat.core.model.ContentType import com.garfiec.librechat.core.model.Message import com.garfiec.librechat.core.model.MessageContentPart import com.garfiec.librechat.core.ui.components.AvatarImage @@ -85,24 +69,12 @@ import com.mikepenz.markdown.m3.markdownTypography import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import kotlinx.coroutines.delay import librechat_mobile.feature.chat.generated.resources.Res -import librechat_mobile.feature.chat.generated.resources.cd_collapse -import librechat_mobile.feature.chat.generated.resources.cd_embedded_image -import librechat_mobile.feature.chat.generated.resources.cd_expand import librechat_mobile.feature.chat.generated.resources.cd_expand_table import librechat_mobile.feature.chat.generated.resources.cd_close -import librechat_mobile.feature.chat.generated.resources.cd_failed_to_load_image import librechat_mobile.feature.chat.generated.resources.dialog_table -import librechat_mobile.feature.chat.generated.resources.label_input -import librechat_mobile.feature.chat.generated.resources.label_output -import librechat_mobile.feature.chat.generated.resources.label_thinking import librechat_mobile.feature.chat.generated.resources.sender_assistant import librechat_mobile.feature.chat.generated.resources.sender_you import org.jetbrains.compose.resources.stringResource -import com.garfiec.librechat.feature.chat.components.artifact.ArtifactButton -import com.garfiec.librechat.feature.chat.components.artifact.ArtifactPanel -import com.garfiec.librechat.feature.chat.components.artifact.ArtifactSegment -import com.garfiec.librechat.feature.chat.components.artifact.detectArtifacts -import com.garfiec.librechat.feature.chat.components.artifact.groupArtifactVersions private const val ACTION_AUTO_HIDE_MILLIS = 30_000L @@ -364,72 +336,18 @@ actual fun ContentPartRenderer( showImageDescriptions: Boolean, searchQuery: String?, searchFocusedOccurrence: Int, onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?, ) { - val mod = modifier.fillMaxWidth() - when (part.type) { - ContentType.TEXT, ContentType.TEXT_DELTA -> { - val text = part.text.orEmpty() - if (text.isNotBlank()) { - val segments = remember(text) { detectArtifacts(text) } - val hasArtifacts = remember(segments) { segments.any { it is ArtifactSegment.ArtifactReference } } - if (!hasArtifacts) { - MarkdownContent(text, mod, fontSizeMultiplier, useKatex, searchQuery, searchFocusedOccurrence, onFocusedOccurrencePositioned) - } else { - val versionMap = remember(segments) { groupArtifactVersions(segments) } - var activeArtifact by remember { - mutableStateOf(null) - } - Column(modifier = mod) { - segments.forEach { segment -> - when (segment) { - is ArtifactSegment.Text -> { - MarkdownContent(segment.text, Modifier.fillMaxWidth(), fontSizeMultiplier, useKatex, searchQuery, searchFocusedOccurrence, onFocusedOccurrencePositioned) - } - is ArtifactSegment.ArtifactReference -> { - val versions = versionMap[segment.artifact.identifier] ?: listOf(segment.artifact) - Spacer(modifier = Modifier.height(8.dp)) - ArtifactButton( - artifact = segment.artifact, - onClick = { activeArtifact = segment.artifact }, - versionCount = versions.size, - ) - Spacer(modifier = Modifier.height(8.dp)) - } - } - } - } - activeArtifact?.let { artifact -> - val versions = versionMap[artifact.identifier] ?: listOf(artifact) - ArtifactPanel( - artifact = artifact, - onDismiss = { activeArtifact = null }, - versions = versions, - ) - } - } - } - } - ContentType.THINK -> ThinkingBlock(part.think.orEmpty(), mod, fontSizeMultiplier, useKatex) - ContentType.TOOL_CALL -> { - val tc = part.toolCall - ToolCallCard(tc?.name ?: tc?.function?.name ?: "Tool Call", tc?.function?.arguments, tc?.output ?: tc?.function?.output, mod) - } - ContentType.IMAGE_FILE -> { - val url = part.imageFile?.filepath?.let { fp -> - when { - fp.startsWith("http") -> fp - fp.startsWith("/images/") && baseUrl.isNotBlank() -> "$baseUrl$fp" - baseUrl.isNotBlank() -> "$baseUrl/api/files/$fp" - else -> fp - } - } ?: part.imageFile?.fileId?.let { if (baseUrl.isNotBlank()) "$baseUrl/api/files/$it" else null } - ImageContentPart(url, mod) - } - ContentType.IMAGE_URL -> ImageContentPart(part.imageUrl?.url, mod) - ContentType.VIDEO_URL -> part.videoUrl?.url?.let { VideoContent(it, mod) } - ContentType.INPUT_AUDIO -> AudioContent(part.inputAudio?.data, part.inputAudio?.format, mod) - ContentType.ERROR -> ErrorContentPart(part.error ?: part.text.orEmpty(), mod) - else -> if (!part.text.isNullOrEmpty()) MarkdownContent(part.text.orEmpty(), mod, fontSizeMultiplier, useKatex) - } + ContentPartDispatcher( + part = part, + modifier = modifier, + baseUrl = baseUrl, + fontSizeMultiplier = fontSizeMultiplier, + useKatex = useKatex, + attachments = attachments, + showImageDescriptions = showImageDescriptions, + searchQuery = searchQuery, + searchFocusedOccurrence = searchFocusedOccurrence, + onFocusedOccurrencePositioned = onFocusedOccurrencePositioned, + ) } // ─── MarkdownContent ───────────────────────────────────────────────── @@ -753,67 +671,3 @@ private fun androidx.compose.ui.text.TextStyle.scale(m: Float): androidx.compose ) } -// ─── Sub-components ────────────────────────────────────────────────── - -@Composable -private fun ThinkingBlock(text: String, modifier: Modifier = Modifier, fontSizeMultiplier: Float = 1f, useKatex: Boolean = false) { - var expanded by remember { mutableStateOf(false) } - Column(modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceContainerLow)) { - Row( - Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { expanded = !expanded }.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Default.Psychology, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.label_thinking), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f)) - Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, stringResource(if (expanded) Res.string.cd_collapse else Res.string.cd_expand), Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) { - Column(Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { - Spacer(Modifier.height(8.dp)) - MarkdownContent(text, fontSizeMultiplier = fontSizeMultiplier, useKatex = useKatex) - } - } - } -} - -@Composable -private fun ToolCallCard(toolName: String, args: String?, output: String?, modifier: Modifier = Modifier) { - var expanded by remember { mutableStateOf(false) } - Card(modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), shape = RoundedCornerShape(8.dp)) { - Column(Modifier.padding(12.dp)) { - Row(Modifier.fillMaxWidth().clickable { expanded = !expanded }, verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Build, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(8.dp)) - Text(toolName, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f)) - Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, stringResource(if (expanded) Res.string.cd_collapse else Res.string.cd_expand), Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) { - Column { - if (!args.isNullOrBlank()) { Spacer(Modifier.height(8.dp)); Text(stringResource(Res.string.label_input), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant); Spacer(Modifier.height(4.dp)); CodeBlock(code = args, language = "json") } - if (!output.isNullOrBlank()) { Spacer(Modifier.height(8.dp)); Text(stringResource(Res.string.label_output), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant); Spacer(Modifier.height(4.dp)); CodeBlock(code = output, language = null) } - } - } - } - } -} - -@Composable -private fun ImageContentPart(imageUrl: String?, modifier: Modifier = Modifier) { - if (imageUrl == null) return - var showFs by remember { mutableStateOf(false) } - SubcomposeAsyncImage( - model = imageUrl, contentDescription = stringResource(Res.string.cd_embedded_image), contentScale = ContentScale.FillWidth, - modifier = modifier.fillMaxWidth().heightIn(max = 300.dp).clip(RoundedCornerShape(12.dp)).clickable { showFs = true }, - loading = { Box(Modifier.fillMaxWidth().height(120.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp)), Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } }, - error = { Box(Modifier.fillMaxWidth().height(120.dp).background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp)), Alignment.Center) { Icon(Icons.Default.BrokenImage, stringResource(Res.string.cd_failed_to_load_image), Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } }, - ) - if (showFs) FullscreenImageViewer(imageUrl, { showFs = false }) -} - -@Composable -private fun ErrorContentPart(errorText: String, modifier: Modifier = Modifier) { - Row(modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.errorContainer).padding(12.dp), verticalAlignment = Alignment.Top) { - Text(errorText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 55428df..e752a45 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -49,6 +49,8 @@ kotlin { implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel.navigation) + implementation(libs.coil3.compose) + implementation(libs.coil3.network.ktor) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/app/LibreChatApp.kt b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/app/LibreChatApp.kt index 370748c..b841e7f 100644 --- a/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/app/LibreChatApp.kt +++ b/shared/src/commonMain/kotlin/com/garfiec/librechat/shared/app/LibreChatApp.kt @@ -4,10 +4,17 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import coil3.ImageLoader +import coil3.compose.setSingletonImageLoaderFactory +import coil3.memory.MemoryCache +import coil3.network.ktor3.KtorNetworkFetcherFactory +import coil3.request.crossfade import com.garfiec.librechat.core.data.datastore.ThemeDataStore import com.garfiec.librechat.core.data.datastore.ThemeMode import com.garfiec.librechat.core.ui.theme.LibreChatTheme import com.garfiec.librechat.shared.navigation.LibreChatNavHost +import io.ktor.client.HttpClient import org.koin.compose.koinInject /** @@ -22,6 +29,24 @@ import org.koin.compose.koinInject */ @Composable fun LibreChatApp() { + val httpClient = koinInject() + val imageLoaderFactory = remember(httpClient) { + { context: coil3.PlatformContext -> + ImageLoader.Builder(context) + .components { + add(KtorNetworkFetcherFactory(httpClient)) + } + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + } + .crossfade(true) + .build() + } + } + setSingletonImageLoaderFactory(imageLoaderFactory) + val themeDataStore = koinInject() val themeMode by themeDataStore.themeMode.collectAsState(initial = themeDataStore.initialThemeMode) val systemDark = isSystemInDarkTheme()