refonte(llm): moteur LLM unifié GGUF + .pte NPU (UnifiedLlmAdapter)

Remplace la couche LLM par la dernière version de kazeia-engine
(cf dist/LLM_INTEGRATION.md). Chargement par magic-byte :
- GGUF ("GGUF"@0)  -> EngineLlmEngine (libllama CPU i8mm, session cache-préfixe KV)
- .pte  ("ET12"@4) -> PteLlmEngine (ExecuTorch+QNN, NPU V79, sans root)

- LlmLoader.kt copié de l'engine + fix encode DJL (encode(p,false,false))
- UnifiedLlmAdapter : pont com.kazeia.core.LlmEngine, generateWithSystem par tour
- ModelRegistry : 5 modèles (Qwen3.5-4B GGUF défaut + 4 .pte en sous-dossiers)
- KazeiaService recâblé ; DEFAULT_SYSTEM_PROMPT -> ConfigStore.DEFAULT_SPEAKER_PROMPT
- DJL tokenizer arm64 Android : tokenizers + tokenizer-native alignés 0.33.0
  (natif libdjl_tokenizer.so dans l'AAR ; natifs desktop exclus, -23 Mo APK)
- jniLibs : +libkazeia_pte.so, +libQnnHtpNetRunExtensions.so,
  libqnn_executorch_backend.so -> QAIRT 2.42 ; manifest re-figé (28 libs)
- fichiers morts archivés (ExecuTorchLlmEngine, GenieLlmEngine)

Phase 1 (#287) : build debug green. Phase 2 = validation device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia Team 2026-06-17 22:47:32 +02:00
parent 3b58fcb467
commit 0a72654093
8 changed files with 345 additions and 514 deletions

View File

@ -106,6 +106,17 @@ android {
jniLibs.srcDirs("src/main/jniLibs")
}
}
packaging {
resources {
// DJL `tokenizers` embarque des natifs desktop (macOS/Windows) inutiles
// sur Android (~23 Mo) ; seul l'arm64 de `tokenizer-native` sert. On les
// exclut pour alléger l'APK (payload self-update OTA).
excludes += "native/lib/osx-**"
excludes += "native/lib/win-**"
excludes += "native/lib/linux-**"
}
}
}
// Reproductibilité des jniLibs : les .so (gitignorés, 5 chaînes de build) doivent
@ -151,6 +162,14 @@ dependencies {
implementation("com.facebook.soloader:nativeloader:0.10.5")
implementation(files("libs/executorch.jar"))
// Tokeniseur HF (DJL) — pour PteLlmEngine (.pte NPU) : tokenise le ChatML Qwen
// côté Kotlin puis passe les IDs au natif (tokeniseur natif de l'engine cassé pour Qwen).
// L'artefact `tokenizers` ne porte QUE les natifs desktop (osx/win) ; le natif Android
// arm64 (libdjl_tokenizer.so) vit dans l'AAR `ai.djl.android:tokenizer-native`, dont la
// seule version publiée est 0.33.0 → on aligne les DEUX sur 0.33.0 (DJL exige l'égalité).
implementation("ai.djl.huggingface:tokenizers:0.33.0")
implementation("ai.djl.android:tokenizer-native:0.33.0")
// OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// WorkManager — orchestration du téléchargement modèles (foreground, reprise)

View File

@ -54,27 +54,11 @@ object ModelRegistry {
}
}
// Catalogue refonte 2026-06-17 (cf dist/LLM_INTEGRATION.md §2). Chargement unifié
// via UnifiedLlmAdapter (magic-byte) : GGUF→CPU i8mm, .pte→NPU V79 (libkazeia_pte).
// .pte en SOUS-DOSSIER <id>/hybrid_llama_qnn.pte (+ tokenizer.json) : le nom du
// dossier porte l'auto-détection decoder_model_version (qwen3/qwen2_5) côté natif.
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) ★",
@ -83,43 +67,58 @@ object ModelRegistry {
role = Role.SPEAKER,
sizeMb = 2380,
maxSeqLen = 4096,
notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.",
notes = "Speaker PAR DÉFAUT — hybride GatedDeltaNet (non exportable .pte), GGUF CPU i8mm. Thinking-off.",
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 = "",
id = "qwen3-4b",
displayName = "Qwen3-4B (.pte NPU)",
pteFile = "qwen3_4b_seq1024/hybrid_llama_qnn.pte",
tokenizerFile = "qwen3_4b_seq1024/tokenizer.json",
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",
sizeMb = 3100,
maxSeqLen = 1024,
notes = "Dense Qwen3-4B sur NPU. Prefill 313 tok/s, RAM ÷2, sans root. Decode 15.7.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen3-8b",
displayName = "Qwen3-8B (.pte NPU)",
pteFile = "qwen3_8b_seq512/hybrid_llama_qnn.pte",
tokenizerFile = "qwen3_8b_seq512/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 5500,
maxSeqLen = 512,
notes = "Dense Qwen3-8B sur NPU. Decode 10.5, prefill 219, RAM ~3.1 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
),
ModelInfo(
id = "qwen2.5-7b",
displayName = "Qwen2.5-7B (.pte NPU)",
pteFile = "qwen2_5_7b_seq512/hybrid_llama_qnn.pte",
tokenizerFile = "qwen2_5_7b_seq512/tokenizer.json",
role = Role.SPEAKER,
sizeMb = 5000,
maxSeqLen = 512,
notes = "Dense Qwen2.5-7B sur NPU (decoder qwen2_5). Decode 8.0 (embeddings non-tied), RAM ~5.1 G.",
backend = Backend.PTE,
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",
displayName = "Qwen3Guard 4B (.pte NPU)",
pteFile = "guard4b/hybrid_llama_qnn.pte",
tokenizerFile = "guard4b/tokenizer.json",
role = Role.THINKER,
sizeMb = 3000,
sizeMb = 3100,
maxSeqLen = 512,
notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM."
notes = "Thinker cascade sur NPU. Decode 17.2, prefill 166. Guard-4B + Speaker tiennent en 16 G.",
backend = Backend.PTE,
chatTemplate = ChatTemplate.QWEN35_THINKOFF
)
)
@ -127,11 +126,12 @@ object ModelRegistry {
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
// Défaut = Speaker GGUF (moteur kazeia-engine lib) — on n'utilise plus de .pte
// (directive 2026-06-15). Repli : tout autre Speaker présent sur disque.
// Défaut Speaker = Qwen3.5-4B GGUF (hybride, décision refonte 2026-06-17).
// Repli : tout autre Speaker présent sur disque.
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
fun defaultThinker(): ModelInfo = byId("guard06b")
// Défaut Thinker (cascade) = Guard-4B .pte NPU.
fun defaultThinker(): ModelInfo = byId("guard4b")
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
}

