Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.local_llm

import android.content.Context
import androidx.annotation.ColorRes
import androidx.annotation.StringRes

enum class AppAppearanceMode {
Expand All @@ -11,14 +12,16 @@ enum class AppAppearanceMode {
enum class AppAccentOption(
val darkStyleRes: Int,
val lightStyleRes: Int,
@field:StringRes val labelResId: Int
@field:StringRes val labelResId: Int,
@field:ColorRes val swatchColorRes: Int
) {
OCEAN(R.style.Theme_local_llm, R.style.Theme_local_llm_Light, R.string.accent_blue),
MIDNIGHT(R.style.Theme_local_llm_Midnight, R.style.Theme_local_llm_Midnight_Light, R.string.accent_indigo),
FOREST(R.style.Theme_local_llm_Forest, R.style.Theme_local_llm_Forest_Light, R.string.accent_green),
VIOLET(R.style.Theme_local_llm_Violet, R.style.Theme_local_llm_Violet_Light, R.string.accent_violet),
AMBER(R.style.Theme_local_llm_Amber, R.style.Theme_local_llm_Amber_Light, R.string.accent_amber),
CORAL(R.style.Theme_local_llm_Coral, R.style.Theme_local_llm_Coral_Light, R.string.accent_coral);
POCKET(R.style.Theme_local_llm, R.style.Theme_local_llm_Light, R.string.accent_pocket, R.color.send_button_fill),
OCEAN(R.style.Theme_local_llm_Ocean, R.style.Theme_local_llm_Ocean_Light, R.string.accent_blue, R.color.ocean_send_fill),
MIDNIGHT(R.style.Theme_local_llm_Midnight, R.style.Theme_local_llm_Midnight_Light, R.string.accent_indigo, R.color.midnight_send_fill),
FOREST(R.style.Theme_local_llm_Forest, R.style.Theme_local_llm_Forest_Light, R.string.accent_green, R.color.forest_send_fill),
VIOLET(R.style.Theme_local_llm_Violet, R.style.Theme_local_llm_Violet_Light, R.string.accent_violet, R.color.violet_send_fill),
AMBER(R.style.Theme_local_llm_Amber, R.style.Theme_local_llm_Amber_Light, R.string.accent_amber, R.color.amber_send_fill),
CORAL(R.style.Theme_local_llm_Coral, R.style.Theme_local_llm_Coral_Light, R.string.accent_coral, R.color.coral_send_fill);

fun styleFor(appearance: AppAppearanceMode): Int {
return when (appearance) {
Expand All @@ -31,14 +34,14 @@ enum class AppAccentOption(
fun fromStoredName(name: String?): AppAccentOption {
return when (name) {
"TEAL" -> AMBER
else -> entries.firstOrNull { it.name == name } ?: OCEAN
else -> entries.firstOrNull { it.name == name } ?: POCKET
}
}
}
}

data class AppSettings(
val accent: AppAccentOption = AppAccentOption.OCEAN,
val accent: AppAccentOption = AppAccentOption.POCKET,
val appearance: AppAppearanceMode = AppAppearanceMode.DARK,
val chatFontSizeSp: Float = 16f
)
Expand All @@ -56,7 +59,7 @@ class AppSettingsStore(context: Context) {

fun load(): AppSettings {
val accent = AppAccentOption.fromStoredName(
prefs.getString(KEY_ACCENT, AppAccentOption.OCEAN.name)
prefs.getString(KEY_ACCENT, AppAccentOption.POCKET.name)
)
val appearance = runCatching {
AppAppearanceMode.valueOf(prefs.getString(KEY_APPEARANCE, AppAppearanceMode.DARK.name)!!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ class ChatAdapter(
holder.textView.setBackgroundResource(bubbleBackground)
holder.messageImage.setBackgroundResource(bubbleBackground)
holder.textView.setTextColor(resolveThemeColor(holder.textView, textColorAttr))
val tokensPerSecond = turn.tokensPerSecond
?.takeIf { !turn.isUser && !turn.isStreaming && !turn.isImage && !turn.isAttachment }
holder.speedView.visibility = if (tokensPerSecond == null) View.GONE else View.VISIBLE
holder.speedView.text = tokensPerSecond?.let {
holder.speedView.context.getString(R.string.tokens_per_second_format, it)
}
holder.textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSizeSp)
holder.preResponseStatusView.setTextColor(resolveThemeColor(holder.preResponseStatusView, R.attr.colorStatusText))
holder.preResponseStatusView.setTextSize(TypedValue.COMPLEX_UNIT_SP, (fontSizeSp - 2f).coerceAtLeast(12f))
Expand Down Expand Up @@ -447,6 +453,7 @@ class ChatAdapter(
val messageImage: ImageView = view.findViewById(R.id.messageImage)
val textView: TextView = view.findViewById(R.id.messageText)
val copyButton: ImageButton = view.findViewById(R.id.copyResponseButton)
val speedView: TextView = view.findViewById(R.id.messageSpeed)
var messageTextRenderKey: TextRenderKey? = null
var thoughtTextRenderKey: TextRenderKey? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class ChatSessionStore(context: Context) {
}
turn.thinkingText?.let { put("thinkingText", it) }
turn.thinkingDurationMillis?.let { put("thinkingDurationMillis", it) }
turn.tokensPerSecond?.let { put("tokensPerSecond", it.toDouble()) }
put("stopped", turn.stopped)
put("renderAsMarkdown", turn.renderAsMarkdown)
put("isStreaming", turn.isStreaming)
Expand Down Expand Up @@ -156,6 +157,9 @@ class ChatSessionStore(context: Context) {
thinkingText = turnJson.optString("thinkingText").takeIf { it.isNotBlank() },
thinkingDurationMillis = turnJson.optLong("thinkingDurationMillis")
.takeIf { turnJson.has("thinkingDurationMillis") },
tokensPerSecond = turnJson.optDouble("tokensPerSecond")
.takeIf { turnJson.has("tokensPerSecond") }
?.toFloat(),
stopped = turnJson.optBoolean("stopped"),
renderAsMarkdown = turnJson.optBoolean("renderAsMarkdown", true),
isStreaming = turnJson.optBoolean("isStreaming", false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ data class ChatTurn(
val preResponseStatusText: String? = null,
val thinkingText: String? = null,
val thinkingDurationMillis: Long? = null,
val tokensPerSecond: Float? = null,
val stopped: Boolean = false,
val renderAsMarkdown: Boolean = false,
val isStreaming: Boolean = false,
Expand Down Expand Up @@ -58,6 +59,7 @@ fun ChatTurn.asModelMemoryTurn(): ChatTurn? {
preResponseStatusText = null,
thinkingText = null,
thinkingDurationMillis = null,
tokensPerSecond = null,
stopped = false,
renderAsMarkdown = false,
isStreaming = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@ data class ChatUiState(
val activeAttachmentId: String? = null,
/** Estimated tokens the chat already takes in the context window; 0 when unknown. */
val contextUsedTokens: Int = 0,
val contextWindowTokens: Int = 0
val contextWindowTokens: Int = 0,
/** How [contextUsedTokens] splits into its parts; parts add up to it. */
val contextBreakdown: ContextBreakdown = ContextBreakdown()
)

data class ContextBreakdown(
val instructionTokens: Int = 0,
val summaryTokens: Int = 0,
val documentTokens: Int = 0,
val conversationTokens: Int = 0
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.net.wifi.WifiManager
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
Expand Down Expand Up @@ -60,6 +61,7 @@ class LanServerService : Service() {
private var preserveFailureState = false
private var stopRequested = false
private var serverWakeLock: PowerManager.WakeLock? = null
private var serverWifiLock: WifiManager.WifiLock? = null
private lateinit var notificationManager: NotificationManager

override fun onCreate() {
Expand Down Expand Up @@ -226,11 +228,26 @@ class LanServerService : Service() {
// awake until Stop, startup failure, or service destruction; never the display.
acquire()
}
if (serverWifiLock?.isHeld != true) {
// Keeps Wi-Fi from dozing while the screen is off, so browsers on the network can still reach the server.
@Suppress("DEPRECATION")
val mode = WifiManager.WIFI_MODE_FULL_HIGH_PERF
serverWifiLock = runCatching {
applicationContext.getSystemService(WifiManager::class.java)
?.createWifiLock(mode, "$packageName:LanServer")
?.apply {
setReferenceCounted(false)
acquire()
}
}.getOrNull()
}
}

private fun releaseServerWakeLock() {
serverWakeLock?.takeIf { it.isHeld }?.release()
serverWakeLock = null
serverWifiLock?.takeIf { it.isHeld }?.release()
serverWifiLock = null
}

private fun startForegroundCompat() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class PersistentChatController(
) {

companion object {
private const val MIN_SPEED_SAMPLE_MILLIS = 300L
// Roughly 10 tokens worth of text before live markdown rendering kicks in.
private const val MARKDOWN_STREAM_CHAR_THRESHOLD = 40
private const val TABLE_MARKDOWN_UPDATE_WORD_STEP = 50
Expand Down Expand Up @@ -83,6 +84,7 @@ class PersistentChatController(
private var thinkingEnabled = false
private var contextUsageKey: List<Any?>? = null
private var contextUsageTokens = 0
private var contextBreakdown = ContextBreakdown()
private var liveMarkdownEnabled = false
private var currentGenerationId: Long = 0L
private var currentSessionId: String? = null
Expand All @@ -93,6 +95,7 @@ class PersistentChatController(
private var lastPublishedMarkdownTextLength: Int = 0
private var lastPublishedMarkdownAtMillis: Long = 0L
private var currentGenerationStartedAtMillis: Long? = null
private var firstOutputAtMillis: Long? = null
private var currentThinkingStartedAtMillis: Long? = null
private var currentThinkingFinishedAtMillis: Long? = null
private var currentGenerationImageFilePaths: List<String> = emptyList()
Expand Down Expand Up @@ -594,6 +597,7 @@ class PersistentChatController(
text = response.text,
thinkingText = finalThinkingText,
thinkingDurationMillis = thinkingDurationMillis(finalThinkingText),
tokensPerSecond = tokensPerSecond(response.text, finalThinkingText),
stopped = false,
renderAsMarkdown = true,
isStreaming = false
Expand Down Expand Up @@ -1032,6 +1036,7 @@ class PersistentChatController(
if (partialTurn != null && (partialTurn.text.isNotBlank() || !partialTurn.thinkingText.isNullOrBlank())) {
committedTurns += partialTurn.copy(
thinkingDurationMillis = thinkingDurationMillis(partialTurn.thinkingText),
tokensPerSecond = tokensPerSecond(partialTurn.text, partialTurn.thinkingText),
stopped = true,
renderAsMarkdown = true,
isStreaming = false
Expand All @@ -1049,12 +1054,16 @@ class PersistentChatController(
private fun startGenerationTimer() {
val now = SystemClock.elapsedRealtime()
currentGenerationStartedAtMillis = now
firstOutputAtMillis = null
currentThinkingStartedAtMillis = null
currentThinkingFinishedAtMillis = null
}

private fun updateThinkingTimer(response: BackendResponse) {
val now = SystemClock.elapsedRealtime()
if (firstOutputAtMillis == null && (response.text.isNotBlank() || !response.thinkingText.isNullOrBlank())) {
firstOutputAtMillis = now
}
if (!response.thinkingText.isNullOrBlank() && currentThinkingStartedAtMillis == null) {
currentThinkingStartedAtMillis = currentGenerationStartedAtMillis ?: now
}
Expand All @@ -1073,7 +1082,19 @@ class PersistentChatController(
return (end - start).coerceAtLeast(0L)
}

/** Decode speed from the first streamed output, using the same estimate as prompt sizing. */
private fun tokensPerSecond(text: String, thinkingText: String?): Float? {
val start = firstOutputAtMillis ?: return null
val elapsedMillis = SystemClock.elapsedRealtime() - start
if (elapsedMillis < MIN_SPEED_SAMPLE_MILLIS) return null
val tokens = PromptTokenEstimator.estimate(text) +
(thinkingText?.let(PromptTokenEstimator::estimate) ?: 0)
if (tokens <= 0) return null
return tokens * 1000f / elapsedMillis
}

private fun resetGenerationTimer() {
firstOutputAtMillis = null
currentGenerationStartedAtMillis = null
currentThinkingStartedAtMillis = null
currentThinkingFinishedAtMillis = null
Expand Down Expand Up @@ -1111,11 +1132,51 @@ class PersistentChatController(
InferenceRequest(history = modelHistory(), thinkingEnabled = thinkingEnabled, modelInstruction = instruction)
)
}.getOrDefault(0)
contextBreakdown = runCatching { contextBreakdown(instruction, contextUsageTokens) }
.getOrDefault(ContextBreakdown(conversationTokens = contextUsageTokens))
contextUsageKey = key
}
return contextUsageTokens
}

/**
* Splits the prompt estimate into the instruction, the summary of earlier messages, document
* passages added to user messages, and the conversation itself. The conversation takes the
* remainder so the parts always add up to [total].
*/
private fun contextBreakdown(instruction: String, total: Int): ContextBreakdown {
if (total <= 0) return ContextBreakdown()
val baseInstruction = currentModelInstruction()
val instructionTokens = if (baseInstruction.isBlank()) 0 else estimateInstructionTokens(baseInstruction)
val withSummaryTokens = if (instruction == baseInstruction) {
instructionTokens
} else {
estimateInstructionTokens(instruction)
}
val fixedTokens = withSummaryTokens.coerceAtMost(total)
val originals = committedTurns.associateBy { it.id }
var documentTokens = 0
modelHistory().forEach { turn ->
val typed = originals[turn.id]?.displayText ?: return@forEach
if (turn.role == ChatRole.USER && typed.length < turn.text.length) {
documentTokens += (estimateTurnTokens(turn) - estimateTurnTokens(turn.copy(text = typed)))
.coerceAtLeast(0)
}
}
documentTokens = documentTokens.coerceAtMost(total - fixedTokens)
return ContextBreakdown(
instructionTokens = instructionTokens.coerceAtMost(fixedTokens),
summaryTokens = (fixedTokens - instructionTokens).coerceAtLeast(0),
documentTokens = documentTokens,
conversationTokens = total - fixedTokens - documentTokens
)
}

private fun estimateInstructionTokens(instruction: String): Int =
backend.estimateSerializedPromptTokens(
InferenceRequest(history = emptyList(), thinkingEnabled = false, modelInstruction = instruction)
)

private fun activeSummary(): String? =
compaction?.takeIf { ChatCompactionPlanner.isActive(committedTurns.asModelMemoryTurns(), it) }?.summary

Expand Down Expand Up @@ -1286,7 +1347,8 @@ class PersistentChatController(
supportsNativeAudioInput = backend.supportsNativeAudioInput,
activeAttachmentId = activeAttachmentId,
contextUsedTokens = if (isReady) contextUsage() else 0,
contextWindowTokens = if (isReady) backend.capabilities.contextWindowTokens else 0
contextWindowTokens = if (isReady) backend.capabilities.contextWindowTokens else 0,
contextBreakdown = if (isReady) contextBreakdown else ContextBreakdown()
)
}

Expand Down
Loading
Loading