feat(speaker): rendre les Speakers GGUF sélectionnables in-app (Qwen3.5-4B, Qwen2.5-7B)

ModelRegistry devient backend-aware (PTE | GGUF) : enum Backend, ggufFile +
ggufPath() sous MODELS_DIR, fileExists() par backend, et un type de template
ChatTemplate (QWEN35_THINKOFF | PLAIN_CHATML). Ajout de deux entrées Speaker
GGUF servies par le moteur lib : Qwen3.5-4B (q35-lmq4, le défaut lib jusqu'ici
codé en dur, désormais visible) et Qwen2.5-7B (Q4_K_M, arch qwen2 dense).

KazeiaService : le path lib route le GGUF du Speaker sélectionné (sp.ggufPath())
au lieu de q35-lmq4 en dur, et passe plainChatml selon le template. Pour les
modèles sans thinking (Qwen2.5), EngineLlmAdapter construit un ChatML standard
sans bloc <think> via generateRaw (le natif l'injecterait sinon).

Affordances de test/maintenance (pilotage adb sans UI) :
- llm_text / llm_max : génération LLM pure loggée (tok/s, chars), hors pipeline TTS.
- cfg_set : persiste engine+model+prompt via am --es (contourne content --bind
  qui casse sur ':'); cfg_sys=DEFAULT résout le prompt thérapeutique en interne.

Provider /models : expose le chemin GGUF pour les entrées GGUF.

Validé device : les deux GGUF chargent, FR cohérent, templates propres.
Bench : Qwen3.5-4B ~13.5 tok/s, Qwen2.5-7B ~6.8 tok/s (CPU-only) ; qualité FR
thérapeutique nettement en faveur du Qwen3.5-4B.

Note : KazeiaService.kt et les 3 fichiers nouveaux portent aussi du travail
antérieur non commité (migration moteur lib + app admin) ; ce commit en capture
l'état courant en plus de la fonctionnalité Speaker GGUF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-07 23:14:06 +02:00
parent 8a3151681d
commit 06b333f0f0
4 changed files with 1643 additions and 239 deletions

View File

@ -0,0 +1,135 @@
package com.kazeia.config
import java.io.File
/**
* Catalogue figé des modèles LLM qu'on sait charger via ExecuTorch QNN HTP.
* Filtrer par `availableOnDisk()` pour ne proposer dans l'UI admin que ceux
* réellement présents sur la tablette.
*
* L'admin app lit cette liste via le ContentProvider URI `/models` ;
* KazeiaService la consulte pour résoudre un `model_id` (pte, tokenizer).
*/
object ModelRegistry {
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
// sur tablette dev, stockage externe app sur tablette de production).
private val ET_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
// Racine GGUF (moteur kazeia-engine "lib"). Les GGUF (q35-lmq4, qwen2.5-7b…)
// vivent sous MODELS_DIR, pas LLM_DIR.
private val GGUF_DIR: String get() = com.kazeia.KazeiaApplication.MODELS_DIR
enum class Role { SPEAKER, THINKER, BOTH }
// PTE = runner ExecuTorch QNN HTP ; GGUF = moteur kazeia-engine "lib" (CPU).
enum class Backend { PTE, GGUF }
// Template ChatML du modèle. QWEN35_THINKOFF injecte le bloc <think></think>
// (thinking-off déterministe Qwen3.5). PLAIN_CHATML = ChatML standard sans
// <think> (Qwen2.5 et autres modèles dense sans raisonnement).
enum class ChatTemplate { QWEN35_THINKOFF, PLAIN_CHATML }
data class ModelInfo(
val id: String,
val displayName: String,
val pteFile: String,
val tokenizerFile: String,
val role: Role,
val sizeMb: Int,
val maxSeqLen: Int,
val notes: String = "",
val backend: Backend = Backend.PTE,
// Nom du fichier GGUF (sous GGUF_DIR) quand backend = GGUF. Le tokenizer
// est embarqué dans le GGUF, pas de fichier séparé.
val ggufFile: String = "",
val chatTemplate: ChatTemplate = ChatTemplate.QWEN35_THINKOFF
) {
fun ptePath() = "$ET_DIR/$pteFile"
fun tokenizerPath() = "$ET_DIR/$tokenizerFile"
fun ggufPath() = "$GGUF_DIR/$ggufFile"
fun fileExists(): Boolean = when (backend) {
Backend.PTE -> File(ptePath()).exists()
Backend.GGUF -> File(ggufPath()).exists()
}
}
val ALL: List<ModelInfo> = listOf(
ModelInfo(
id = "qwen3-4b-seq512",
displayName = "Qwen3-4B (seq=512)",
pteFile = "hybrid_llama_qnn_4b.pte",
tokenizerFile = "tokenizer.json",
role = Role.SPEAKER,
sizeMb = 3000,
maxSeqLen = 512,
notes = "Speaker historique, budget contexte serré"
),
ModelInfo(
id = "qwen3-4b-seq1024",
displayName = "Qwen3-4B (seq=1024) ★",
pteFile = "hybrid_llama_qnn_4b_seq1024.pte",
tokenizerFile = "tokenizer.json",
role = Role.SPEAKER,
sizeMb = 3000,
maxSeqLen = 1024,
notes = "Speaker recommandé — production stable 2026-05-14"
),
ModelInfo(
id = "qwen3.5-4b",
displayName = "Qwen3.5-4B (GGUF) ★",
pteFile = "",
tokenizerFile = "",
role = Role.SPEAKER,
sizeMb = 2380,
maxSeqLen = 4096,
notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.",
backend = Backend.GGUF,
ggufFile = "q35-lmq4.gguf",
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen2.5-7b-instruct",
displayName = "Qwen2.5-7B Instruct (GGUF)",
pteFile = "",
tokenizerFile = "",
role = Role.SPEAKER,
sizeMb = 4683,
maxSeqLen = 4096,
notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.",
backend = Backend.GGUF,
ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
chatTemplate = ChatTemplate.PLAIN_CHATML
),
ModelInfo(
id = "guard06b",
displayName = "Qwen3Guard 0.6B",
pteFile = "hybrid_llama_qnn_guard06b.pte",
tokenizerFile = "tokenizer_guard06b.json",
role = Role.THINKER,
sizeMb = 672,
maxSeqLen = 512,
notes = "Thinker léger, ION ~700 MB, cascade safe"
),
ModelInfo(
id = "guard4b",
displayName = "Qwen3Guard 4B",
pteFile = "hybrid_llama_qnn_guard4b.pte",
tokenizerFile = "tokenizer_guard.json",
role = Role.THINKER,
sizeMb = 3000,
maxSeqLen = 512,
notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM."
)
)
fun availableOnDisk(): List<ModelInfo> = ALL.filter { it.fileExists() }
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
fun defaultSpeaker(): ModelInfo = byId("qwen3-4b-seq1024")
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
fun defaultThinker(): ModelInfo = byId("guard06b")
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
}

View File

@ -0,0 +1,69 @@
package com.kazeia.llm
import com.kazeia.core.GenerationResult
import com.kazeia.core.LlmConfig
import com.kazeia.core.LlmEngine
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Adapter qui expose `com.kazeia.llm.EngineLlmEngine` (façade dist Kazeia-Engine,
* GGUF Qwen3.5/Qwen3 via libkazeia_engine.so + libllama fork ql + Hexagon)
* derrière l'interface prod `com.kazeia.core.LlmEngine`. Sert au swap réversible
* piloté par le flag config `llmEngine` ("prod" = ExecuTorch .pte | "lib" = engine GGUF).
*
* Pré-requis runtime : `libllama.so` fork ql + `libggml*.so` cohérents dans jniLibs.
* Si la chaîne libllama est encore en legacy (f86d), `System.loadLibrary("kazeia_engine")`
* jette UnsatisfiedLinkError au 1er accès à `EngineJni` KazeiaService doit catch
* et tomber en repli sur le path prod.
*
* Modèle par défaut : Qwen3.5-4B `q35-lmq4.gguf` (upgrade vs Qwen3 dense en prod).
*/
class EngineLlmAdapter(
private val modelPath: String,
private val systemPrompt: String,
private val ctxLen: Int = 4096,
private val nThreads: Int = 6,
// true = ChatML standard sans <think> (Qwen2.5…) via generateRaw ;
// false = bloc thinking-off Qwen3.5 injecté côté natif (generate).
private val plainChatml: Boolean = false,
private val onLog: ((String) -> Unit)? = null
) : LlmEngine {
private var engine: EngineLlmEngine? = null
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
if (engine != null) return@withContext
val t0 = System.currentTimeMillis()
engine = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads}")
}
override fun isLoaded(): Boolean = engine != null
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = withContext(Dispatchers.IO) {
val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded")
val t0 = System.currentTimeMillis()
val out = if (plainChatml) {
// ChatML standard, sans bloc <think> (le natif generate l'injecterait).
val p = buildString {
append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n")
append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n")
append("<|im_start|>assistant\n")
}
e.generateRaw(p, params.maxNewTokens).trim()
} else {
e.generate(systemPrompt, prompt, params.maxNewTokens).trim()
}
val ms = System.currentTimeMillis() - t0
onToken?.invoke(out)
val n = out.split(Regex("\\s+")).size
GenerationResult(out, n, ms, if (ms > 0) n * 1000f / ms else 0f)
}
override fun release() { engine?.release(); engine = null }
}

