LLM: интерфейс + две реализации (Qwen удалённо / Gemma 4 E2B локально) + переключатель
- LlmClient интерфейс; RemoteLlmClient (OkHttp, llm.binom.pw) и LocalLlmClient (LiteRT-LM, Gemma 4 E2B) - LocalLlm: Gemma4E2B (SHA-256, GPU/CPU-бэкенды), buildLocalLlmPrompt (SYSTEM→systemInstruction, TOOL→user), GemmaModelDownloader (стриминг, прогресс, фолбэк источников), спекулятивное декодирование - LlmPrefs (персистентность выбора), PhoneApp: фабрика по llmChoice, пересоздание ассистента, скачивание/отмена - UI: GlassesInfoScreen — выбор сервер/локальная + прогресс; ChatScreen — индикатор активной модели - Тесты: +12 (LlmLocalTest) — phone 154 зелёных
This commit is contained in:
@@ -5,7 +5,9 @@ import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -28,10 +30,16 @@ import pw.binom.viewmate.phone.agent.AgentToolkit
|
||||
import pw.binom.viewmate.phone.agent.Assistant
|
||||
import pw.binom.viewmate.phone.agent.ChatDao
|
||||
import pw.binom.viewmate.phone.agent.ChatDb
|
||||
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||
import pw.binom.viewmate.phone.agent.GemmaModelDownloader
|
||||
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmClient
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LlmPrefs
|
||||
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
|
||||
import pw.binom.viewmate.phone.agent.MediaToolExecutor
|
||||
import pw.binom.viewmate.phone.agent.RemoteLlmClient
|
||||
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||
@@ -98,13 +106,92 @@ class PhoneApp : Application() {
|
||||
private var assistantLock = Any()
|
||||
private var assistant: Assistant? = null
|
||||
private var toolkit: AgentToolkit? = null
|
||||
/** Модель, на которой создан текущий [assistant] — смена выбора пересоздаёт ассистента. */
|
||||
private var assistantChoice: LlmModelChoice? = null
|
||||
/** Активный локальный LLM-клиент (движок закрываем при смене выбора). */
|
||||
private var localLlm: LocalLlmClient? = null
|
||||
|
||||
/** Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу. */
|
||||
/** Выбор модели LLM (персистится, [pw.binom.viewmate.phone.agent.LlmPrefs]). */
|
||||
val llmPrefs = LlmPrefs(this)
|
||||
private val _llmChoice = MutableStateFlow<LlmModelChoice>(llmPrefs.current())
|
||||
val llmChoice: StateFlow<LlmModelChoice> = _llmChoice.asStateFlow()
|
||||
|
||||
/** Состояние локальной модели Gemma 4 E2B для UI настроек. */
|
||||
private val _localLlmState = MutableStateFlow<LocalLlmModelState>(initialLocalLlmState())
|
||||
val localLlmState: StateFlow<LocalLlmModelState> = _localLlmState.asStateFlow()
|
||||
|
||||
private var localLlmDownloadJob: Job? = null
|
||||
private fun initialLocalLlmState(): LocalLlmModelState =
|
||||
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
||||
|
||||
/** Файл локальной модели: getExternalFilesDir(null)/gemma-4-E2B-it.litertlm. */
|
||||
fun localModelFile(): File = File(getExternalFilesDir(null) ?: filesDir, Gemma4E2B.FILE_NAME)
|
||||
|
||||
/** Сменить выбор модели: персист + пересоздание ассистента при следующей фразе. */
|
||||
fun setLlmModelChoice(choice: LlmModelChoice) {
|
||||
if (_llmChoice.value == choice) return
|
||||
llmPrefs.set(choice)
|
||||
_llmChoice.value = choice
|
||||
synchronized(assistantLock) { assistant = null }
|
||||
log("llm", "выбрана модель: ${choice.name}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
||||
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
||||
* Повторный вызов во время скачивания — no-op.
|
||||
*/
|
||||
fun downloadLocalLlmModel() {
|
||||
val current = localLlmDownloadJob
|
||||
if (current != null && current.isActive) return
|
||||
localLlmDownloadJob = scope.launch {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
||||
log("llm", "не удалось скачать модель: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
||||
fun cancelLocalLlmDownload() {
|
||||
localLlmDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ленивый ассистент (паттерн ensureStt): один экземпляр на процесс, не на каждую фразу.
|
||||
* LLM-клиент берётся по текущему выбору [llmChoice]: серверный или локальный
|
||||
* LiteRT-LM; при смене выбора ассистент создаётся заново.
|
||||
*/
|
||||
private fun ensureAssistant(): Assistant? {
|
||||
synchronized(assistantLock) {
|
||||
assistant?.let { return it }
|
||||
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
||||
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
val choice = _llmChoice.value
|
||||
if (assistant != null) {
|
||||
if (assistantChoice == choice) return assistant
|
||||
assistant = null
|
||||
}
|
||||
if (choice == LlmModelChoice.REMOTE && BuildConfig.LLM_API_KEY.isBlank()) {
|
||||
assistantChoice = choice
|
||||
return null
|
||||
}
|
||||
runCatching { localLlm?.close() }
|
||||
localLlm = null
|
||||
val llm = when (choice) {
|
||||
LlmModelChoice.REMOTE -> RemoteLlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
LlmModelChoice.LOCAL -> {
|
||||
val cacheDir = File(filesDir, "litertlm-cache")
|
||||
LocalLlmClient(modelFile = localModelFile(), cacheDir = cacheDir).also { localLlm = it }
|
||||
}
|
||||
}
|
||||
val toolsets = ToolsetRegistry()
|
||||
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
|
||||
val tk = toolkit ?: AgentToolkit(
|
||||
@@ -116,7 +203,10 @@ class PhoneApp : Application() {
|
||||
chatDao = chatDao,
|
||||
chat = { history, system -> llm.chat(history, system) },
|
||||
toolkit = tk,
|
||||
).also { assistant = it }
|
||||
).also {
|
||||
assistant = it
|
||||
assistantChoice = choice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +262,7 @@ class PhoneApp : Application() {
|
||||
log("assistant", "получена фраза: $phrase")
|
||||
val assistant = ensureAssistant()
|
||||
if (assistant == null) {
|
||||
log("assistant", "LLM-ключ не задан — фраза пропущена")
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
||||
continue
|
||||
}
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
|
||||
Reference in New Issue
Block a user