View File

@ -1,326 +0,0 @@
package com.kazeia.llm
import android.content.Context
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import org.pytorch.executorch.extension.llm.LlmCallback
import org.pytorch.executorch.extension.llm.LlmModule
/**
* LLM Engine using ExecuTorch LlmModule in-process **no root required**.
*
* Runs Qwen3-4B via `org.pytorch.executorch.extension.llm.LlmModule`, which
* wraps the same C++ TextLlmRunner as the standalone qnn_llama_runner binary
* but inside the app's own process. The QNN HTP backend works because the
* DSP fastrpc service accepts the Zygote-forked app process (unlike
* ProcessBuilder-spawned subprocesses which lose supplementary GIDs on exec
* and get rejected by the fastrpc credential checks).
*
* Model + tokenizer live in /data/local/tmp/kazeia-et/ (readable by the app
* on this device's permissive SELinux policy). libexecutorch.so + QNN libs
* are bundled in jniLibs.
*
* Current tablet config: Qwen3-4B KV-mode, ~18-22 tok/s on Hexagon V79
* (Snapdragon 8 Elite), TTFT 0.9 s, RSS 1.76 GB.
*/
/**
* Cascade Option E (in-process multi-LlmModule) :
* Le même process app peut instancier 2 ExecuTorchLlmEngine
* (Speaker + Thinker) qui partagent automatiquement le `QnnBackendBundle`
* via le singleton `QnnBackendUnifiedRegistry` (cf
* executorch/backends/qualcomm/runtime/backends/QnnBackendUnifiedRegistry.cpp).
* Aucune contention DSP chaque instance crée son propre `QnnContext` mais
* réutilise le même handle fastrpc.
*/
class ExecuTorchLlmEngine(
private val context: Context,
private val onLog: ((String) -> Unit)? = null,
private val modelPath: String = DEFAULT_MODEL_PATH,
private val tokenizerPath: String = DEFAULT_TOKENIZER_PATH,
private val systemPrompt: String = DEFAULT_SYSTEM_PROMPT,
private val temperature: Float = 0.7f,
private val displayName: String = "Qwen3-4B Speaker"
) : LlmEngine {
companion object {
private const val TAG = "ExecuTorchLLM"
// /no_think disables Qwen3's chain-of-thought block.
// UNE OU DEUX phrases courtes (5-8 mots) — l'utilisateur veut des réponses
// brèves. Validation émotionnelle, pas de remplissage. La 1ʳᵉ phrase est
// synthétisée pendant que la 2ᵉ est générée → audio démarre tôt.
const val DEFAULT_SYSTEM_PROMPT = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
// sur tablette dev, stockage externe app sinon). Fichier régulier, pas
// de symlink : SELinux ColorOS sans Magisk bloque l'accès app aux
// symlinks dans /data/local/tmp/. cf feedback_selinux_symlink.
// Test 2026-05-13 : Speaker seq=1024 (re-export 2026-05-04).
val MODEL_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
val DEFAULT_MODEL_PATH: String get() = "$MODEL_DIR/hybrid_llama_qnn_4b_seq1024.pte"
val DEFAULT_TOKENIZER_PATH: String get() = "$MODEL_DIR/tokenizer.json"
// seqLen = budget TOTAL (prefill + decode). Doit correspondre au
// max_seq_len avec lequel le .pte a été compilé.
const val MODEL_SEQ_LEN = 1024
}
private var llmModule: LlmModule? = null
private var modelName = ""
private var loaded = false
private fun nlog(msg: String) {
Log.i(TAG, msg)
onLog?.invoke("[LLM] $msg")
}
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
if (!File(this@ExecuTorchLlmEngine.modelPath).exists()) {
nlog("ERROR: model not found at ${this@ExecuTorchLlmEngine.modelPath}")
return@withContext
}
if (!File(tokenizerPath).exists()) {
nlog("ERROR: tokenizer not found at $tokenizerPath")
return@withContext
}
try {
val t0 = System.currentTimeMillis()
// MODEL_TYPE_QNN_LLAMA=4 selects the Qualcomm runner path in
// jni_layer_llama.cpp, which uses example::Runner (same code
// as the qnn_llama_runner binary) instead of the generic
// TextLLMRunner. Our .pte was exported with
// --decoder_model qwen3-4b which requires this path.
val MODEL_TYPE_QNN_LLAMA = 4
llmModule = LlmModule(MODEL_TYPE_QNN_LLAMA, this@ExecuTorchLlmEngine.modelPath, tokenizerPath, temperature)
nlog("[$displayName] LlmModule instantiated in ${System.currentTimeMillis() - t0}ms")
// Load the PTE into QNN HTP (calls the native load()).
val loadResult = llmModule!!.load()
if (loadResult != 0) {
nlog("ERROR: LlmModule.load() returned $loadResult")
llmModule = null
return@withContext
}
nlog("[$displayName] LlmModule loaded in ${System.currentTimeMillis() - t0}ms total")
loaded = true
modelName = displayName
nlog("[$displayName] Ready")
} catch (e: Throwable) {
nlog("ERROR: LlmModule init failed: ${e.javaClass.simpleName}: ${e.message}")
llmModule = null
}
}
}
override fun isLoaded(): Boolean = loaded && llmModule != null
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = generateWithSystem(prompt, null, params, onToken, tag = "SPEAKER")
/**
* Cascade Option B : permet de passer un system prompt différent par appel
* (par ex. SYS_THINKER pour la passe d'analyse, puis le system prompt par
* défaut pour la passe Speaker). `null` = utilise le `systemPrompt`
* d'instance. Le `tag` préfixe les logs pour distinguer les passes
* cascadées dans logcat.
*/
suspend fun generateWithSystem(
prompt: String,
systemPromptOverride: String?,
params: SamplingParams = SamplingParams(),
onToken: ((String) -> Boolean)? = null,
tag: String = "LLM"
): GenerationResult = withContext(Dispatchers.IO) {
val mod = llmModule ?: throw IllegalStateException("Model not loaded")
// Logger local taggué pour cette passe (THINKER, SPEAKER, …).
val nl: (String) -> Unit = { msg ->
Log.i(TAG, "[$tag] $msg")
onLog?.invoke("[$tag] $msg")
}
val startTime = System.currentTimeMillis()
val fullPrompt = buildChatTemplate(prompt, systemPromptOverride ?: systemPrompt)
nl("Prompt: '${prompt.take(80)}'")
// Clear KV cache → cur_pos_ = 0. Indispensable pour le cascade B :
// sans ça, le cur_pos_ accumule entre passes (Thinker → Speaker) et
// entre tours, et l'assertion ExecuTorch
// `cur_pos_+num_prompt_tokens < seq_len` finit par exploser après
// 2-3 messages. Le prompt est repassé en entier à chaque appel donc
// clear le KV n'altère pas le contexte conversationnel.
try { mod.resetContext() } catch (e: Throwable) {
nl("WARN: resetContext() failed: ${e.message}")
}
val responseBuilder = StringBuilder()
var firstTokenMs = -1L
// Track whether we're inside a <think>…</think> block so the upstream
// SentenceStreamer / TTS doesn't get fed reasoning tokens. Even with
// /no_think in the system prompt Qwen3 still emits empty <think></think>
// wrappers for ~3 tokens before the real answer.
var inThink = false
val tokenScan = StringBuilder() // small lookahead to spot tag boundaries
// Singleton special tokens that should never reach the TTS streamer
// (they leak when the model wraps its reply or signals end-of-turn).
val stripTokens = listOf("<|im_start|>", "<|im_end|>", "<|endoftext|>")
val maxTagLen = listOf("<think>", "</think>", "<|im_start|>", "<|im_end|>", "<|endoftext|>")
.maxOf { it.length }
val cb = object : LlmCallback {
override fun onResult(result: String) {
if (firstTokenMs < 0) firstTokenMs = System.currentTimeMillis() - startTime
responseBuilder.append(result)
// Forward to caller only outside <think> blocks, and strip
// singleton special tokens. We accumulate a tiny lookahead buffer
// so tag tokens that arrive split ("<thi", "nk>") still match.
tokenScan.append(result)
while (true) {
if (!inThink) {
val open = tokenScan.indexOf("<think>")
if (open < 0) {
// No <think> open pending — strip any singleton tokens
// that fully landed in the buffer, then flush prose
// up to a safe point preserving lookahead.
for (tok in stripTokens) {
var idx = tokenScan.indexOf(tok)
while (idx >= 0) {
tokenScan.delete(idx, idx + tok.length)
idx = tokenScan.indexOf(tok)
}
}
val safe = tokenScan.length - maxTagLen
if (safe > 0) {
onToken?.invoke(tokenScan.substring(0, safe))
tokenScan.delete(0, safe)
}
break
}
// Flush the prose before the <think> tag, then enter think mode.
if (open > 0) onToken?.invoke(tokenScan.substring(0, open))
tokenScan.delete(0, open + "<think>".length)
inThink = true
} else {
val close = tokenScan.indexOf("</think>")
if (close < 0) {
// Drop all buffered chars except a small tail in case
// the closing tag is split across tokens.
val keep = "</think>".length - 1
if (tokenScan.length > keep) tokenScan.delete(0, tokenScan.length - keep)
break
}
tokenScan.delete(0, close + "</think>".length)
inThink = false
}
}
}
override fun onStats(stats: String) {
nl("stats: ${stats.take(200)}")
}
}
// seqLen est le budget TOTAL (prefill + decode) pour QNN runner.
// Doit matcher le max_seq_len du .pte compilé (cf MODEL_SEQ_LEN).
// Test 2026-05-13 : seq=1024 + reload-after-turn (Speaking state →
// release+reload caché derrière TTS playback) pour éviter
// l'accumulation cur_pos qui causait l'OOM précédent.
val seqLen = MODEL_SEQ_LEN
val rc = try {
// echo=false so onResult() only receives the generated completion,
// not the prompt tokens echoed back — otherwise the sentence
// streamer would feed '<|im_start|>user …' to the TTS.
mod.generate(fullPrompt, seqLen, cb, /* echo */ false)
} catch (e: Throwable) {
nl("generate() threw: ${e.message}")
-1
}
// Drain any leftover prose buffered during <think>-suppression so the
// last sentence reaches the TTS even if it ran past the closing tag.
if (!inThink && tokenScan.isNotEmpty()) {
onToken?.invoke(tokenScan.toString())
tokenScan.clear()
}
val elapsed = System.currentTimeMillis() - startTime
val rawText = responseBuilder.toString()
val responseText = cleanResponse(rawText)
val tokenCount = rawText.length / 4 // rough estimate without a tokenizer
val rate = if (elapsed > 0) (tokenCount * 1000f) / elapsed else 0f
nl("Response: '${responseText.take(80)}'")
nl("Stats: rc=$rc ~${tokenCount}tok ~${"%.1f".format(rate)}tok/s TTFT=${firstTokenMs}ms total=${elapsed}ms")
GenerationResult(
text = responseText,
tokenCount = tokenCount,
timeMs = elapsed,
tokensPerSecond = rate
)
}
/**
* Qwen3 chat template matching qnn_llama_runner.cpp's get_formatted_prompt()
* for DecoderModelVersion::kQwen3. Note the user-first-then-system ordering
* (quirky but required the runner binary produces the same layout and our
* .pte was trained with it). Terminates with `<|im_start|>assistant` with
* no trailing newline, matching the binary exactly.
*/
private fun buildChatTemplate(userInput: String, sysPrompt: String = systemPrompt): String {
val sb = StringBuilder()
sb.append("<|im_start|>user\n").append(userInput).append("<|im_end|>\n")
if (sysPrompt.isNotEmpty()) {
sb.append("<|im_start|>system\n").append(sysPrompt).append("<|im_end|>\n")
}
sb.append("<|im_start|>assistant")
return sb.toString()
}
/** Strip <think>…</think>, special tokens, and leading/trailing whitespace. */
private fun cleanResponse(raw: String): String {
var text = raw
val thinkEnd = text.indexOf("</think>")
if (thinkEnd >= 0) {
text = text.substring(thinkEnd + "</think>".length)
} else if (text.indexOf("<think>") >= 0) {
nlog("WARN: <think> block never closed")
return ""
}
return text
.replace("<|im_start|>", "")
.replace("<|im_end|>", "")
.replace("<|endoftext|>", "")
.replace("<think>", "")
.replace("</think>", "")
.trim()
}
override fun release() {
try { llmModule?.resetNative() } catch (_: Throwable) {}
llmModule = null
loaded = false
}
/**
* @deprecated reload-after-turn workaround. Test 2026-05-13 sur seq=1024 .pte
* a montré que (1) resetContext() au début de generate() suffit (cur_pos
* remis à 0 via Runner::reset côté C++), et (2) le release()+load()
* échoue avec ExecutorchInvalidArgumentException "Failed to load llm runner: [1]"
* sur la 2 instanciation (probable bug singleton QnnBackendUnifiedRegistry).
* Conservé pour rétro-compat ; ne plus appeler.
*/
@Deprecated("Use resetContext() — already called at start of generate()")
suspend fun reloadAfterTurn() {
release()
load("", com.kazeia.core.LlmConfig())
}
}

