Merge pull request #118 from Hy4ri/feat/issue-60-tool-json-parsing

feat(#60): human-readable tool JSON parsing in chat
This commit is contained in:
M57 authored and GitHub committed 2026-06-19 23:32:27 +03:00
commit b1c628ee24
1 file changed
+203 -1
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.widthIn import androidx.compose.foundation.layout.widthIn
@@ -285,6 +287,168 @@ private fun SystemBubble(
} }
} }
private data class ParsedToolOutput(
val isTerminal: Boolean = false,
val stdout: String? = null,
val stderr: String? = null,
val exitCode: Int? = null,
val error: String? = null,
val mainOutput: String? = null,
val genericFields: Map<String, String> = emptyMap(),
)
private fun parseToolOutput(content: String): ParsedToolOutput? {
val trimmed = content.trim()
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null
return try {
val element = com.google.gson.JsonParser.parseString(trimmed)
if (element.isJsonObject) {
val obj = element.asJsonObject
val hasStdout = obj.has("stdout")
val hasStderr = obj.has("stderr")
val hasExitCode = obj.has("exit_code") || obj.has("exitCode")
if (hasStdout || hasStderr || hasExitCode) {
val stdout = obj.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val stderr = obj.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
val exitCode = (obj.get("exit_code") ?: obj.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt
val error = obj.get("error")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() }
ParsedToolOutput(
isTerminal = true,
stdout = stdout,
stderr = stderr,
exitCode = exitCode,
error = error,
)
} else {
val mainOutput =
(obj.get("output") ?: obj.get("result") ?: obj.get("content") ?: obj.get("text"))
?.takeIf { !it.isJsonNull }
?.let {
if (it.isJsonPrimitive) it.asString else it.toString()
}?.takeIf { it.isNotEmpty() }
val genericFields = mutableMapOf<String, String>()
obj.entrySet().forEach { (key, value) ->
if (key != "output" && key != "result" && key != "content" && key != "text" && !value.isJsonNull) {
val valStr = if (value.isJsonPrimitive) value.asString else value.toString()
if (valStr.isNotEmpty() && valStr != "null") {
genericFields[key] = valStr
}
}
}
ParsedToolOutput(
mainOutput = mainOutput,
genericFields = genericFields,
)
}
} else if (element.isJsonArray) {
val arr = element.asJsonArray
ParsedToolOutput(
mainOutput = arr.toString(),
)
} else {
null
}
} catch (e: Exception) {
null
}
}
@Composable
private fun ParsedToolContent(
parsed: ParsedToolOutput,
contentColor: Color,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (parsed.isTerminal) {
parsed.stdout?.let {
Text(
text = it,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
parsed.stderr?.let {
Text(
text = "Error Output (stderr):\n$it",
style =
MaterialTheme.typography.bodySmall.copy(
color = StatusRed.copy(alpha = 0.9f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
parsed.error?.let {
Text(
text = "Execution Error: $it",
style =
MaterialTheme.typography.bodySmall.copy(
color = StatusRed,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
parsed.exitCode?.let { code ->
if (code != 0) {
Text(
text = "Exit Code: $code",
style =
MaterialTheme.typography.labelSmall.copy(
color = StatusRed,
fontWeight = FontWeight.Medium,
),
)
}
}
} else {
parsed.mainOutput?.let {
Text(
text = it,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontSize = 12.sp,
),
)
}
parsed.genericFields.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "$key:",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold,
),
)
Text(
text = value,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontSize = 11.sp,
),
)
}
}
}
}
}
@Composable @Composable
private fun ToolBubble( private fun ToolBubble(
message: ChatMessage, message: ChatMessage,
@@ -292,9 +456,12 @@ private fun ToolBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var showRawJson by remember { mutableStateOf(false) }
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black val contentColor = if (isDarkTheme) Color.White else Color.Black
val parsedOutput = remember(message.content) { parseToolOutput(message.content) }
Box( Box(
modifier = modifier =
modifier modifier
@@ -351,6 +518,24 @@ private fun ToolBubble(
} }
if (expanded && message.content.isNotBlank()) { if (expanded && message.content.isNotBlank()) {
Column(modifier = Modifier.padding(top = 6.dp)) {
if (parsedOutput != null && !showRawJson) {
ParsedToolContent(parsedOutput, contentColor)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Show Raw JSON",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { showRawJson = true })
},
)
} else {
Text( Text(
text = message.content, text = message.content,
style = style =
@@ -359,8 +544,25 @@ private fun ToolBubble(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 11.sp, fontSize = 11.sp,
), ),
modifier = Modifier.padding(top = 6.dp),
) )
if (parsedOutput != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Show Parsed Output",
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { showRawJson = false })
},
)
}
}
}
} }
} }
} }