View File

@ -0,0 +1,576 @@
package com.kazeia.telemetry
import android.content.ContentProvider
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Debug
import android.os.Process
import java.io.File
/**
* Expose à l'admin app (com.kazeia.admin) les données runtime du process
* Kazeia patient via une URI standard.
*
* Tables (read-only sauf voices delete) :
* - content://com.kazeia.provider/state — 1 row : état process courant
* - content://com.kazeia.provider/turns — N rows : derniers tours pipeline
* - content://com.kazeia.provider/crashes — N rows : derniers crashes
* - content://com.kazeia.provider/voices — N rows : voix présentes sur disque
* - content://com.kazeia.provider/voices/<id> — delete : supprime la voix
*
* Pas de permission custom en MVP données non sensibles (système + meta voix).
* À sécuriser en production via signature-based permission une fois le keystore
* partagé mis en place (cf project_kazeia_admin_app.md).
*/
class KazeiaTelemetryProvider : ContentProvider() {
companion object {
const val AUTHORITY = "com.kazeia.provider"
const val PATH_STATE = "state"
const val PATH_TURNS = "turns"
const val PATH_CRASHES = "crashes"
const val PATH_VOICES = "voices"
const val PATH_CONFIG = "config"
const val PATH_MODELS = "models"
const val PATH_PROFILES = "profiles"
const val PATH_PROFILE_ACTIVE = "profiles/active"
const val PATH_CONVERSATIONS = "conversations"
const val PATH_DIST_CONFIG = "dist_config"
const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
private const val CODE_STATE = 1
private const val CODE_TURNS = 2
private const val CODE_CRASHES = 3
private const val CODE_VOICES = 4
private const val CODE_VOICE_ITEM = 5
private const val CODE_CONFIG = 6
private const val CODE_MODELS = 7
private const val CODE_PROFILES = 8
private const val CODE_PROFILE_ITEM = 9
private const val CODE_PROFILE_ACTIVE = 10
private const val CODE_CONVERSATIONS = 11
private const val CODE_CONVERSATION_SESSION = 12
private const val CODE_CONVERSATION_PROFILE = 13
private const val CODE_DIST_CONFIG = 14
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
// sur tablette dev, stockage externe app sinon). Non-const.
private val VOICE_WAV_DIR: String
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/../voix/voix"
private val VOICE_EMB_DIR: String
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
private const val PREFIX_SUFFIX = "_voice_prefix.bin"
private const val SUFFIX_SUFFIX = "_voice_suffix.bin"
}
private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
addURI(AUTHORITY, PATH_STATE, CODE_STATE)
addURI(AUTHORITY, PATH_TURNS, CODE_TURNS)
addURI(AUTHORITY, PATH_CRASHES, CODE_CRASHES)
addURI(AUTHORITY, PATH_VOICES, CODE_VOICES)
addURI(AUTHORITY, "$PATH_VOICES/*", CODE_VOICE_ITEM)
addURI(AUTHORITY, PATH_CONFIG, CODE_CONFIG)
addURI(AUTHORITY, PATH_MODELS, CODE_MODELS)
// Profiles : enum + active + per-id (CRUD)
addURI(AUTHORITY, PATH_PROFILES, CODE_PROFILES)
addURI(AUTHORITY, PATH_PROFILE_ACTIVE, CODE_PROFILE_ACTIVE)
addURI(AUTHORITY, "$PATH_PROFILES/*", CODE_PROFILE_ITEM)
// Conversations : timeline + filter by session/profile
addURI(AUTHORITY, PATH_CONVERSATIONS, CODE_CONVERSATIONS)
addURI(AUTHORITY, "$PATH_CONVERSATIONS/session/*", CODE_CONVERSATION_SESSION)
addURI(AUTHORITY, "$PATH_CONVERSATIONS/profile/*", CODE_CONVERSATION_PROFILE)
// Config WebDAV de distribution (éditable depuis l'app admin)
addURI(AUTHORITY, PATH_DIST_CONFIG, CODE_DIST_CONFIG)
}
override fun onCreate(): Boolean = true
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = when (matcher.match(uri)) {
CODE_STATE -> stateCursor()
CODE_TURNS -> turnsCursor()
CODE_CRASHES -> crashesCursor()
CODE_VOICES -> voicesCursor()
CODE_CONFIG -> configCursor()
CODE_MODELS -> modelsCursor()
CODE_PROFILES -> profilesCursor()
CODE_PROFILE_ACTIVE -> activeProfileCursor()
CODE_CONVERSATIONS -> sessionsCursor(profileId = null)
CODE_CONVERSATION_PROFILE -> sessionsCursor(profileId = uri.lastPathSegment)
CODE_CONVERSATION_SESSION -> turnsForSessionCursor(uri.lastPathSegment ?: "")
CODE_DIST_CONFIG -> distConfigCursor()
else -> null
}
private fun profilesCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val store = com.kazeia.profiles.ProfileStore.get(ctx)
val cols = arrayOf(
"id", "display_name", "avatar_color", "voice_id",
"system_prompt_override", "has_pin", "created_at",
"last_used_at", "notes", "is_default", "turn_count"
)
val cursor = MatrixCursor(cols)
val dao = com.kazeia.profiles.ConversationDao(
com.kazeia.profiles.ConversationDb.get(ctx)
)
for (p in store.all()) {
val count = try { dao.countTurns(p.id) } catch (_: Exception) { 0 }
cursor.addRow(arrayOf(
p.id, p.displayName, p.avatarColor, p.voiceId,
p.systemPromptOverride, if (p.hasPin()) 1 else 0,
p.createdAt, p.lastUsedAt, p.notes,
if (p.isDefault) 1 else 0, count
))
}
return cursor
}
private fun activeProfileCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cursor = MatrixCursor(arrayOf("active_profile_id"))
val active = com.kazeia.profiles.ProfileStore.get(ctx).activeProfile()
cursor.addRow(arrayOf(active?.id))
return cursor
}
private fun sessionsCursor(profileId: String?): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cols = arrayOf(
"profile_id", "session_id", "started_at", "ended_at", "turn_count"
)
val cursor = MatrixCursor(cols)
try {
val dao = com.kazeia.profiles.ConversationDao(
com.kazeia.profiles.ConversationDb.get(ctx)
)
for (s in dao.listSessions(profileId)) {
cursor.addRow(arrayOf(s.profileId, s.sessionId,
s.startedAt, s.endedAt, s.turnCount))
}
} catch (_: Exception) { /* DB locked or new : retourne vide */ }
return cursor
}
private fun turnsForSessionCursor(sessionId: String): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cols = arrayOf(
"id", "profile_id", "session_id", "timestamp", "role",
"text", "ttft_ms", "total_ms", "voice_used", "model_used"
)
val cursor = MatrixCursor(cols)
try {
val dao = com.kazeia.profiles.ConversationDao(
com.kazeia.profiles.ConversationDb.get(ctx)
)
for (t in dao.listTurns(sessionId = sessionId)) {
cursor.addRow(arrayOf(
t.id, t.profileId, t.sessionId, t.timestamp, t.role.name,
t.text, t.ttftMs, t.totalMs, t.voiceUsed, t.modelUsed
))
}
} catch (_: Exception) { }
return cursor
}
private fun configCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cfg = com.kazeia.config.ConfigStore.get(ctx).current()
val cols = arrayOf(
"cascade_enabled", "tts_enabled",
"stt_engine", "llm_engine", "tts_engine",
"speaker_model_id", "speaker_system_prompt", "speaker_temperature",
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
)
val cursor = MatrixCursor(cols)
cursor.addRow(arrayOf(
if (cfg.cascadeEnabled) 1 else 0,
if (cfg.ttsEnabled) 1 else 0,
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
))
return cursor
}
private fun modelsCursor(): Cursor {
val cols = arrayOf(
"id", "display_name", "pte_path", "tokenizer_path",
"role", "size_mb", "max_seq_len", "file_exists", "notes"
)
val cursor = MatrixCursor(cols)
for (m in com.kazeia.config.ModelRegistry.ALL) {
val path = if (m.backend == com.kazeia.config.ModelRegistry.Backend.GGUF) m.ggufPath() else m.ptePath()
cursor.addRow(arrayOf(
m.id, m.displayName, path, m.tokenizerPath(),
m.role.name, m.sizeMb, m.maxSeqLen,
if (m.fileExists()) 1 else 0, m.notes
))
}
return cursor
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
if (values == null) return 0
return when (matcher.match(uri)) {
CODE_CONFIG -> updateConfig(uri, values)
CODE_PROFILES -> upsertProfile(values)
CODE_PROFILE_ITEM -> upsertProfile(values.apply {
if (!containsKey("id")) put("id", uri.lastPathSegment)
})
CODE_PROFILE_ACTIVE -> setActiveProfile(values.getAsString("active_profile_id"))
CODE_DIST_CONFIG -> updateDistConfig(uri, values)
else -> 0
}
}
/**
* Config WebDAV : 1 ligne. Le mot de passe n'est jamais renvoyé en clair
* seul `has_password` indique s'il est défini.
*/
private fun distConfigCursor(): Cursor {
val ctx = context ?: return MatrixCursor(arrayOf<String>())
val cfg = com.kazeia.dist.DistConfigStore.get(ctx)
val cursor = MatrixCursor(arrayOf(
"webdav_base", "webdav_user", "has_password", "is_customized"))
cursor.addRow(arrayOf(
cfg.base,
cfg.user,
if (cfg.pass.isNotBlank()) 1 else 0,
if (com.kazeia.dist.DistConfigStore.isCustomized(ctx)) 1 else 0
))
return cursor
}
private fun updateDistConfig(uri: Uri, values: ContentValues): Int {
val ctx = context ?: return 0
// reset=true → retour au défaut d'usine (BuildConfig).
if (values.getAsBoolean("reset") == true) {
com.kazeia.dist.DistConfigStore.reset(ctx)
ctx.contentResolver.notifyChange(uri, null)
return 1
}
val cur = com.kazeia.dist.DistConfigStore.get(ctx)
val base = values.getAsString("webdav_base") ?: cur.base
val user = values.getAsString("webdav_user") ?: cur.user
// Mot de passe : absent ou vide → inchangé ; non-vide → nouveau.
val pass = values.getAsString("webdav_pass")?.takeIf { it.isNotEmpty() }
com.kazeia.dist.DistConfigStore.update(ctx, base, user, pass)
ctx.contentResolver.notifyChange(uri, null)
return 1
}
private fun updateConfig(uri: Uri, values: ContentValues): Int {
val ctx = context ?: return 0
val store = com.kazeia.config.ConfigStore.get(ctx)
val cur = store.current()
val newCfg = com.kazeia.config.ConfigStore.RuntimeConfig(
cascadeEnabled = if (values.containsKey("cascade_enabled"))
values.getAsBoolean("cascade_enabled") else cur.cascadeEnabled,
ttsEnabled = if (values.containsKey("tts_enabled"))
values.getAsBoolean("tts_enabled") else cur.ttsEnabled,
sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine,
llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine,
ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine,
speaker = com.kazeia.config.ConfigStore.ModelConfig(
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
systemPrompt = values.getAsString("speaker_system_prompt")
?: cur.speaker.systemPrompt,
temperature = values.getAsFloat("speaker_temperature")
?: cur.speaker.temperature
),
thinker = com.kazeia.config.ConfigStore.ModelConfig(
modelId = values.getAsString("thinker_model_id") ?: cur.thinker.modelId,
systemPrompt = values.getAsString("thinker_system_prompt")
?: cur.thinker.systemPrompt,
temperature = values.getAsFloat("thinker_temperature")
?: cur.thinker.temperature
)
)
store.save(newCfg)
ctx.contentResolver.notifyChange(uri, null)
ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_CONFIG))
return 1
}
private fun upsertProfile(values: ContentValues): Int {
val ctx = context ?: return 0
val store = com.kazeia.profiles.ProfileStore.get(ctx)
val id = values.getAsString("id") ?: com.kazeia.profiles.ProfileStore.newProfileId()
val existing = store.byId(id)
val displayName = values.getAsString("display_name") ?: existing?.displayName ?: id
val pinRaw = values.getAsString("pin") // PIN en clair pour set/clear
val newPinHash = when {
pinRaw == null -> existing?.pinHash // pas de changement
pinRaw.isEmpty() -> null // efface PIN
else -> com.kazeia.profiles.Profile.hashPin(pinRaw)
}
val p = com.kazeia.profiles.Profile(
id = id,
displayName = displayName,
avatarColor = values.getAsInteger("avatar_color")
?: existing?.avatarColor
?: com.kazeia.profiles.Profile.defaultColorFor(id),
voiceId = values.getAsString("voice_id") ?: existing?.voiceId,
systemPromptOverride = values.getAsString("system_prompt_override")
?: existing?.systemPromptOverride,
pinHash = newPinHash,
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
lastUsedAt = existing?.lastUsedAt ?: System.currentTimeMillis(),
notes = values.getAsString("notes") ?: existing?.notes,
isDefault = values.getAsBoolean("is_default") ?: existing?.isDefault ?: false
)
store.upsert(p)
ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES))
return 1
}
private fun setActiveProfile(profileId: String?): Int {
val ctx = context ?: return 0
com.kazeia.profiles.ProfileStore.get(ctx).setActiveProfile(profileId)
ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES))
return 1
}
private fun voicesCursor(): Cursor {
val cols = arrayOf(
"id", "name", "state",
"wav_exists", "prefix_exists", "suffix_exists",
"wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at"
)
val cursor = MatrixCursor(cols)
// Set des ids = union (wav basenames) (prefix bin basenames)
val wavDir = File(VOICE_WAV_DIR)
val embDir = File(VOICE_EMB_DIR)
val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray())
.associateBy { it.nameWithoutExtension.lowercase() }
val prefixIds = (embDir.listFiles { f -> f.name.endsWith(PREFIX_SUFFIX) } ?: emptyArray())
.associateBy { it.name.removeSuffix(PREFIX_SUFFIX).lowercase() }
val ids = (wavIds.keys + prefixIds.keys).sorted()
for (id in ids) {
val wav = wavIds[id]
val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX")
val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX")
val state = when {
prefix.exists() && suffix.exists() -> "ready"
wav != null -> "recorded"
else -> "error"
}
val durSec = wav?.let { wavDurationSeconds(it) } ?: 0f
cursor.addRow(arrayOf(
id,
id.replaceFirstChar { it.uppercase() },
state,
if (wav != null) 1 else 0,
if (prefix.exists()) 1 else 0,
if (suffix.exists()) 1 else 0,
wav?.length() ?: 0L,
durSec,
wav?.absolutePath ?: "",
wav?.lastModified() ?: prefix.lastModified()
))
}
return cursor
}
/** Décode l'en-tête WAV pour estimer la durée. Très tolérant. */
private fun wavDurationSeconds(f: File): Float = try {
java.io.RandomAccessFile(f, "r").use { raf ->
// Saute le RIFF header (12 bytes), parcourt les chunks
raf.seek(12)
var sampleRate = 0
var channels = 0
var bitsPerSample = 0
var dataSize = 0L
while (raf.filePointer < raf.length() - 8) {
val chunkId = ByteArray(4).also { raf.readFully(it) }
val sizeBytes = ByteArray(4).also { raf.readFully(it) }
val size = ((sizeBytes[0].toInt() and 0xff)
or ((sizeBytes[1].toInt() and 0xff) shl 8)
or ((sizeBytes[2].toInt() and 0xff) shl 16)
or ((sizeBytes[3].toInt() and 0xff) shl 24)).toLong() and 0xffffffffL
val id = String(chunkId)
if (id == "fmt ") {
val pos = raf.filePointer
raf.skipBytes(2) // audio format
channels = raf.readUnsignedByte() or (raf.readUnsignedByte() shl 8)
sampleRate = raf.readUnsignedByte() or
(raf.readUnsignedByte() shl 8) or
(raf.readUnsignedByte() shl 16) or
(raf.readUnsignedByte() shl 24)
raf.seek(pos + size)
} else if (id == "data") {
dataSize = size
break
} else {
raf.skipBytes(size.toInt())
}
if (raf.filePointer % 2L != 0L) raf.skipBytes(1)
}
// bitsPerSample : lu rapidement par estimation sinon 16 par défaut
if (sampleRate <= 0 || channels <= 0) 0f
else {
// bytes per sample = 2 par défaut
val bytesPerSample = 2
val totalSamples = dataSize / (channels * bytesPerSample)
totalSamples.toFloat() / sampleRate
}
}
} catch (_: Exception) { 0f }
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val matched = matcher.match(uri)
android.util.Log.i("KazeiaProvider", "delete uri=$uri matched=$matched lastSeg=${uri.lastPathSegment}")
when (matched) {
CODE_VOICE_ITEM -> return deleteVoiceFiles(uri.lastPathSegment?.lowercase() ?: return 0)
CODE_PROFILE_ITEM -> return deleteProfile(uri.lastPathSegment ?: return 0)
CODE_CONVERSATION_PROFILE -> return deleteConversationsForProfile(
uri.lastPathSegment ?: return 0
)
CODE_CONVERSATION_SESSION -> return deleteConversationSession(
uri.lastPathSegment ?: return 0
)
}
return 0
}
private fun deleteVoiceFiles(id: String): Int {
var removed = 0
listOf(
File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX"),
File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX"),
File(VOICE_WAV_DIR, "${id}.wav")
).forEach { f ->
val exists = f.exists()
val ok = if (exists) f.delete() else false
android.util.Log.i("KazeiaProvider", " ${f.absolutePath} exists=$exists deleted=$ok")
if (exists && ok) removed++
}
context?.contentResolver?.notifyChange(
Uri.parse("content://$AUTHORITY/$PATH_VOICES"), null
)
context?.sendBroadcast(android.content.Intent("com.kazeia.action.RELOAD_VOICES"))
return removed
}
private fun deleteProfile(id: String): Int {
val ctx = context ?: return 0
// Supprime aussi tout l'historique de conversation associé
try {
com.kazeia.profiles.ConversationDao(
com.kazeia.profiles.ConversationDb.get(ctx)
).deleteProfile(id)
} catch (_: Exception) { }
val ok = com.kazeia.profiles.ProfileStore.get(ctx).delete(id)
if (ok) ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES))
return if (ok) 1 else 0
}
private fun deleteConversationsForProfile(id: String): Int {
val ctx = context ?: return 0
return try {
com.kazeia.profiles.ConversationDao(
com.kazeia.profiles.ConversationDb.get(ctx)
).deleteProfile(id)
} catch (_: Exception) { 0 }
}
private fun deleteConversationSession(id: String): Int {
val ctx = context ?: return 0
return try {
com.kazeia.profiles.ConversationDao(
com.kazeia.profiles.ConversationDb.get(ctx)
).deleteSession(id)
} catch (_: Exception) { 0 }
}
private fun stateCursor(): Cursor {
val cols = arrayOf(
"pid", "uptime_seconds", "pss_mb", "ion_mb",
"anon_pages_mb", "mem_available_mb"
)
val cursor = MatrixCursor(cols)
val info = Debug.MemoryInfo()
Debug.getMemoryInfo(info)
val pssMb = info.totalPss / 1024
val ionMb = readMeminfoKb("IonTotalUsed") / 1024
val anonMb = readMeminfoKb("AnonPages") / 1024
val availMb = readMeminfoKb("MemAvailable") / 1024
val uptimeSec = (System.currentTimeMillis() - TelemetryHolder.processStartedAt) / 1000
cursor.addRow(arrayOf(
Process.myPid(),
uptimeSec,
pssMb,
ionMb,
anonMb,
availMb
))
return cursor
}
private fun turnsCursor(): Cursor {
val cols = arrayOf(
"timestamp", "input_kind", "status", "total_ms",
"stt_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
"time_to_first_audio_ms", "audio_playback_ms"
)
val cursor = MatrixCursor(cols)
for (t in TelemetryHolder.recentTurns()) {
cursor.addRow(arrayOf(
t.timestamp, t.inputKind, t.status, t.totalMs,
t.sttDurationMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
t.timeToFirstAudioMs, t.audioPlaybackMs
))
}
return cursor
}
private fun crashesCursor(): Cursor {
val cols = arrayOf("timestamp", "component", "message")
val cursor = MatrixCursor(cols)
for (c in TelemetryHolder.recentCrashes()) {
cursor.addRow(arrayOf(c.timestamp, c.component, c.message))
}
return cursor
}
private fun readMeminfoKb(key: String): Long = try {
File("/proc/meminfo").bufferedReader().useLines { lines ->
lines.firstOrNull { it.startsWith("$key:") }
?.split(Regex("\\s+"))?.get(1)?.toLong() ?: 0L
}
} catch (_: Exception) { 0L }
override fun getType(uri: Uri): String? = when (matcher.match(uri)) {
CODE_STATE -> "vnd.android.cursor.item/vnd.com.kazeia.state"
CODE_TURNS -> "vnd.android.cursor.dir/vnd.com.kazeia.turns"
CODE_CRASHES -> "vnd.android.cursor.dir/vnd.com.kazeia.crashes"
CODE_VOICES -> "vnd.android.cursor.dir/vnd.com.kazeia.voices"
CODE_VOICE_ITEM -> "vnd.android.cursor.item/vnd.com.kazeia.voice"
CODE_CONFIG -> "vnd.android.cursor.item/vnd.com.kazeia.config"
CODE_MODELS -> "vnd.android.cursor.dir/vnd.com.kazeia.models"
else -> null
}
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
}