View File

@ -1,74 +0,0 @@
package com.kazeia.llm
import android.util.Log
import com.kazeia.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GenieLlmEngine : LlmEngine {
companion object {
private const val TAG = "GenieLlmEngine"
}
private var dialogHandle: Long = 0
private var loaded = false
override suspend fun load(modelPath: String, config: LlmConfig) {
withContext(Dispatchers.IO) {
Log.i(TAG, "Loading Genie model from $modelPath")
val configFile = "$modelPath/genie_config.json"
dialogHandle = GenieJni.createDialog(configFile)
if (dialogHandle == 0L) {
throw RuntimeException("Failed to create Genie dialog from $configFile")
}
// Set stop sequences for chat
GenieJni.setStopSequence(dialogHandle, "Patient:")
GenieJni.setStopSequence(dialogHandle, "\nPatient")
loaded = true
Log.i(TAG, "Genie model loaded, handle=$dialogHandle, version=${GenieJni.getVersion()}")
}
}
override fun isLoaded(): Boolean = loaded
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = withContext(Dispatchers.IO) {
if (!loaded) throw IllegalStateException("Model not loaded")
val startTime = System.currentTimeMillis()
var tokenCount = 0
val callback = if (onToken != null) {
object : GenieJni.TokenCallback {
override fun onToken(token: String): Boolean {
tokenCount++
return onToken(token)
}
}
} else null
val response = GenieJni.query(dialogHandle, prompt, callback)
val elapsed = System.currentTimeMillis() - startTime
if (tokenCount == 0) tokenCount = response.split(" ").size
GenerationResult(
text = response,
tokenCount = tokenCount,
timeMs = elapsed,
tokensPerSecond = if (elapsed > 0) tokenCount * 1000f / elapsed else 0f
)
}
override fun release() {
if (dialogHandle != 0L) {
GenieJni.freeDialog(dialogHandle)
dialogHandle = 0
loaded = false
Log.i(TAG, "Genie model released")
}
}
}

