Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
5ab7ad16df
commit
a9c3cd0691
9 files changed
+244
-3
No files matched your search
@@ -0,0 +1,94 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.Serializer
|
||||
import androidx.datastore.dataStore
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Disk-backed per-command usage counts for the slash-autocomplete ranking
|
||||
* (issue #865).
|
||||
*
|
||||
* The backend's `commands.catalog` carries no usage data — the usage-ranked
|
||||
* ordering exists only in the desktop/TUI completion path. Mobile keeps its
|
||||
* own local counts (keyed by command name, lowercase, WITH leading slash,
|
||||
* e.g. "/model") and ranks the suggestion menu by them.
|
||||
*/
|
||||
@Serializable
|
||||
private data class SlashUsageState(
|
||||
// Command name (lowercase, WITH leading slash, e.g. "/model") → count.
|
||||
val counts: Map<String, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
private object SlashUsageSerializer : Serializer<SlashUsageState> {
|
||||
override val defaultValue: SlashUsageState = SlashUsageState()
|
||||
|
||||
override suspend fun readFrom(input: InputStream): SlashUsageState =
|
||||
try {
|
||||
// Use the app's wire Json (ignoreUnknownKeys=true) so a future
|
||||
// schema addition can't break the on-disk decode.
|
||||
OkHttpProvider.json.decodeFromString(
|
||||
SlashUsageState.serializer(),
|
||||
input.readBytes().decodeToString(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
defaultValue
|
||||
}
|
||||
|
||||
override suspend fun writeTo(
|
||||
t: SlashUsageState,
|
||||
output: OutputStream,
|
||||
) {
|
||||
output.write(
|
||||
OkHttpProvider.json
|
||||
.encodeToString(SlashUsageState.serializer(), t)
|
||||
.toByteArray(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val Context.slashUsageDataStore: DataStore<SlashUsageState> by dataStore(
|
||||
fileName = "slash_usage.json",
|
||||
serializer = SlashUsageSerializer,
|
||||
)
|
||||
|
||||
/**
|
||||
* Stores how often each slash command was dispatched so the autocomplete can
|
||||
* surface most-used commands first. Best-effort: read/write failures degrade
|
||||
* to the empty map / a dropped write — never a crash.
|
||||
*/
|
||||
open class SlashUsageStore(
|
||||
private val context: Context,
|
||||
) {
|
||||
/** Current usage counts; empty map when nothing is recorded yet. */
|
||||
open fun counts(): Flow<Map<String, Int>> =
|
||||
context.slashUsageDataStore.data
|
||||
.catch { emit(SlashUsageState()) }
|
||||
.map { it.counts }
|
||||
|
||||
/** Bump the count for [command] (e.g. "/model"). Never throws. */
|
||||
open suspend fun recordUse(command: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.slashUsageDataStore.updateData { state ->
|
||||
state.copy(
|
||||
counts =
|
||||
state.counts +
|
||||
(command to ((state.counts[command] ?: 0) + 1)),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Best-effort: a failed write must never block the dispatch.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,4 +45,15 @@ object ChatInputPolicy {
|
||||
* selection.
|
||||
*/
|
||||
fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length))
|
||||
|
||||
/**
|
||||
* Rank slash-command suggestions by how often the user has dispatched them
|
||||
* (issue #865): most-used first, ties broken by the current (catalog)
|
||||
* order via the stable sort. Commands with no recorded usage keep their
|
||||
* catalog position, so a fresh install behaves exactly as before.
|
||||
*/
|
||||
fun sortSlashSuggestions(
|
||||
commands: List<String>,
|
||||
usageCounts: Map<String, Int>,
|
||||
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
|
||||
}
|
||||
@@ -681,6 +681,7 @@ fun ChatScreen(
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isConnected = state.isConnected,
|
||||
commandCatalog = state.commandCatalog,
|
||||
slashUsageCounts = state.slashUsageCounts,
|
||||
pendingAttachments = state.pendingAttachments,
|
||||
onCameraTap = {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.local.SlashUsageStore
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
@@ -274,6 +275,10 @@ data class ChatUiState(
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
// Commands catalog
|
||||
val commandCatalog: CommandCatalog = CommandCatalog(),
|
||||
// Per-command usage counts for the slash-autocomplete ranking (issue
|
||||
// #865). Empty until the local store loads; commands without recorded
|
||||
// usage keep their catalog order.
|
||||
val slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||
// In-session model picker (issue #589) — surfaced when the user types /model
|
||||
// (or taps the top-bar model chip). Mirror of the global model screen's
|
||||
// picker, but the selection hot-swaps the CURRENT session via the slash path.
|
||||
@@ -378,6 +383,7 @@ class ChatViewModel(
|
||||
ChatPersistenceRepository(
|
||||
HermesDatabase.get(application).chatMessageDao(),
|
||||
),
|
||||
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
|
||||
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
|
||||
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
|
||||
) : AndroidViewModel(application) {
|
||||
@@ -456,6 +462,7 @@ class ChatViewModel(
|
||||
|
||||
// ── Session persistence ──────────────────────────────────────────────
|
||||
private val repo: ChatPersistenceRepository = repo
|
||||
private val slashUsageStore: SlashUsageStore = slashUsageStore
|
||||
private val slashDispatcher = SlashCommandDispatcher()
|
||||
|
||||
/**
|
||||
@@ -565,6 +572,14 @@ class ChatViewModel(
|
||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||
}
|
||||
}
|
||||
// Slash-command usage ranking (issue #865): mirror the local usage
|
||||
// counts into state so the autocomplete can surface most-used
|
||||
// commands first. Best-effort — the store never throws.
|
||||
viewModelScope.launch {
|
||||
slashUsageStore.counts().collect { counts ->
|
||||
_uiState.update { it.copy(slashUsageCounts = counts) }
|
||||
}
|
||||
}
|
||||
if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) {
|
||||
handleGatewayReady()
|
||||
}
|
||||
@@ -1390,6 +1405,15 @@ class ChatViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
// Track per-command usage for the slash-autocomplete ranking (issue
|
||||
// #865). Counted here, AFTER the blocklist guard, so commands that
|
||||
// can never dispatch don't climb the ranking. Best-effort — a store
|
||||
// failure must never block the dispatch itself.
|
||||
val commandName = command.split(" ", limit = 2)[0].lowercase()
|
||||
viewModelScope.launch {
|
||||
slashUsageStore.recordUse(commandName)
|
||||
}
|
||||
|
||||
when (val result = slashDispatcher.dispatch(command)) {
|
||||
is SlashResult.Interrupt -> {
|
||||
interruptSession()
|
||||
|
||||
@@ -78,6 +78,7 @@ fun ChatInputBar(
|
||||
isAgentTyping: Boolean,
|
||||
isConnected: Boolean,
|
||||
commandCatalog: CommandCatalog,
|
||||
slashUsageCounts: Map<String, Int> = emptyMap(),
|
||||
pendingAttachments: List<Attachment> = emptyList(),
|
||||
onCameraTap: () -> Unit = {},
|
||||
onImageTap: () -> Unit = {},
|
||||
@@ -136,7 +137,10 @@ fun ChatInputBar(
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier =
|
||||
|
||||
Reference in new issue
Block a user