Skip to content

Commit b818840

Browse files
authored
Add Markdown rendering and collapsible thinking block to LlamaDemo (#228)
- Render model responses as Markdown using compose-richtext library - Parse <think>/<\/think> tags into a separate collapsible thinking block with animated expand/collapse and vertical bar decoration - Always enable thinking mode for Qwen-3 and remove manual toggle button - User messages remain plain text
1 parent 01463a3 commit b818840

7 files changed

Lines changed: 175 additions & 39 deletions

File tree

llm/android/LlamaDemo/app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ dependencies {
270270
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
271271
implementation("com.facebook.fbjni:fbjni:0.5.1")
272272
implementation("com.google.code.gson:gson:2.8.6")
273+
implementation("com.halilibo.compose-richtext:richtext-commonmark:1.0.0-alpha02")
274+
implementation("com.halilibo.compose-richtext:richtext-ui-material3:1.0.0-alpha02")
273275
if (useLocalAar == true) {
274276
implementation(files("libs/executorch.aar"))
275277
} else {

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,16 @@ class Message(
4444

4545
var totalGenerationTime: Long = 0L
4646

47+
var thinkingContent: String = ""
48+
4749
fun appendText(text: String) {
4850
this.text += text
4951
}
5052

53+
fun appendThinkingText(text: String) {
54+
thinkingContent += text
55+
}
56+
5157
/**
5258
* Creates a new Message instance with the same state.
5359
* Required for Compose strong skipping mode (default since Kotlin 2.0): composable functions
@@ -59,6 +65,7 @@ class Message(
5965
return Message(sourceText, isSent, messageType, promptID, timestamp).also {
6066
it.tokensPerSecond = tokensPerSecond
6167
it.totalGenerationTime = totalGenerationTime
68+
it.thinkingContent = thinkingContent
6269
}
6370
}
6471

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/PromptFormat.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ object PromptFormat {
6464
@JvmStatic
6565
fun getThinkingModeToken(modelType: ModelType, thinkingMode: Boolean): String {
6666
return when (modelType) {
67-
ModelType.QWEN_3 -> if (thinkingMode) "" else "<think>\n\n</think>\n\n\n"
67+
ModelType.QWEN_3 -> "" // Always enable thinking for Qwen-3
6868
else -> ""
6969
}
7070
}
@@ -74,8 +74,6 @@ object PromptFormat {
7474
return when (modelType) {
7575
ModelType.QWEN_3 -> when (token) {
7676
"<|im_end|>" -> ""
77-
"<think>" -> "Thinking...\n"
78-
"</think>" -> "\nDone thinking"
7977
else -> token
8078
}
8179
else -> token

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/ChatInput.kt

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import androidx.compose.foundation.text.BasicTextField
3535
import androidx.compose.material.icons.Icons
3636
import androidx.compose.material.icons.filled.Add
3737
import androidx.compose.material.icons.filled.Close
38-
import androidx.compose.material.icons.filled.Lightbulb
3938
import androidx.compose.material.icons.filled.Send
4039
import androidx.compose.material.icons.filled.Stop
4140
import androidx.compose.material.icons.outlined.AudioFile
@@ -70,8 +69,6 @@ fun ChatInput(
7069
onInputTextChange: (String) -> Unit,
7170
isModelReady: Boolean,
7271
isGenerating: Boolean,
73-
thinkMode: Boolean,
74-
onThinkModeToggle: () -> Unit,
7572
onSendClick: () -> Unit,
7673
onStopClick: () -> Unit,
7774
showMediaButtons: Boolean,
@@ -151,18 +148,6 @@ fun ChatInput(
151148
}
152149
}
153150

154-
// Think mode button
155-
IconButton(
156-
onClick = onThinkModeToggle,
157-
modifier = Modifier.size(40.dp)
158-
) {
159-
Icon(
160-
imageVector = Icons.Filled.Lightbulb,
161-
contentDescription = "Think mode",
162-
tint = if (thinkMode) Color(0xFFFFD54F) else appColors.textOnNavBar
163-
)
164-
}
165-
166151
// Text input
167152
Box(
168153
modifier = Modifier

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/components/MessageItem.kt

Lines changed: 128 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,44 @@
99
package com.example.executorchllamademo.ui.components
1010

1111
import android.net.Uri
12+
import androidx.compose.animation.AnimatedVisibility
13+
import androidx.compose.animation.core.animateFloatAsState
14+
import androidx.compose.animation.expandVertically
15+
import androidx.compose.animation.shrinkVertically
1216
import androidx.compose.foundation.background
17+
import androidx.compose.foundation.clickable
1318
import androidx.compose.foundation.layout.Arrangement
1419
import androidx.compose.foundation.layout.Box
1520
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.IntrinsicSize
1622
import androidx.compose.foundation.layout.Row
1723
import androidx.compose.foundation.layout.Spacer
24+
import androidx.compose.foundation.layout.fillMaxHeight
1825
import androidx.compose.foundation.layout.fillMaxWidth
26+
import androidx.compose.foundation.layout.height
1927
import androidx.compose.foundation.layout.padding
28+
import androidx.compose.foundation.layout.size
2029
import androidx.compose.foundation.layout.width
2130
import androidx.compose.foundation.layout.widthIn
2231
import androidx.compose.foundation.shape.RoundedCornerShape
32+
import androidx.compose.material.icons.Icons
33+
import androidx.compose.material.icons.filled.KeyboardArrowDown
34+
import androidx.compose.material3.Icon
2335
import androidx.compose.material3.Text
2436
import androidx.compose.runtime.Composable
37+
import androidx.compose.runtime.getValue
38+
import androidx.compose.runtime.mutableStateOf
39+
import androidx.compose.runtime.remember
40+
import androidx.compose.runtime.setValue
2541
import androidx.compose.ui.Alignment
2642
import androidx.compose.ui.Modifier
2743
import androidx.compose.ui.draw.clip
44+
import androidx.compose.ui.draw.rotate
2845
import androidx.compose.ui.graphics.Color
2946
import androidx.compose.ui.layout.ContentScale
3047
import androidx.compose.ui.platform.LocalConfiguration
48+
import androidx.compose.ui.text.TextStyle
49+
import androidx.compose.ui.text.font.FontFamily
3150
import androidx.compose.ui.text.font.FontStyle
3251
import androidx.compose.ui.unit.dp
3352
import androidx.compose.ui.unit.sp
@@ -36,6 +55,10 @@ import com.example.executorchllamademo.Message
3655
import com.example.executorchllamademo.MessageType
3756
import com.example.executorchllamademo.ui.theme.LocalAppColors
3857
import com.example.executorchllamademo.ui.theme.MessageBubbleSent
58+
import com.halilibo.richtext.commonmark.Markdown
59+
import com.halilibo.richtext.ui.CodeBlockStyle
60+
import com.halilibo.richtext.ui.RichTextStyle
61+
import com.halilibo.richtext.ui.material3.RichText
3962

4063
@Composable
4164
fun MessageItem(
@@ -151,12 +174,39 @@ private fun TextMessage(
151174
)
152175
.padding(horizontal = 12.dp, vertical = 8.dp)
153176
) {
154-
Text(
155-
text = message.text,
156-
fontSize = 16.sp,
157-
letterSpacing = 0.sp,
158-
color = textColor
159-
)
177+
if (isSent) {
178+
// User messages: plain text
179+
Text(
180+
text = message.text,
181+
fontSize = 16.sp,
182+
letterSpacing = 0.sp,
183+
color = textColor
184+
)
185+
} else {
186+
// Thinking block (collapsible, shown before response)
187+
if (message.thinkingContent.isNotEmpty()) {
188+
ThinkingBlock(
189+
content = message.thinkingContent,
190+
textColor = textColor
191+
)
192+
}
193+
// Model responses: Markdown rendering
194+
if (message.text.isNotEmpty()) {
195+
RichText(
196+
style = RichTextStyle(
197+
codeBlockStyle = CodeBlockStyle(
198+
textStyle = TextStyle(
199+
fontSize = 14.sp,
200+
fontFamily = FontFamily.Monospace,
201+
color = textColor
202+
)
203+
)
204+
)
205+
) {
206+
Markdown(content = message.text)
207+
}
208+
}
209+
}
160210

161211
// Show metrics and timestamp on the same row
162212
val hasMetrics = message.tokensPerSecond > 0 || message.totalGenerationTime > 0
@@ -207,3 +257,75 @@ private fun TextMessage(
207257
}
208258
}
209259
}
260+
261+
@Composable
262+
private fun ThinkingBlock(
263+
content: String,
264+
textColor: Color,
265+
modifier: Modifier = Modifier
266+
) {
267+
var expanded by remember { mutableStateOf(false) }
268+
val rotationAngle by animateFloatAsState(
269+
targetValue = if (expanded) 180f else 0f,
270+
label = "thinking_expand"
271+
)
272+
273+
Column(modifier = modifier.padding(bottom = 8.dp)) {
274+
// Header: clickable to expand/collapse
275+
Row(
276+
modifier = Modifier
277+
.clickable { expanded = !expanded }
278+
.padding(vertical = 4.dp),
279+
verticalAlignment = Alignment.CenterVertically
280+
) {
281+
Icon(
282+
imageVector = Icons.Default.KeyboardArrowDown,
283+
contentDescription = if (expanded) "Collapse" else "Expand",
284+
tint = textColor.copy(alpha = 0.5f),
285+
modifier = Modifier
286+
.size(18.dp)
287+
.rotate(rotationAngle)
288+
)
289+
Spacer(modifier = Modifier.width(4.dp))
290+
Text(
291+
text = "Thinking",
292+
fontSize = 13.sp,
293+
fontStyle = FontStyle.Italic,
294+
color = textColor.copy(alpha = 0.5f)
295+
)
296+
}
297+
298+
// Collapsible thinking content
299+
AnimatedVisibility(
300+
visible = expanded,
301+
enter = expandVertically(),
302+
exit = shrinkVertically()
303+
) {
304+
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
305+
// Left vertical line decoration
306+
Box(
307+
modifier = Modifier
308+
.width(2.dp)
309+
.fillMaxHeight()
310+
.background(textColor.copy(alpha = 0.15f))
311+
)
312+
Spacer(modifier = Modifier.width(8.dp))
313+
// Thinking content with Markdown rendering
314+
RichText(
315+
modifier = Modifier.weight(1f),
316+
style = RichTextStyle(
317+
codeBlockStyle = CodeBlockStyle(
318+
textStyle = TextStyle(
319+
fontSize = 13.sp,
320+
fontFamily = FontFamily.Monospace,
321+
color = textColor.copy(alpha = 0.7f)
322+
)
323+
)
324+
)
325+
) {
326+
Markdown(content = content)
327+
}
328+
}
329+
}
330+
}
331+
}

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,6 @@ fun ChatScreen(
195195
onInputTextChange = { viewModel.inputText = it },
196196
isModelReady = viewModel.isModelReady,
197197
isGenerating = viewModel.isGenerating,
198-
thinkMode = viewModel.thinkMode,
199-
onThinkModeToggle = { viewModel.toggleThinkMode() },
200198
onSendClick = { viewModel.sendMessage() },
201199
onStopClick = { viewModel.stopGeneration() },
202200
showMediaButtons = viewModel.showMediaButtons,

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
6060
var supportsImageInput by mutableStateOf(false)
6161
var supportsAudioInput by mutableStateOf(false)
6262

63+
// Thinking mode state: tracks whether we're inside a <think>...</think> block
64+
private var isInThinkingBlock = false
65+
6366
// Counter that increments on each token to trigger auto-scroll during generation
6467
var scrollTrigger by mutableStateOf(0)
6568
private set
@@ -643,6 +646,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
643646

644647
// Create result message placeholder
645648
resultMessage = Message("", false, MessageType.TEXT, promptID)
649+
isInThinkingBlock = false
646650
_messages.add(resultMessage!!)
647651

648652
// Clear selected images after adding to chat
@@ -756,6 +760,16 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
756760
return
757761
}
758762

763+
// Thinking mode state machine: intercept <think> / </think> tags
764+
if (processedResult == "<think>") {
765+
isInThinkingBlock = true
766+
return
767+
}
768+
if (processedResult == "</think>") {
769+
isInThinkingBlock = false
770+
return
771+
}
772+
759773
processedResult = PromptFormat.replaceSpecialToken(currentSettingsFields.modelType, processedResult)
760774

761775
if (currentSettingsFields.modelType == ModelType.LLAMA_3 &&
@@ -773,21 +787,31 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
773787
return
774788
}
775789

776-
val keepResult = !(processedResult == "\n" || processedResult == "\n\n") ||
777-
resultMessage?.text?.isNotEmpty() == true
778-
if (keepResult) {
779-
resultMessage?.appendText(processedResult)
780-
// Create a new Message reference to trigger recomposition under Compose strong
781-
// skipping mode, which compares unstable parameters by reference equality (===).
782-
val index = _messages.indexOfLast { it === resultMessage }
783-
if (index >= 0) {
784-
val updated = resultMessage!!.copy()
785-
_messages[index] = updated
786-
resultMessage = updated
790+
if (isInThinkingBlock) {
791+
// Skip leading newlines in thinking content
792+
val keepThinking = !(processedResult == "\n" || processedResult == "\n\n") ||
793+
resultMessage?.thinkingContent?.isNotEmpty() == true
794+
if (keepThinking) {
795+
resultMessage?.appendThinkingText(processedResult)
796+
}
797+
} else {
798+
val keepResult = !(processedResult == "\n" || processedResult == "\n\n") ||
799+
resultMessage?.text?.isNotEmpty() == true
800+
if (keepResult) {
801+
resultMessage?.appendText(processedResult)
787802
}
788-
// Increment scroll trigger to auto-scroll during generation
789-
scrollTrigger++
790803
}
804+
805+
// Create a new Message reference to trigger recomposition under Compose strong
806+
// skipping mode, which compares unstable parameters by reference equality (===).
807+
val index = _messages.indexOfLast { it === resultMessage }
808+
if (index >= 0) {
809+
val updated = resultMessage!!.copy()
810+
_messages[index] = updated
811+
resultMessage = updated
812+
}
813+
// Increment scroll trigger to auto-scroll during generation
814+
scrollTrigger++
791815
}
792816

793817
override fun onStats(stats: String) {

0 commit comments

Comments
 (0)