View File

@ -0,0 +1,106 @@
package com.kazeia.llm
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer
import java.io.File
import java.nio.file.Paths
// Chargeur LLM agnostique au format. Détecte par magic-byte et instancie le bon moteur :
// - GGUF -> GgufLlmEngine (libkazeia_engine / libllama, CPU-i8mm). TOUS les modèles,
// dont l'hybride Qwen3.5-4B (GatedDeltaNet) qui ne s'exporte PAS en .pte.
// - .pte -> PteLlmEngine (ExecuTorch + QNN, NPU V79, lib kazeia_pte). Denses UNIQUEMENT
// (Qwen3-4B, Qwen3-8B, Qwen2.5-7B, Qwen3Guard-4B). Chemin in-app autorisé
// (libQnn Maven, sans root). RAM ~÷2, prefill NPU rapide ; decode ~= CPU.
//
// Parc validé on-device (17/06, SM8750/V79, decode tok/s) :
// Qwen3-4B .pte 15.7 | Qwen3-8B .pte 10.5 | Qwen3Guard-4B .pte 17.2 | Qwen2.5-7B .pte 8.0
// Qwen3.5-4B GGUF 16.6 (hybride, CPU-i8mm)
interface LlmEngine {
fun generate(sys: String, usr: String, max: Int = 96): String
fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean)
fun lastStats(): GenStats
fun reset()
fun release()
}
object LlmLoader {
private fun magic(path: String): ByteArray =
ByteArray(8).also { b -> File(path).inputStream().use { it.read(b) } }
private fun ByteArray.has(off: Int, tag: String): Boolean =
tag.indices.all { i -> off + i < size && this[off + i] == tag[i].code.toByte() }
// GGUF -> "GGUF" aux octets 0-3. ExecuTorch .pte -> "ET12" à l'octet 4.
// tokenizerPath : requis pour .pte (tokeniseur HF externe). Défaut = tokenizer.json voisin du .pte.
fun load(path: String, ctx: Int = 4096, nThreads: Int = 6, tokenizerPath: String? = null): LlmEngine {
val m = magic(path)
return when {
m.has(0, "GGUF") -> GgufLlmEngine(path, ctx, nThreads)
m.has(4, "ET12") -> {
val tk = tokenizerPath ?: (File(path).parent ?: ".") + "/tokenizer.json"
PteLlmEngine(path, tk, seqLen = ctx)
}
else -> error("Format LLM inconnu (ni GGUF ni ExecuTorch .pte) : $path")
}
}
}
// Adaptateur GGUF : délègue au moteur natif existant (libkazeia_engine / libllama, CPU-i8mm).
class GgufLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) : LlmEngine {
private val e = EngineLlmEngine(model, ctx, nThreads)
override fun generate(sys: String, usr: String, max: Int) = e.generate(sys, usr, max)
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) =
e.generateStream(sys, usr, max, onToken)
override fun lastStats() = e.lastStats()
override fun reset() = e.reset()
override fun release() = e.release()
}
// JNI ExecuTorch+QNN. Lib `kazeia_pte` = wrapper natif du runner ExecuTorch (rebuild QAIRT 2.42).
// jniLibs/arm64-v8a doit aussi contenir : libqnn_executorch_backend.so (2.42) +
// libQnnHtp/System/Prepare/HtpV79Stub/HtpNetRunExtensions.so (QAIRT 2.42) + libQnnHtpV79Skel.so.
// Set figé : /opt/Kazeia/pte_prep_work/ship_runtime/.
internal object ExecuTorchJni {
external fun load(ptePath: String, tokenizerPath: String, seqLen: Int, evalMode: Int): Long
external fun generateFromIds(handle: Long, promptIds: LongArray, maxNewTokens: Int): LongArray
external fun lastStats(handle: Long): LongArray // [prefillMs, decodeMs, nGenerated]
external fun free(handle: Long)
init { System.loadLibrary("kazeia_pte") }
}
// Adaptateur ExecuTorch+QNN (.pte) pour les denses sur NPU V79.
// La tokenisation est faite ICI (côté Kotlin) car le tokeniseur natif est cassé pour Qwen
// (regex lookahead RE2/PCRE2) : on applique le template ChatML, on encode en IDs, et on passe
// les IDs au natif (eval_mode 1 hybride). Le natif décode greedy jusqu'à EOS et renvoie les IDs générés.
class PteLlmEngine(ptePath: String, tokenizerJson: String, private val seqLen: Int = 4096) : LlmEngine {
private val tok = HuggingFaceTokenizer.newInstance(Paths.get(tokenizerJson))
private val h = ExecuTorchJni.load(ptePath, tokenizerJson, seqLen, /*evalMode=hybrid*/ 1)
init { require(h != 0L) { "échec chargement .pte : $ptePath" } }
// Template ChatML Qwen ; les balises sont dans le texte -> encode sans special tokens auto.
private fun chatml(sys: String, usr: String): LongArray {
val p = buildString {
if (sys.isNotEmpty()) append("<|im_start|>system\n").append(sys).append("<|im_end|>\n")
append("<|im_start|>user\n").append(usr).append("<|im_end|>\n<|im_start|>assistant\n")
}
return tok.encode(p, /*addSpecialTokens=*/false, /*withOverflowingTokens=*/false).ids
}
override fun generate(sys: String, usr: String, max: Int): String {
val outIds = ExecuTorchJni.generateFromIds(h, chatml(sys, usr), max)
return tok.decode(outIds, /*skipSpecialTokens=*/true).trim()
}
// Streaming réel = variante native à callback de token (TODO côté JNI).
// En attendant : génération bloquante puis émission en un bloc.
override fun generateStream(sys: String, usr: String, max: Int, onToken: (String) -> Boolean) {
onToken(generate(sys, usr, max))
}
override fun lastStats(): GenStats =
ExecuTorchJni.lastStats(h).let { GenStats(it[0], it[1], it[2]) }
override fun reset() { /* runner sans état persistant entre appels */ }
override fun release() { ExecuTorchJni.free(h); tok.close() }
}

