Skip to content
Merged
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
4 changes: 2 additions & 2 deletions android-llmservice/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@ android-llmservice/
├── main/res/values/{strings,themes}.xml + values-*/strings.xml (13 languages)
├── main/res/xml/locales_config.xml
├── main/kotlin/net/ladenthin/android/llmservice/
│ ├── MainActivity.kt # Compose UI + SAF picker + flag language menu + save/load
│ ├── ChatViewModel.kt # model load + streaming chat + session save/load (the logic)
│ ├── MainActivity.kt # Compose UI + SAF picker + flag menu + save/load + settings + log viewer
│ ├── ChatViewModel.kt # model load + streaming chat + sampling settings + in-app log + session (the logic)
│ ├── Languages.kt # the flag/language list
│ └── SessionStore.kt # private local JSON persistence (filesDir)
└── androidTest/kotlin/net/ladenthin/android/llmservice/
Expand Down
9 changes: 6 additions & 3 deletions android-llmservice/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ Everything else in the brief is feasible with standard AndroidX + the existing b
| Private local save / load session | ✅ | JSON in `filesDir`, app-private, nothing uploaded |
| `allowBackup=false` (data stays on device) | ✅ | resolves CodeQL alert; privacy posture |
| minSdk 28, arm64-v8a + x86_64, signed release `.aab`, on-device emulator UI test | ✅ | |
| Generation settings sheet (temperature, Top-K/P, Min-P, **repeat penalty**, repeat range, max tokens) | ✅ | ⚙️ in the top bar; live sliders + reset. The repeat-penalty default (1.1) fixes the degenerate repetition loop on small models |
| In-app log: always-visible one-line strip + full viewer (copy-all, save-as-txt via SAF, clear) | ✅ | 🧾 strip at the bottom; tap to open, ✕ to close |
| Draggable (horizontally scrollable) model-name title | ✅ | long names can be dragged into view — no auto-marquee wobble |

---

Expand All @@ -56,7 +59,7 @@ Everything else in the brief is feasible with standard AndroidX + the existing b
| Material 3 theme + brand palette (indigo `#476EAC`, lavender, charcoal, cream) | Polished, on-brand look | S–M | 🔧 | currently default `MaterialTheme`; add color scheme + shapes/elevation |
| Light + dark ("technical dark" / "soft light") | Brief's hybrid direction | S | 🔧 | Compose supports; wire dynamic + manual toggle |
| Smooth transitions / motion polish | "more polished than a dev demo" | S | ⬜ | Compose animations, shared-element nav |
| Optional 5th destination: Tools / Logs | Advanced users | S | | keep out of bottom bar to avoid crowding (overflow/menu) |
| Optional 5th destination: Tools / Logs | Advanced users | S | 🔧 | **log** shipped as a bottom strip + full viewer (copy/save-txt/clear); a dedicated Tools destination is still todo |

## 2. Onboarding & device readiness

Expand All @@ -76,7 +79,7 @@ Everything else in the brief is feasible with standard AndroidX + the existing b
| Feature | Purpose | Effort | Status | Notes |
|---|---|---|---|---|
| Streaming responses + bubbles | Core chat | S | ✅ | polish to pastel user bubbles / larger AI cards |
| Top bar: model chip · "Local" · RAM indicator · offline badge · switch | Context + control | M | 🔧 | model name shown; RAM/switch/offline todo |
| Top bar: model chip · "Local" · RAM indicator · offline badge · switch | Context + control | M | 🔧 | model name shown (now horizontally draggable so long names are fully readable); RAM/switch/offline todo |
| Markdown rendering (code, lists, tables, headings) | Readable answers | M | ⬜ | add a Compose Markdown renderer (e.g. compose-markdown) |
| Copy / regenerate / stop / clear-chat actions | Standard chat UX | M | 🔧 | **stop** = `CancellationToken` (façade wires it; `completeSuspend`/flow cancel) |
| Prompt shortcut chips (Summarize / Explain code / Translate / …) | Fast starts | S | ⬜ | localized prompt templates |
Expand Down Expand Up @@ -131,7 +134,7 @@ See decision #1. All rows below assume a new in-app Ktor/NanoHTTPD HTTP layer wr
|---|---|---|---|---|
| CPU threads | Tune speed | S | ⬜ | `ModelParameters.setThreads` / `setThreadsBatch` |
| Context length | Memory vs. history | S | ⬜ | `setCtxSize` |
| Temperature / Max tokens | Sampling control | S | | `withTemperature` / `withNPredict` (+ TopK/TopP/MinP available) |
| Temperature / Max tokens / Top-K / Top-P / Min-P / **repeat penalty** + range | Sampling control | S | | shipped in the ⚙️ Generation settings sheet (live sliders + reset); `withTemperature`/`withNPredict`/`withTopK`/`withTopP`/`withMinP`/`withRepeatPenalty`/`withRepeatLastN`. The repeat-penalty default is what stops the small-model repetition loop |
| GPU acceleration toggle | Speed on Adreno | M | 🔧 | `setGpuLayers`; **Adreno/OpenCL AAR only** (see §7) |
| Battery saver mode | Protect battery | M | ⬜ | throttle threads / ngl on low battery |
| Thermal protection | Prevent overheating | M | ⬜ | `PowerManager.getCurrentThermalStatus()` → pause/throttle |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import java.io.File
import java.time.LocalTime
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -50,6 +52,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** A localized-at-the-UI error: the [type] selects the template, [detail] fills it in. */
data class ErrorInfo(val type: ErrorType, val detail: String)

