Merge pull request #16 from garfiec/fix/ios-image-rendering

fix: configure Coil ImageLoader for iOS and add missing tool call card rendering
This commit is contained in:
Garfie Chiu 2026-04-06 19:34:43 -05:00 committed by GitHub
commit 4d2a074f56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 818 additions and 1038 deletions

View file

@ -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<com.garfiec.librechat.feature.chat.components.artifact.Artifact?>(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<WebSearchResult> {
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<McpResource> {
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<com.garfiec.librechat.core.model.Attachment> = 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()
}
}

View file

@ -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<Attachment> = 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<Attachment>,
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<Artifact?>(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)
}
}

View file

@ -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<WebSearchResult> {
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<McpResource> {
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<Attachment> = 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()
}
}

View file

@ -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<com.garfiec.librechat.feature.chat.components.artifact.Artifact?>(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)
}
}

View file

@ -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)

View file

@ -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<HttpClient>()
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<ThemeDataStore>()
val themeMode by themeDataStore.themeMode.collectAsState(initial = themeDataStore.initialThemeMode)
val systemDark = isSystemInDarkTheme()