View File

@ -0,0 +1,129 @@
package com.kazeia.llm
import com.kazeia.core.GenerationResult
import com.kazeia.core.LlmConfig
import com.kazeia.core.SamplingParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Pont unique app moteur LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md).
* Expose l'interface app `com.kazeia.core.LlmEngine` au-dessus des 2 moteurs de l'engine,
* choisis par MAGIC-BYTE (comme LlmLoader) :
*
* - **GGUF** ("GGUF"@0) `EngineLlmEngine` (libkazeia_engine, CPU i8mm) AVEC session
* cache-préfixe KV (system prefillé 1×, streaming) pour le hybride Qwen3.5-4B + tout GGUF.
* - **.pte** ("ET12"@4) `PteLlmEngine` (ExecuTorch+QNN, NPU V79, libkazeia_pte, tokenis. DJL)
* pour les denses Qwen3-4B/8B, Qwen2.5-7B, Qwen3Guard-4B. Chemin NPU autorisé sans root.
*
* Remplace ExecuTorchLlmEngine (ancien .pte) + EngineLlmAdapter (GGUF). `generateWithSystem`
* porte le prompt système PAR TOUR (cascade : bullets du Thinker ; hot-reload prompt Speaker).
*/
class UnifiedLlmAdapter(
private val modelPath: String,
private val systemPrompt: String,
private val ctx: Int = 4096,
private val nThreads: Int = 6,
private val tokenizerPath: String? = null,
private val onLog: ((String) -> Unit)? = null
) : com.kazeia.core.LlmEngine {
private enum class Fmt { GGUF, PTE }
@Volatile private var fmt: Fmt? = null
private var gguf: EngineLlmEngine? = null
private var session: LlmSession? = null
private var pte: PteLlmEngine? = null
private fun detect(path: String): Fmt {
val b = ByteArray(8)
File(path).inputStream().use { it.read(b) }
fun at(off: Int, s: String) = s.indices.all { i -> off + i < b.size && b[off + i] == s[i].code.toByte() }
return when {
at(0, "GGUF") -> Fmt.GGUF
at(4, "ET12") -> Fmt.PTE
else -> error("Format LLM inconnu (ni GGUF ni ET12 .pte) : $path")
}
}
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
if (fmt != null) return@withContext
val t0 = System.currentTimeMillis()
val name = File(this@UnifiedLlmAdapter.modelPath).name
when (detect(this@UnifiedLlmAdapter.modelPath).also { fmt = it }) {
Fmt.GGUF -> {
val e = EngineLlmEngine(this@UnifiedLlmAdapter.modelPath, ctx, nThreads)
gguf = e
session = e.newSession(systemPrompt) // cache-préfixe KV (system prefillé 1×)
onLog?.invoke("[LLM] GGUF chargé en ${System.currentTimeMillis() - t0}ms (session) : $name")
}
Fmt.PTE -> {
val tk = tokenizerPath ?: (File(this@UnifiedLlmAdapter.modelPath).parent ?: ".") + "/tokenizer.json"
pte = PteLlmEngine(this@UnifiedLlmAdapter.modelPath, tk, seqLen = ctx)
onLog?.invoke("[LLM] .pte NPU chargé en ${System.currentTimeMillis() - t0}ms : $name")
}
}
}
override fun isLoaded(): Boolean = fmt != null
override suspend fun generate(
prompt: String,
params: SamplingParams,
onToken: ((String) -> Boolean)?
): GenerationResult = generateWithSystem(prompt, null, params, onToken)
/** Génère avec prompt système optionnel PAR TOUR. null = prompt système par défaut
* (chemin nominal GGUF = session cache-préfixe). Non-null = override (cascade). */
suspend fun generateWithSystem(
prompt: String,
systemPromptOverride: String?,
params: SamplingParams,
onToken: ((String) -> Boolean)?,
tag: String = "" // étiquette de log (compat call-sites SPEAKER/THINKER)
): GenerationResult = withContext(Dispatchers.IO) {
// Anti-effondrement decode CPU (GGUF, doc §7.2) : priorité grands cœurs.
try { android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_FOREGROUND) } catch (_: Exception) {}
val t0 = System.currentTimeMillis()
val out: String
val stats: GenStats
when (fmt) {
Fmt.GGUF -> {
val e = gguf ?: error("UnifiedLlmAdapter GGUF non chargé")
val sb = StringBuilder()
if (systemPromptOverride == null) {
// nominal : session (cache-préfixe), stateless par tour
val s = session!!
s.reset()
s.ask(prompt, params.maxNewTokens) { p -> sb.append(p); onToken?.invoke(p) ?: true }
} else {
// override par tour (cascade) : mono-tour avec le system fourni
e.generateStream(systemPromptOverride, prompt, params.maxNewTokens) { p -> sb.append(p); onToken?.invoke(p) ?: true }
}
out = sb.toString().trim()
stats = e.lastStats()
}
Fmt.PTE -> {
val e = pte ?: error("UnifiedLlmAdapter .pte non chargé")
out = e.generate(systemPromptOverride ?: systemPrompt, prompt, params.maxNewTokens)
onToken?.invoke(out) // streaming .pte = un bloc (génération bloquante, doc §10)
stats = e.lastStats()
}
null -> error("UnifiedLlmAdapter non chargé")
}
val n = if (stats.nTokens > 0) stats.nTokens.toInt() else out.split(Regex("\\s+")).size
val ms = (stats.prefillMs + stats.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
onLog?.invoke("[LLM] prefill=${stats.prefillMs}ms decode=${stats.decodeMs}ms tok=${stats.nTokens} (${"%.1f".format(stats.decodeTokPerSec)} tok/s)")
GenerationResult(out, n, ms, stats.decodeTokPerSec.toFloat())
}
/** Conversation vierge (bornes : changement de patient, CLEAR_CHAT). GGUF (cache) ; no-op .pte. */
fun resetSession() { session?.reset() }
override fun release() {
session = null
gguf?.release(); gguf = null
pte?.release(); pte = null
fmt = null
}
}

