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
|
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.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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
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 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
|
@Composable
|
||||||
actual fun ContentPartRenderer(
|
actual fun ContentPartRenderer(
|
||||||
|
|
@ -82,821 +18,16 @@ actual fun ContentPartRenderer(
|
||||||
searchFocusedOccurrence: Int,
|
searchFocusedOccurrence: Int,
|
||||||
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
|
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val constrainedModifier = modifier.fillMaxWidth()
|
ContentPartDispatcher(
|
||||||
when (part.type) {
|
part = part,
|
||||||
ContentType.TEXT, ContentType.TEXT_DELTA -> {
|
modifier = modifier,
|
||||||
TextContentPart(
|
baseUrl = baseUrl,
|
||||||
text = part.text.orEmpty(),
|
fontSizeMultiplier = fontSizeMultiplier,
|
||||||
fontSizeMultiplier = fontSizeMultiplier,
|
useKatex = useKatex,
|
||||||
useKatex = useKatex,
|
attachments = attachments,
|
||||||
searchQuery = searchQuery,
|
showImageDescriptions = showImageDescriptions,
|
||||||
searchFocusedOccurrence = searchFocusedOccurrence,
|
searchQuery = searchQuery,
|
||||||
onFocusedOccurrencePositioned = onFocusedOccurrencePositioned,
|
searchFocusedOccurrence = searchFocusedOccurrence,
|
||||||
modifier = constrainedModifier,
|
onFocusedOccurrencePositioned = onFocusedOccurrencePositioned,
|
||||||
)
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Close
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
|
||||||
import androidx.compose.material.icons.filled.Fullscreen
|
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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -56,7 +43,6 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.compositeOver
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coil3.compose.SubcomposeAsyncImage
|
|
||||||
import com.garfiec.librechat.core.common.ChatLayoutConstants
|
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.Message
|
||||||
import com.garfiec.librechat.core.model.MessageContentPart
|
import com.garfiec.librechat.core.model.MessageContentPart
|
||||||
import com.garfiec.librechat.core.ui.components.AvatarImage
|
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 org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import librechat_mobile.feature.chat.generated.resources.Res
|
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_expand_table
|
||||||
import librechat_mobile.feature.chat.generated.resources.cd_close
|
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.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_assistant
|
||||||
import librechat_mobile.feature.chat.generated.resources.sender_you
|
import librechat_mobile.feature.chat.generated.resources.sender_you
|
||||||
import org.jetbrains.compose.resources.stringResource
|
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
|
private const val ACTION_AUTO_HIDE_MILLIS = 30_000L
|
||||||
|
|
||||||
|
|
@ -364,72 +336,18 @@ actual fun ContentPartRenderer(
|
||||||
showImageDescriptions: Boolean, searchQuery: String?, searchFocusedOccurrence: Int,
|
showImageDescriptions: Boolean, searchQuery: String?, searchFocusedOccurrence: Int,
|
||||||
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
|
onFocusedOccurrencePositioned: ((LayoutCoordinates) -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val mod = modifier.fillMaxWidth()
|
ContentPartDispatcher(
|
||||||
when (part.type) {
|
part = part,
|
||||||
ContentType.TEXT, ContentType.TEXT_DELTA -> {
|
modifier = modifier,
|
||||||
val text = part.text.orEmpty()
|
baseUrl = baseUrl,
|
||||||
if (text.isNotBlank()) {
|
fontSizeMultiplier = fontSizeMultiplier,
|
||||||
val segments = remember(text) { detectArtifacts(text) }
|
useKatex = useKatex,
|
||||||
val hasArtifacts = remember(segments) { segments.any { it is ArtifactSegment.ArtifactReference } }
|
attachments = attachments,
|
||||||
if (!hasArtifacts) {
|
showImageDescriptions = showImageDescriptions,
|
||||||
MarkdownContent(text, mod, fontSizeMultiplier, useKatex, searchQuery, searchFocusedOccurrence, onFocusedOccurrencePositioned)
|
searchQuery = searchQuery,
|
||||||
} else {
|
searchFocusedOccurrence = searchFocusedOccurrence,
|
||||||
val versionMap = remember(segments) { groupArtifactVersions(segments) }
|
onFocusedOccurrencePositioned = onFocusedOccurrencePositioned,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MarkdownContent ─────────────────────────────────────────────────
|
// ─── 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)
|
||||||
implementation(libs.koin.compose.viewmodel)
|
implementation(libs.koin.compose.viewmodel)
|
||||||
implementation(libs.koin.compose.viewmodel.navigation)
|
implementation(libs.koin.compose.viewmodel.navigation)
|
||||||
|
implementation(libs.coil3.compose)
|
||||||
|
implementation(libs.coil3.network.ktor)
|
||||||
}
|
}
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
implementation(libs.ktor.client.darwin)
|
implementation(libs.ktor.client.darwin)
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,17 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.ThemeDataStore
|
||||||
import com.garfiec.librechat.core.data.datastore.ThemeMode
|
import com.garfiec.librechat.core.data.datastore.ThemeMode
|
||||||
import com.garfiec.librechat.core.ui.theme.LibreChatTheme
|
import com.garfiec.librechat.core.ui.theme.LibreChatTheme
|
||||||
import com.garfiec.librechat.shared.navigation.LibreChatNavHost
|
import com.garfiec.librechat.shared.navigation.LibreChatNavHost
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22,6 +29,24 @@ import org.koin.compose.koinInject
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LibreChatApp() {
|
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 themeDataStore = koinInject<ThemeDataStore>()
|
||||||
val themeMode by themeDataStore.themeMode.collectAsState(initial = themeDataStore.initialThemeMode)
|
val themeMode by themeDataStore.themeMode.collectAsState(initial = themeDataStore.initialThemeMode)
|
||||||
val systemDark = isSystemInDarkTheme()
|
val systemDark = isSystemInDarkTheme()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue