Skip to content

Commit 04ef8a8

Browse files
Siddhesh2377claude
andcommitted
perf: streaming typewriter effect, startup optimizations, and bug fixes
- Typewriter animation in AssistantStreamingBubble: smooth 2-4 char reveal at ~30fps instead of chunky batch updates, with adaptive pacing - Skip parseThinkingTags when thinking mode is disabled (no regex overhead) - yield() before native generation to let Compose render before CPU saturation - Increase streaming throttle to 100ms (typewriter handles visual smoothness) - Typewriter idles at 100ms when waiting for tokens, 33ms when actively revealing - Defer TTS engine creation off main thread (lazy init on first use) - Defer data integrity check by 2s to let UI render first - Parallelize DataStore reads in MainActivity with async - Fix thread slider off-by-one: all values now selectable - Clean up old plan docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d12f4ae commit 04ef8a8

14 files changed

Lines changed: 87 additions & 3455 deletions

app/src/main/java/com/dark/tool_neuron/NVApplication.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import dagger.hilt.android.HiltAndroidApp
2121
import kotlinx.coroutines.CoroutineScope
2222
import kotlinx.coroutines.Dispatchers
2323
import kotlinx.coroutines.SupervisorJob
24+
import kotlinx.coroutines.delay
2425
import kotlinx.coroutines.flow.first
2526
import kotlinx.coroutines.launch
2627

@@ -54,8 +55,9 @@ class NVApplication : Application() {
5455
TTSManager.init(applicationContext, autoLoad = false)
5556
Log.d(TAG, "TTSManager initialized")
5657

57-
// Run data integrity check after UMS is ready
58+
// Run data integrity check after UMS is ready (deferred to let UI render first)
5859
appScope.launch {
60+
delay(2000) // Let Activity.onCreate + first frame complete before scanning
5961
try {
6062
if (!VaultManager.isReady.value) {
6163
Log.w(TAG, "UMS not ready, skipping integrity check")

app/src/main/java/com/dark/tool_neuron/activity/MainActivity.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import com.dark.tool_neuron.worker.NotificationPermissionHelper
4848
import dagger.hilt.android.AndroidEntryPoint
4949
import jakarta.inject.Inject
5050
import kotlinx.coroutines.Dispatchers
51+
import kotlinx.coroutines.async
52+
import kotlinx.coroutines.coroutineScope
5153
import kotlinx.coroutines.flow.first
5254
import kotlinx.coroutines.launch
5355
import kotlinx.coroutines.withContext
@@ -90,13 +92,13 @@ class MainActivity : ComponentActivity() {
9092

9193
LaunchedEffect(Unit) {
9294
withContext(Dispatchers.IO) {
93-
val termsDataStore = TermsDataStore(context)
94-
val setupDataStore = SetupDataStore(context)
95-
val appSettings = AppSettingsDataStore(context)
96-
97-
val termsAccepted = termsDataStore.hasAcceptedTerms.first()
98-
val setupDone = setupDataStore.isSetupDone.first()
99-
val guideSeen = appSettings.guideSeen.first()
95+
// Parallelize DataStore reads — each opens a separate file
96+
val (termsAccepted, setupDone, guideSeen) = coroutineScope {
97+
val t = async { TermsDataStore(context).hasAcceptedTerms.first() }
98+
val s = async { SetupDataStore(context).isSetupDone.first() }
99+
val g = async { AppSettingsDataStore(context).guideSeen.first() }
100+
Triple(t.await(), s.await(), g.await())
101+
}
100102

101103
// Auto-init vault for returning users (exists on disk but not yet opened)
102104
if (!VaultManager.isReady.value && VaultManager.exists(context)) {

app/src/main/java/com/dark/tool_neuron/tts/TTSManager.kt

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ object TTSManager {
4444

4545
fun init(appContext: Context, autoLoad: Boolean = true) {
4646
context = appContext.applicationContext
47-
tts = SupertonicTTS(appContext)
48-
Log.d(TAG, "TTSManager initialized")
47+
// Defer native TTS engine creation off main thread — SupertonicTTS()
48+
// loads native libs via JNI which can block for 100-500ms
49+
Log.d(TAG, "TTSManager initialized (engine deferred)")
4950

5051
if (autoLoad) {
5152
// Auto-load model if it exists in the models directory
@@ -57,8 +58,18 @@ object TTSManager {
5758
}
5859
}
5960

61+
/** Lazily create the TTS engine on first use (off main thread). */
62+
private fun ensureEngine(): SupertonicTTS? {
63+
if (tts == null) {
64+
val ctx = context ?: return null
65+
tts = SupertonicTTS(ctx)
66+
Log.d(TAG, "SupertonicTTS engine created (lazy)")
67+
}
68+
return tts
69+
}
70+
6071
fun loadModel(modelDir: String, useNNAPI: Boolean = false): Boolean {
61-
val engine = tts ?: return false
72+
val engine = ensureEngine() ?: return false
6273
return try {
6374
val success = engine.loadModel(modelDir, useNNAPI)
6475
_isModelLoaded.value = success
@@ -79,7 +90,7 @@ object TTSManager {
7990
fun isLoaded(): Boolean = _isModelLoaded.value
8091

8192
suspend fun speak(text: String, settings: TTSSettings = TTSSettings(), msgId: String? = null) {
82-
val engine = tts ?: return
93+
val engine = ensureEngine() ?: return
8394
if (!_isModelLoaded.value) {
8495
Log.w(TAG, "TTS model not loaded, cannot speak")
8596
return
@@ -139,7 +150,7 @@ object TTSManager {
139150
}
140151

141152
fun stopPlayback() {
142-
tts?.stopPlayback()
153+
ensureEngine()?.stopPlayback()
143154
_isPlaying.value = false
144155
_currentPlayingMsgId.value = null
145156
_isSynthesizing.value = false

app/src/main/java/com/dark/tool_neuron/ui/screen/home/BodyContent.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,8 @@ fun BodyContent(
126126
currentToolChainRound = agent.currentRound,
127127
agentPhase = agent.phase,
128128
agentPlan = agent.plan,
129-
agentSummary = agent.summary
129+
agentSummary = agent.summary,
130+
thinkingEnabled = chatState.thinkingEnabled
130131
)
131132
} else {
132133
val deduped = remember(messages.size) { messages.distinctBy { it.msgId } }

app/src/main/java/com/dark/tool_neuron/ui/screen/home/MessageBubbles.kt

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.dark.tool_neuron.ui.components.ExpandCollapseIcon
2626
import com.dark.tool_neuron.ui.components.MarkdownText
2727
import com.dark.tool_neuron.ui.icons.TnIcons
2828
import com.dark.tool_neuron.ui.theme.Motion
29+
import kotlinx.coroutines.delay
2930
import java.util.Base64
3031
import com.dark.tool_neuron.global.Standards
3132

@@ -61,8 +62,38 @@ internal fun UserMessageBubble(message: Messages) {
6162
// ── AssistantStreamingBubble ──
6263

6364
@Composable
64-
internal fun AssistantStreamingBubble(text: String) {
65-
val parsedMessage = remember(text) { parseThinkingTags(text) }
65+
internal fun AssistantStreamingBubble(text: String, thinkingEnabled: Boolean = false) {
66+
// ── Typewriter effect ──
67+
// Smoothly reveals text 2-4 chars per tick instead of chunky batch updates
68+
var revealedLen by remember { mutableIntStateOf(0) }
69+
val latestText by rememberUpdatedState(text)
70+
71+
LaunchedEffect(Unit) {
72+
while (true) {
73+
val target = latestText.length
74+
if (revealedLen < target) {
75+
val behind = target - revealedLen
76+
val step = when {
77+
behind > 20 -> 4 // far behind: catch up faster
78+
behind > 8 -> 3
79+
else -> 2 // normal: gentle reveal
80+
}
81+
revealedLen = minOf(revealedLen + step, target)
82+
delay(33) // ~30 FPS — actively revealing
83+
} else {
84+
delay(100) // idle — waiting for tokens, check less often
85+
}
86+
}
87+
}
88+
89+
val displayed = if (revealedLen < text.length) text.substring(0, revealedLen) else text
90+
91+
// Only parse thinking tags when thinking mode is enabled — skip regex overhead otherwise
92+
val parsedMessage = if (thinkingEnabled) {
93+
remember(displayed) { parseThinkingTags(displayed) }
94+
} else {
95+
ParsedMessage(thinkingContent = null, actualContent = displayed)
96+
}
6697

6798
Column(
6899
modifier = Modifier

app/src/main/java/com/dark/tool_neuron/ui/screen/home/StreamingView.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ internal fun StreamingView(
3838
currentToolChainRound: Int = 0,
3939
agentPhase: AgentPhase = AgentPhase.Idle,
4040
agentPlan: String? = null,
41-
agentSummary: String? = null
41+
agentSummary: String? = null,
42+
thinkingEnabled: Boolean = false
4243
) {
4344
val scrollState = rememberScrollState()
4445

@@ -131,7 +132,7 @@ internal fun StreamingView(
131132
// Show streaming text when in simple flow or during plan/summary generation
132133
agentPhase == AgentPhase.Idle || agentPhase == AgentPhase.Complete -> {
133134
if (assistantMessage.isNotEmpty()) {
134-
AssistantStreamingBubble(text = assistantMessage)
135+
AssistantStreamingBubble(text = assistantMessage, thinkingEnabled = thinkingEnabled)
135136
}
136137
}
137138
}

app/src/main/java/com/dark/tool_neuron/ui/screen/model_config/ModelConfigEditorScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ private fun IntField(
702702
value = value.toFloat(),
703703
onValueChange = { onValueChange(it.toInt()) },
704704
valueRange = range.first.toFloat()..range.last.toFloat(),
705-
steps = (range.last - range.first) / step - 1,
705+
steps = (range.last - range.first) / step,
706706
enabled = enabled
707707
)
708708
}

app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,12 @@ class ChatViewModel @Inject constructor(
136136
private val userMessageAdded = java.util.concurrent.atomic.AtomicBoolean(false)
137137

138138
// Current model ID for per-message attribution
139-
private val currentModelId: String? get() = LlmModelWorker.currentGgufModelId.value
139+
private val currentModelId: String?
140+
get() = LlmModelWorker.currentGgufModelId.value
141+
142+
/** True when a text generation model is loaded. */
143+
private val isAnyTextModelLoaded: Boolean
144+
get() = LlmModelWorker.isGgufModelLoaded.value
140145

141146
// UI state
142147
private val _showDynamicWindow = MutableStateFlow(false)
@@ -373,7 +378,7 @@ class ChatViewModel @Inject constructor(
373378
// ==================== Unified Text Generation Entry Point ====================
374379

375380
fun sendChat(prompt: String) {
376-
if (!LlmModelWorker.isGgufModelLoaded.value) {
381+
if (!isAnyTextModelLoaded) {
377382
reportError("Please load a text generation model first")
378383
return
379384
}
@@ -396,6 +401,9 @@ class ChatViewModel @Inject constructor(
396401

397402
generationJob = viewModelScope.launch {
398403
try {
404+
// Let Compose render the StreamingView before native engine saturates CPU
405+
kotlinx.coroutines.yield()
406+
399407
// Read maxTokens from the current model's config
400408
val maxTokens = getCurrentModelMaxTokens()
401409

@@ -471,6 +479,9 @@ class ChatViewModel @Inject constructor(
471479

472480
generationJob = viewModelScope.launch {
473481
try {
482+
// Let Compose render the StreamingView before native engine saturates CPU
483+
kotlinx.coroutines.yield()
484+
474485
val maxTokens = getCurrentModelMaxTokens()
475486
val isNewChat = isNewConversation
476487

@@ -1115,9 +1126,9 @@ class ChatViewModel @Inject constructor(
11151126
var lastRepCheckLen = 0
11161127
var repetitionTrimIndex = -1
11171128

1118-
LlmModelWorker.ggufGenerateMultiTurnStreaming(
1119-
jsonArray.toString(), maxTokens
1120-
).collect { event ->
1129+
val generationFlow = LlmModelWorker.ggufGenerateMultiTurnStreaming(jsonArray.toString(), maxTokens)
1130+
1131+
generationFlow.collect { event ->
11211132
when (event) {
11221133
is GenerationEvent.Token -> {
11231134
val validText = utf8Buffer.append(event.text)
@@ -1885,7 +1896,9 @@ class ChatViewModel @Inject constructor(
18851896

18861897
// 2. Stop native generation (synchronous signal to engine)
18871898
when (_currentGenerationType.value) {
1888-
ModelType.TEXT_GENERATION -> LlmModelWorker.ggufStopGeneration()
1899+
ModelType.TEXT_GENERATION -> {
1900+
LlmModelWorker.ggufStopGeneration()
1901+
}
18891902
ModelType.IMAGE_GENERATION -> LlmModelWorker.stopDiffusionGeneration()
18901903
ModelType.AUDIO_GENERATION -> stopTTS()
18911904
}
@@ -2124,7 +2137,7 @@ class ChatViewModel @Inject constructor(
21242137
private const val TAG = "ChatViewModel"
21252138
private const val PLAN_MAX_TOKENS = 150
21262139
private const val SUMMARY_MAX_TOKENS = 512
2127-
private const val STREAMING_THROTTLE_MS = 50L
2140+
private const val STREAMING_THROTTLE_MS = 100L
21282141
private const val REPETITION_CHECK_INTERVAL = 200
21292142
private const val REPETITION_MIN_PATTERN_LEN = 30
21302143
private const val REPETITION_MIN_REPEATS = 4

0 commit comments

Comments
 (0)