View File

@ -20,8 +20,6 @@ import com.kazeia.conversation.ConversationManager
import com.kazeia.conversation.PromptBuilder
import com.kazeia.conversation.StoppingCriteria
import com.kazeia.core.*
import com.kazeia.llm.ExecuTorchLlmEngine
import com.kazeia.llm.GenieLlmEngine
import com.kazeia.profiles.ConversationDao
import com.kazeia.profiles.ConversationDb
import com.kazeia.profiles.ProfileStore
@ -64,7 +62,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// singleton partage le QnnBackendBundle (handle fastrpc DSP) entre les 2
// contextes — pas de collision DSP, pas de subprocess. Cf
// project_kazeia_cascade_option_e dans la mémoire persistante.
private var thinkerEngine: com.kazeia.llm.ExecuTorchLlmEngine? = null
private var thinkerEngine: com.kazeia.llm.UnifiedLlmAdapter? = null
private val thinkerLock = Object()
// Config runtime (cascade, prompts, modèles) pilotée par l'admin app via
@ -124,14 +122,10 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
log("[LLM] loaded in ${System.currentTimeMillis() - t0}ms")
}
private suspend fun reloadLlmAfterTurn() = llmMutex.withLock {
val ll = llm as? com.kazeia.llm.ExecuTorchLlmEngine ?: return@withLock
if (!ll.isLoaded()) return@withLock
val t0 = System.currentTimeMillis()
log("[LLM] reload pour reset cur_pos (état propre prochain tour)…")
ll.reloadAfterTurn()
log("[LLM] reloaded in ${System.currentTimeMillis() - t0}ms")
}
// reloadAfterTurn supprimé avec la refonte LLM 2026-06-17 : l'ancien hack
// ExecuTorchLlmEngine (reset cur_pos) n'a plus lieu d'être. UnifiedLlmAdapter
// gère l'état par tour (session reset GGUF ; .pte sans état persistant).
private suspend fun reloadLlmAfterTurn() { /* no-op */ }
private lateinit var tts: TtsEngine
private lateinit var audioPlayback: AudioPlaybackManager
@ -228,14 +222,15 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
log("[THINKER] loading ${info.displayName} engine in-process (lazy first PTT)…")
val tStart = System.currentTimeMillis()
val memBefore = readMemAvailableMb()
val e = com.kazeia.llm.ExecuTorchLlmEngine(
context = this@KazeiaService,
onLog = { msg -> log("[THINKER] $msg") },
modelPath = info.ptePath(),
tokenizerPath = info.tokenizerPath(),
// Thinker via UnifiedLlmAdapter : Guard-4B .pte (NPU) ou tout GGUF du registre.
val isPte = info.backend == com.kazeia.config.ModelRegistry.Backend.PTE
val e = com.kazeia.llm.UnifiedLlmAdapter(
modelPath = if (isPte) info.ptePath() else info.ggufPath(),
systemPrompt = cfg.thinker.systemPrompt,
temperature = cfg.thinker.temperature,
displayName = "${info.displayName} Thinker"
ctx = if (isPte) info.maxSeqLen else 4096,
nThreads = 4, // cascade : laisser des cœurs au Speaker
tokenizerPath = if (isPte) info.tokenizerPath() else null,
onLog = { msg -> log("[THINKER] $msg") }
)
try {
runBlocking { e.load("", com.kazeia.core.LlmConfig()) }
@ -817,47 +812,28 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// LLM = ExecuTorch QNN. Modèle choisi dans la config runtime
// (admin app peut le changer à chaud). Fallback sur le
// Speaker par défaut du registre si l'id config est inconnu.
// LLM = Kazeia-Engine GGUF (llama.cpp upstream + Hexagon), Speaker
// Qwen3.5-9B prefill NPU / decode CPU. Plus de .pte (-30% RAM).
// Backend LLM pilotable via ConfigStore.llmEngine. Default "prod" =
// ExecuTorch .pte (Qwen3 dense). "lib" = EngineLlmAdapter (libkazeia_engine,
// GGUF Qwen3.5). Fallback gracieux sur prod si la lib échoue au load
// (libllama fork ql encore absente du jniLibs tant que swap coordonné non fait).
val llmBackend = runtimeConfig.llmEngine
_loadingState.value = LoadingState(50, "LLM backend=$llmBackend")
// LLM unifié (refonte 2026-06-17, cf dist/LLM_INTEGRATION.md) :
// UnifiedLlmAdapter détecte le format par magic-byte et charge le bon
// moteur — GGUF (Qwen3.5-4B hybride, défaut) sur CPU i8mm avec cache-
// préfixe, ou .pte (denses Qwen3-4B/8B, Qwen2.5-7B) sur NPU V79 (sans
// root). Le flag ConfigStore.llmEngine n'est plus déterminant (auto-détecté).
val sp = com.kazeia.config.ModelRegistry.byId(runtimeConfig.speaker.modelId) ?: com.kazeia.config.ModelRegistry.defaultSpeaker()
val llmLib: com.kazeia.core.LlmEngine? = if (llmBackend == "lib") try {
// Si le Speaker sélectionné est un GGUF du registre (ex. Qwen2.5-7B),
// on le charge ; sinon fallback sur le GGUF moteur par défaut (q35-lmq4).
val gguf = if (sp.backend == com.kazeia.config.ModelRegistry.Backend.GGUF && sp.fileExists())
sp.ggufPath()
else
"${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
val plain = sp.chatTemplate == com.kazeia.config.ModelRegistry.ChatTemplate.PLAIN_CHATML
log("[LLM] lib GGUF=$gguf (speaker=${sp.id}, plainChatml=$plain)")
com.kazeia.llm.EngineLlmAdapter(gguf, runtimeConfig.speaker.systemPrompt,
plainChatml = plain, onLog = { msg -> log(msg) })
} catch (e: Throwable) {
log("[LLM] lib instantiation failed (${e.message}) — fallback .pte")
null
} else null
llm = llmLib ?: ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) },
sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt,
runtimeConfig.speaker.temperature, sp.displayName)
_loadingState.value = LoadingState(50, "LLM ${sp.id}")
val isPte = sp.backend == com.kazeia.config.ModelRegistry.Backend.PTE
val mPath = if (isPte) sp.ptePath() else sp.ggufPath()
val tkPath = if (isPte) sp.tokenizerPath() else null
val ctxLen = if (isPte) sp.maxSeqLen else 4096
log("[LLM] speaker=${sp.id} backend=${sp.backend} path=$mPath ctx=$ctxLen")
llm = com.kazeia.llm.UnifiedLlmAdapter(
mPath, runtimeConfig.speaker.systemPrompt,
ctx = ctxLen, nThreads = 6, tokenizerPath = tkPath,
onLog = { msg -> log(msg) }
)
try {
llm.load("", com.kazeia.core.LlmConfig())
log("LLM créé backend='${if (llmLib != null) "lib" else "prod"}'")
} catch (e: Exception) {
log("LLM load failed (${e.message}) — fallback echo")
if (llmLib != null) {
// lib loadLibrary("kazeia_engine") a échoué (libllama fork ql absente) ;
// on retombe sur .pte pour ne pas perdre l'app
log("[LLM] retry on prod .pte")
llm = ExecuTorchLlmEngine(this@KazeiaService, { msg -> log(msg) },
sp.ptePath(), sp.tokenizerPath(), runtimeConfig.speaker.systemPrompt,
runtimeConfig.speaker.temperature, sp.displayName)
try { llm.load("", com.kazeia.core.LlmConfig()) } catch (_: Exception) {}
}
log("LLM créé : ${sp.id} (${sp.backend})")
} catch (e: Throwable) {
log("LLM load failed (${e.message}) — mode écho jusqu'au prochain essai")
}
// RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la
@ -1010,7 +986,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
val nowId = ProfileStore.get(applicationContext).activeProfile()?.id
if (nowId != lastActiveProfileId) {
lastActiveProfileId = nowId
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession()
(llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
log("[LLM] profil actif changé → mémoire de session réinitialisée")
}
}
@ -1543,7 +1519,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
"CLEAR_CHAT" -> {
_messages.value = emptyList()
// Vide aussi la mémoire conversationnelle du LLM (session engine).
(llm as? com.kazeia.llm.EngineLlmAdapter)?.resetSession()
(llm as? com.kazeia.llm.UnifiedLlmAdapter)?.resetSession()
addMessage(ChatMessage(
role = ChatMessage.Role.SYSTEM,
text = "Conversation effacée."
@ -1802,7 +1778,7 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
// À 4 chars/tok ≈ 200 chars max user message safe avec cascade.
// Si message plus long → on tombe en mono Speaker (pas de bullets).
val speakerSystemPrompt: String? = if (cascadeBullets.isNotBlank() && patientMessage.length <= 200) {
com.kazeia.llm.ExecuTorchLlmEngine.DEFAULT_SYSTEM_PROMPT +
com.kazeia.config.ConfigStore.DEFAULT_SPEAKER_PROMPT +
"\n\nIndices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement à orienter le ton de ta réponse) :\n" +
cascadeBullets
} else {
@ -1974,13 +1950,12 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
maxNewTokens = 450,
temperature = conversationManager.currentTemperature()
)
// Si bullets cascade dispo → injection dans system prompt via
// generateWithSystem (cast vers ExecuTorchLlmEngine). Sinon
// generateWithSystem aussi pour propager le prompt système Speaker
// de la config runtime (hot-reload depuis admin app).
// generateWithSystem propage le prompt système Speaker par tour (bullets
// cascade ou hot-reload admin). UnifiedLlmAdapter le gère pour GGUF
// (mono-tour si override) comme pour .pte.
val llmRef = llm
val speakerPromptToUse = speakerSystemPrompt ?: cfg.speaker.systemPrompt
val result = if (llmRef is com.kazeia.llm.ExecuTorchLlmEngine) {
val result = if (llmRef is com.kazeia.llm.UnifiedLlmAdapter) {
llmRef.generateWithSystem(
prompt = prompt,
systemPromptOverride = speakerPromptToUse,

View File

@ -1,5 +1,5 @@
# jniLibs MANIFEST — état attendu de app/src/main/jniLibs/arm64-v8a/
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-15.
# Régénéré par scripts/jnilibs.sh update-manifest le 2026-06-17.
# Provenances : kazeia-engine dist (libllama/libggml*/libkazeia_*),
# ExecuTorch build local (libexecutorch*, libqnn_executorch_backend, libfbjni),
# QNN SDK 2.42 (libQnn*), Genie (libGenie), TTS pipeline (libtts_pipeline), libomp.
@ -15,15 +15,17 @@ d49b53137f478b7b4ccbd58b9166d83e832c299b681609c70628291c61ae7204 libGenie.so
7aeb209ec5c1da8857779b8f3b4c3e7519ed69c6211491948d3c87349dbced57 libggml.so
b92ca83a53dab49e212fba411b48fcceca4331b05fe881e2b9220efed45e70b1 libkazeia_cosyvoice.so
776f37e43433459a7350dc9d5d15dcb8eb3e17465a28fa78de92559bec3918bd libkazeia_engine.so
f91df82c96d1efd3cbe6f7af6b9d261cd5c6f4a929c08bad49a4cfc1deff4477 libkazeia_pte.so
fdd730af3daf132d0819b3fc99abd8aae79800aab7a9aad371e117c16bd3a3aa libkazeia_stt.so
114795bf0256c380dc87971598707e9e0b745a7fc868f3d1fb460e94cecdd69c libkazeia_tts.so
349ba592462113c6e871b9c3ee508727a9e5f154a33905f946bda97535eff586 libllama-common.so
2ca77a28421fc0c680e0a5ddf0208a9471268370893f8dd4eba2a10a8aacb4ac libllama.so
a207169a2696e8f0f7639090c2882152c734368b5696246a514d63b3ce0769c1 libomp.so
d1e8fc79e47fe04ed00aa9bbcc5ccb84d187f0160b1d77ae5bd012aedea9c938 libqnn_executorch_backend.so
95d01368b556f0c698cbbb6daa47494c666e0242a063cf1ca830202946435df4 libqnn_executorch_backend.so
414794639e2897120143cff80aaf73e830e305357b6c1aa8343de380c89b8c36 libQnnGpuNetRunExtensions.so
26f13b297d375e9629eb997b7adb05a4ce9e1c17d306baaefe905563fee43eb8 libQnnGpuProfilingReader.so
b5b52b66f3fc5567429024409dcedb006ca6a9115f271689810589b7d79af430 libQnnGpu.so
a3bc48674377a04248fcacbdf578a98b47d457821c53e6450ca11b59a3c5ef93 libQnnHtpNetRunExtensions.so
2daafb376fe6904aa325e29810480e3d76dc392991d1367ae6ea3508db2b60ac libQnnHtpPrepare.so
10eb1923b34ac9a726e4a446b395d5eee2ea5388487917778aaf954e8bfb2d92 libQnnHtp.so
7ee72b438c97c13ceb96d18fe255d1f156afbdc4c43669752f349dd5a56e62a3 libQnnHtpV79Skel.so