/**
* Sampling / generation knobs surfaced in the Settings sheet. The defaults are chosen to give
* a coherent reply on small on-device models: a [repeatPenalty] > 1 with a non-zero
* [repeatLastN] is what stops the degenerate "…spezifischen spezifischen…" repetition loop that
* a bare greedy/no-penalty decode falls into (the reported SmolVLM-500M behaviour). All are
* forwarded verbatim to [InferenceParameters]; see [GenerationSettings.DEFAULT].
*/
data class GenerationSettings(
val temperature: Float = 0.7f,
val topK: Int = 40,
val topP: Float = 0.95f,
val minP: Float = 0.05f,
val repeatPenalty: Float = 1.1f,
val repeatLastN: Int = 64,
val maxTokens: Int = 256,
) {
companion object {
/** The shipped defaults (also the "Reset" target). */
val DEFAULT = GenerationSettings()
}
}

/** Immutable snapshot the Compose UI renders. */
data class UiState(
val modelState: ModelState = ModelState.NONE,
Expand All @@ -58,11 +82,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val generating: Boolean = false,
val error: ErrorInfo? = null,
val notice: Notice? = null,
val settings: GenerationSettings = GenerationSettings.DEFAULT,
)

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

/**
* Rolling in-app log (newest last), shown as a one-line strip at the bottom and in full in the
* log viewer. Kept separate from [uiState] so per-token chat updates don't re-emit the whole
* log list. Capped at [MAX_LOG_LINES]; entries are prefixed with a local wall-clock time.
*/
private val _log = MutableStateFlow<List<String>>(emptyList())
val log: StateFlow<List<String>> = _log.asStateFlow()

private var model: LlamaModel? = null
private var modelPath: String? = null
private var generation: Job? = null
Expand All @@ -75,8 +108,30 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private var chatTemplate: String? = null

private companion object {
const val MAX_TOKENS = 256
const val CONTEXT_SIZE = 2048
const val MAX_LOG_LINES = 500
}

/** Appends one timestamped line to the rolling [log] (trimmed to [MAX_LOG_LINES]). */
private fun log(line: String) {
val ts = LocalTime.now().truncatedTo(ChronoUnit.SECONDS)
_log.update { (it + "$ts $line").takeLast(MAX_LOG_LINES) }
}

/** Replaces the sampling settings (from the Settings sheet). */
fun updateSettings(settings: GenerationSettings) {
_uiState.update { it.copy(settings = settings) }
}

/** Restores the shipped default sampling settings. */
fun resetSettings() {
_uiState.update { it.copy(settings = GenerationSettings.DEFAULT) }
log("Settings reset to defaults")
}

/** Clears the in-app log buffer. */
fun clearLog() {
_log.value = emptyList()
}

/**
Expand Down Expand Up @@ -109,6 +164,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}

private suspend fun openModel(path: String, displayName: String, template: String?) {
log("Loading model: $displayName")
try {
val loaded = withContext(Dispatchers.IO) {
LlamaModel(
Expand All @@ -125,7 +181,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update {
it.copy(modelState = ModelState.READY, modelName = displayName, error = null)
}
log("Model ready: $displayName (ctx=$CONTEXT_SIZE, CPU)")
} catch (t: Throwable) {
log("Load failed: ${t.message ?: "load failed"}")
_uiState.update {
it.copy(modelState = ModelState.NONE, error = ErrorInfo(ErrorType.LOAD, t.message ?: "load failed"))
}
Expand All @@ -149,13 +207,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Append an empty assistant message that grows as tokens arrive.
_uiState.update { it.copy(messages = history + Message("assistant", ""), generating = true, error = null) }

val s = _uiState.value.settings
generation = viewModelScope.launch {
val pairs = history.map { Pair(it.role, it.text) }
// Apply the sampling knobs. repeatPenalty (> 1) + repeatLastN are the ones that break
// the degenerate repetition loop small on-device models fall into with a bare decode.
var params = InferenceParameters("")
.withMessages(systemPrompt, pairs)
.withNPredict(MAX_TOKENS)
.withNPredict(s.maxTokens)
.withTemperature(s.temperature)
.withTopK(s.topK)
.withTopP(s.topP)
.withMinP(s.minP)
.withRepeatPenalty(s.repeatPenalty)
.withRepeatLastN(s.repeatLastN)
chatTemplate?.let { params = params.withChatTemplate(it) }

log("Generating (temp=${s.temperature}, repeat=${s.repeatPenalty}/${s.repeatLastN}, maxTokens=${s.maxTokens})")
val reply = StringBuilder()
try {
active.generateChatFlow(params)
Expand All @@ -164,7 +232,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
reply.append(output.text)
_uiState.update { state -> state.replaceLastAssistant(reply.toString()) }
}
log("Reply complete (${reply.length} chars)")
} catch (t: Throwable) {
log("Generation failed: ${t.message ?: "generation failed"}")
_uiState.update { state ->
state.replaceLastAssistant(reply.toString())
.copy(error = ErrorInfo(ErrorType.GENERATION, t.message ?: "generation failed"))
Expand All @@ -185,6 +255,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
withContext(Dispatchers.IO) {
SessionStore.save(getApplication(), pathAtSave, nameAtSave, messagesAtSave)
}
log("Session saved (${messagesAtSave.size} messages)")
_uiState.update { it.copy(notice = Notice.SESSION_SAVED) }
}
}
Expand All @@ -194,9 +265,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch {
val saved = withContext(Dispatchers.IO) { SessionStore.load(getApplication()) }
if (saved == null) {
log("Load session: no saved chat")
_uiState.update { it.copy(notice = Notice.NO_SESSION) }
return@launch
}
log("Session loaded (${saved.messages.size} messages)")
_uiState.update { it.copy(messages = saved.messages, notice = Notice.SESSION_LOADED) }
val path = saved.modelPath
if (path != null && File(path).canRead() && modelPath != path) {
Expand Down
Loading
Loading