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:
commit
4d2a074f56
6 changed files with 818 additions and 1038 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue