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))
|
||||
|
||||
@@ -11,29 +11,47 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* LLM-клиент для [Assistant]: один chat-вызов — история + опц. system-промпт
|
||||
* → текст ответа. Интерфейс для локальных и удалённых LLM (TASK-llm-interface):
|
||||
* - [RemoteLlmClient] — HTTP-запрос к OpenAI-совместимому эндпоинту;
|
||||
* - [LocalLlmClient] — локальный LiteRT-LM (Gemma 4 E2B на телефоне, GPU/CPU).
|
||||
*
|
||||
* Контракт: [chat] бросает [Exception] при ошибке — ассистент обрабатывает
|
||||
* («Ассистент недоступен: …»), сам клиент не падает тихо.
|
||||
*/
|
||||
interface LlmClient {
|
||||
/**
|
||||
* [messages] — история диалога (последнее сообщение — текущее обращение),
|
||||
* [system] — системный промпт тулкита/настроек или null (не обязателен).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
|
||||
* https://llm.binom.pw/v1/chat/completions.
|
||||
*
|
||||
* Коog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
|
||||
* Koog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
|
||||
* content на длинных историях (после tool-вызовов) — см. заметку
|
||||
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
|
||||
* модель Qwen3.8-27B-NVFP4 отвечает стабильно, тул-цикл работает.
|
||||
* модель отвечает стабильно, тул-цикл работает.
|
||||
*
|
||||
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
|
||||
* [http] инжектируется (дефолт — свой клиент с таймаутами).
|
||||
*/
|
||||
class LlmClient(
|
||||
class RemoteLlmClient(
|
||||
private val apiKey: String,
|
||||
private val baseUrl: String = "https://llm.binom.pw",
|
||||
private val model: String,
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
.build(),
|
||||
) : LlmClient {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@Serializable
|
||||
private data class ChatMessageDto(
|
||||
@@ -67,7 +85,7 @@ class LlmClient(
|
||||
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
|
||||
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
|
||||
*/
|
||||
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
val dto = ArrayList<ChatMessageDto>()
|
||||
if (system != null) dto += ChatMessageDto("system", system)
|
||||
for (msg in messages) {
|
||||
|
||||
@@ -15,7 +15,7 @@ object LlmDebug {
|
||||
log("llm", "llm.apiKey не задан в local.properties — пропускаю")
|
||||
return
|
||||
}
|
||||
val client = LlmClient(apiKey = apiKey, model = model)
|
||||
val client = RemoteLlmClient(apiKey = apiKey, model = model)
|
||||
try {
|
||||
val reply = runBlockingSafe {
|
||||
client.chat(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
/**
|
||||
* Персистентный выбор модели LLM ассистента ([LlmModelChoice]) —
|
||||
* SharedPreferences «llm-choice». Дефолт (пусто/неизвестно) — [LlmModelChoice.REMOTE].
|
||||
*
|
||||
* Чистая логика парсинга — [LlmModelChoice.parse] (JVM-тестируемая);
|
||||
* этот класс только обёртка над SharedPreferences.
|
||||
*/
|
||||
class LlmPrefs(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun current(): LlmModelChoice = LlmModelChoice.parse(prefs.getString(KEY_MODEL_CHOICE, null))
|
||||
|
||||
fun set(choice: LlmModelChoice) {
|
||||
prefs.edit().putString(KEY_MODEL_CHOICE, choice.name).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "llm-choice"
|
||||
const val KEY_MODEL_CHOICE = "model_choice"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package pw.binom.viewmate.phone.agent
|
||||
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
import com.google.ai.edge.litertlm.Content
|
||||
import com.google.ai.edge.litertlm.Contents
|
||||
import com.google.ai.edge.litertlm.Conversation
|
||||
import com.google.ai.edge.litertlm.ConversationConfig
|
||||
import com.google.ai.edge.litertlm.Engine
|
||||
import com.google.ai.edge.litertlm.EngineConfig
|
||||
import com.google.ai.edge.litertlm.ExperimentalApi
|
||||
import com.google.ai.edge.litertlm.ExperimentalFlags
|
||||
import com.google.ai.edge.litertlm.Message
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private fun log(tag: String, message: String) = pw.binom.viewmate.phone.log(tag, message)
|
||||
|
||||
/**
|
||||
* Выбор модели LLM ассистента — персистится ([LlmPrefs] в PhoneApp),
|
||||
* переключается UI-блоком в настройках экрана «Телефон»:
|
||||
* - [REMOTE] — сервер llm.binom.pw ([RemoteLlmClient]: OpenAI-совместимый HTTP);
|
||||
* - [LOCAL] — Gemma 4 E2B на телефоне ([LocalLlmClient], LiteRT-LM, GPU/CPU).
|
||||
*/
|
||||
enum class LlmModelChoice {
|
||||
REMOTE, LOCAL;
|
||||
|
||||
companion object {
|
||||
/** Значение из настроек; пустое/неизвестное имя → [REMOTE] (дефолт). */
|
||||
fun parse(name: String?): LlmModelChoice =
|
||||
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: REMOTE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemma 4 E2B (LiteRT-LM): локальный LLM на телефоне.
|
||||
* Модель — файл [FILE_NAME] в getExternalFilesDir(null) телефона;
|
||||
* докачивается внутри приложения (см. [GemmaModelDownloader]).
|
||||
* API-ноты: docs/litertlm-openassistant-notes.md в репо.
|
||||
*/
|
||||
object Gemma4E2B {
|
||||
const val FILE_NAME = "gemma-4-E2B-it.litertlm"
|
||||
const val SHA256 = "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c"
|
||||
|
||||
/** Размер файла для UI (2 588 147 712 байта). */
|
||||
const val SIZE_HUMAN = "≈2.5 ГБ"
|
||||
|
||||
/** Источники скачивания по приоритету: HuggingFace (открытый), затем зеркало. */
|
||||
val DOWNLOAD_URLS = listOf(
|
||||
"https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm",
|
||||
"https://data.vayunmathur.com/models/gemma-4-E2B-it.litertlm",
|
||||
)
|
||||
|
||||
/**
|
||||
* Конфиг движка: GPU-бэкенд (Adreno/Mali), каудирование CPU, кэш в
|
||||
* [cacheDir] (cacheDir — не tmpDir, см. заметку про /data/local/tmp).
|
||||
*/
|
||||
fun engineConfig(modelFile: File, cacheDir: File): EngineConfig = EngineConfig(
|
||||
modelPath = modelFile.absolutePath,
|
||||
backend = Backend.GPU(),
|
||||
visionBackend = Backend.GPU(),
|
||||
audioBackend = Backend.CPU(),
|
||||
cacheDir = cacheDir.absolutePath,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Чистые вспомогательные (JVM-тестируемые) ----------
|
||||
|
||||
/** hex SHA-256 содержимого файла потоково (по 64 КБ — файл ~2.5 ГБ). */
|
||||
fun sha256HexOfFile(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val readSize = input.read(buf)
|
||||
if (readSize < 0) break
|
||||
digest.update(buf, 0, readSize)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** true, если [file] существует и его SHA-256 совпадает с [expectedHex]. */
|
||||
fun sha256OfFileMatches(file: File, expectedHex: String): Boolean =
|
||||
file.isFile && sha256HexOfFile(file).equals(expectedHex, ignoreCase = true)
|
||||
|
||||
/** Данные LiteRT-LM разговора: системный промпт, история и «входящее» сообщение. */
|
||||
class LocalLlmPrompt(
|
||||
val systemInstruction: String?,
|
||||
val initialMessages: List<Message>,
|
||||
val incoming: Message,
|
||||
)
|
||||
|
||||
/** Текст текстового контента LiteRT-LM-сообщения (для проверок/логов). */
|
||||
fun Message.text(): String = contents.contents.filterIsInstance<Content.Text>()
|
||||
.joinToString("") { it.text }
|
||||
|
||||
/**
|
||||
* Маппинг истории ассистента → формат разговора LiteRT-LM (чистая функция):
|
||||
* - USER → [Message.user], ASSISTANT → [Message.model];
|
||||
* - TOOL (результат тула) — как user-сообщение в том же формате «Результат
|
||||
* тула <id>: <текст>», что шлёт [RemoteLlmClient] (у LiteRT-LM нет
|
||||
* tool-роли в разговорной ленте — tool-результат идёт как user-мессадж);
|
||||
* - SYSTEM-сообщения и параметр [system] складываются в ОДИН
|
||||
* systemInstruction (в LiteRT-LM один системный промпт на разговор);
|
||||
* - пустые content отбрасываются.
|
||||
*
|
||||
* Входящее сообщение ([LocalLlmPrompt.incoming]) — последнее непустое USER/TOOL:
|
||||
* оно уходит в sendMessageAsync, остальное — история (initialMessages).
|
||||
* Если последние сообщения только модельные/история пуста — incoming = «Продолжай.»
|
||||
* (аналог fallback [RemoteLlmClient]).
|
||||
*/
|
||||
fun buildLocalLlmPrompt(messages: List<ChatMessage>, system: String? = null): LocalLlmPrompt {
|
||||
val systemParts = ArrayList<String>()
|
||||
system?.takeIf { it.isNotBlank() }?.let { systemParts += it }
|
||||
val mapped = ArrayList<Message>()
|
||||
for (msg in messages) {
|
||||
val text = msg.content
|
||||
when (msg.role) {
|
||||
ChatRole.SYSTEM -> if (text.isNotBlank()) systemParts += text
|
||||
ChatRole.USER -> if (text.isNotBlank()) mapped += Message.user(Contents.of(text))
|
||||
ChatRole.ASSISTANT -> if (text.isNotBlank()) mapped += Message.model(Contents.of(text))
|
||||
ChatRole.TOOL -> if (text.isNotBlank()) {
|
||||
mapped += Message.user(Contents.of("Результат тула ${msg.toolCallId ?: "?"}: $text"))
|
||||
}
|
||||
}
|
||||
}
|
||||
val last = messages.lastOrNull { it.content.isNotBlank() }
|
||||
val incoming: Message
|
||||
val initial: List<Message>
|
||||
if (last != null && (last.role == ChatRole.USER || last.role == ChatRole.TOOL)) {
|
||||
initial = mapped.dropLast(1)
|
||||
incoming = mapped.last()
|
||||
} else {
|
||||
initial = mapped
|
||||
incoming = Message.user(Contents.of("Продолжай."))
|
||||
}
|
||||
return LocalLlmPrompt(
|
||||
systemInstruction = systemParts.joinToString("\n").ifEmpty { null },
|
||||
initialMessages = initial,
|
||||
incoming = incoming,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Скачивание модели ----------
|
||||
|
||||
/** Состояние локальной модели для UI настроек (StateFlow в PhoneApp). */
|
||||
sealed interface LocalLlmModelState {
|
||||
/** Модель не скачана (или ещё не скачивалась). */
|
||||
object Idle : LocalLlmModelState
|
||||
|
||||
/** Скачивание в пути; [percent] — 0..99. */
|
||||
data class Downloading(val percent: Int) : LocalLlmModelState
|
||||
|
||||
/** Модель скачана и проверена (SHA-256 совпал при скачивании). */
|
||||
object Ready : LocalLlmModelState
|
||||
|
||||
/** Ошибка скачивания: [message]. */
|
||||
data class Error(val message: String) : LocalLlmModelState
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивание Gemma 4 E2B ([Gemma4E2B.DOWNLOAD_URLS] по порядку):
|
||||
* OkHttp-стриминг в файл .part, прогресс в процентах через колбэк,
|
||||
* SHA-256-контроль после загрузки (несовпал — удалить и взять следующий
|
||||
* исходник), после успешного — перенос на [target]. Прогресс — из IO-потока
|
||||
* (колбэк должен быть потоковезбёчным; в PhoneApp — запись в StateFlow).
|
||||
*/
|
||||
class GemmaModelDownloader(
|
||||
private val target: File,
|
||||
private val urls: List<String> = Gemma4E2B.DOWNLOAD_URLS,
|
||||
private val expectedSha256: String = Gemma4E2B.SHA256,
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
) {
|
||||
/**
|
||||
* Скачать модель в [target]. Бросает [IOException] «Модель не скачалась…»
|
||||
* (все источники исчерпаны / контрольная сумма). [onProgressPercent] —
|
||||
* 0..99 во время скачивания (вызывается из IO-потока).
|
||||
*/
|
||||
suspend fun download(onProgressPercent: (Int) -> Unit) {
|
||||
target.parentFile?.mkdirs()
|
||||
val partial = File(target.parentFile, target.name + ".part")
|
||||
var lastError: String? = null
|
||||
for (url in urls) {
|
||||
var ok: Boolean
|
||||
try {
|
||||
ok = downloadFrom(url, partial, onProgressPercent)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ok = false
|
||||
lastError = e.message ?: e.toString()
|
||||
}
|
||||
log("llm", "скачивание модели из $url: ${if (ok) "ок" else "не удалось ($lastError)"}")
|
||||
if (ok) {
|
||||
log("llm", "модель скачана: ${target.length()} байт, SHA-256 совпадает")
|
||||
return
|
||||
}
|
||||
partial.delete()
|
||||
}
|
||||
partial.delete()
|
||||
throw IOException("Модель не скачалась (все источники): $lastError — повторите позже")
|
||||
}
|
||||
|
||||
private suspend fun downloadFrom(
|
||||
url: String,
|
||||
partial: File,
|
||||
onProgressPercent: (Int) -> Unit,
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
http.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
log("llm", "HTTP ${response.code} с $url")
|
||||
return@withContext false
|
||||
}
|
||||
val body = response.body ?: return@withContext false
|
||||
val totalBytes = body.contentLength()
|
||||
var received = 0L
|
||||
var lastPercent = -1
|
||||
partial.outputStream().use { out ->
|
||||
body.byteStream().use { input ->
|
||||
val buf = ByteArray(256 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
out.write(buf, 0, n)
|
||||
received += n
|
||||
val percent = if (totalBytes > 0) ((received * 100) / totalBytes).toInt()
|
||||
else ((received / (4 * 1024 * 1024)) * 25).toInt().coerceAtMost(99)
|
||||
if (percent > lastPercent) {
|
||||
lastPercent = percent
|
||||
log("llm", "модель: скачано ${received / (1024 * 1024)} МБ" +
|
||||
(if (totalBytes > 0) " ($percent%)" else ""))
|
||||
onProgressPercent(percent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sha256OfFileMatches(partial, expectedSha256)) {
|
||||
log("llm", "SHA-256 модели не совпал — перезаписываем")
|
||||
partial.delete()
|
||||
return@withContext false
|
||||
}
|
||||
partial.renameTo(target)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Клиент ----------
|
||||
|
||||
/**
|
||||
* Локальный LLM-клиент: Gemma 4 E2B через LiteRT-LM на устройстве
|
||||
* (GPU-бэкенд по дефолту; см. docs/litertlm-openassistant-notes.md).
|
||||
*
|
||||
* Жизненный цикл: [Engine] создаётся один раз (лениво, при первом [chat]),
|
||||
* переиспользуется — модель заново не грузится. Каждый чатовый вызов —
|
||||
* новый Conversation (своей истории у LiteRT-LM нет — она в наших
|
||||
* initialMessages). Ошибки — [IllegalStateException] с читаемым русским
|
||||
* сообщением («локальная модель не скачана…») — [Assistant] покажет его
|
||||
* пользователю, падение приложения исключено.
|
||||
*
|
||||
* Модель-файл: [modelFile] (getExternalFilesDir(null)); [cacheDir] —
|
||||
* кэш-каталог движка (cacheDir, не tmpDir — tmp-файлы удаляются системой).
|
||||
*/
|
||||
class LocalLlmClient(
|
||||
private val modelFile: File,
|
||||
private val cacheDir: File,
|
||||
) : LlmClient {
|
||||
|
||||
private val engineLock = Any()
|
||||
@Volatile
|
||||
private var engine: Engine? = null
|
||||
|
||||
/** Модель на месте (по имени файла; SHA-контроль — в [ensureEngine]). */
|
||||
fun modelDownloaded(): Boolean = modelFile.isFile
|
||||
|
||||
override suspend fun chat(messages: List<ChatMessage>, system: String?): String {
|
||||
val engine = ensureEngine()
|
||||
val prompt = buildLocalLlmPrompt(messages, system)
|
||||
val conversation: Conversation = synchronized(engineLock) {
|
||||
engine.createConversation(
|
||||
ConversationConfig(
|
||||
systemInstruction = prompt.systemInstruction?.let { Contents.of(it) },
|
||||
initialMessages = prompt.initialMessages,
|
||||
)
|
||||
)
|
||||
}
|
||||
try {
|
||||
var text = ""
|
||||
conversation.sendMessageAsync(prompt.incoming).collect { messageChunk ->
|
||||
text += messageChunk.text()
|
||||
}
|
||||
return text.trim()
|
||||
} catch (e: CancellationException) {
|
||||
runCatching { conversation.cancelProcess() }
|
||||
throw e
|
||||
} finally {
|
||||
runCatching { conversation.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Закрыть движок (освобождает ~2-3 ГБ RAM). Вызывать при пересоздании ассистента. */
|
||||
fun close() {
|
||||
val eng = synchronized(engineLock) {
|
||||
val e = engine
|
||||
engine = null
|
||||
e
|
||||
}
|
||||
runCatching { eng?.close() }
|
||||
}
|
||||
|
||||
/** Включаем спекулятивное декодирование (на 2B заметно быстрее, см. заметки). */
|
||||
@OptIn(ExperimentalApi::class)
|
||||
private fun applyExperimentalFlags() {
|
||||
ExperimentalFlags.enableSpeculativeDecoding = true
|
||||
}
|
||||
|
||||
/** Ленивая инициализация движка (блокирующая — вызываем с IO-потока). */
|
||||
private fun ensureEngine(): Engine {
|
||||
val cached = engine
|
||||
if (cached != null) return cached
|
||||
return synchronized(engineLock) {
|
||||
val existing = engine
|
||||
if (existing != null) return existing
|
||||
if (!modelFile.isFile) {
|
||||
throw IllegalStateException("Локальная модель не скачана — скачайте её в настройках экрана «Телефон»")
|
||||
}
|
||||
if (!sha256OfFileMatches(modelFile, Gemma4E2B.SHA256)) {
|
||||
throw IllegalStateException("Локальная модель повреждена (SHA-256 не совпадает) — скачайте её заново")
|
||||
}
|
||||
applyExperimentalFlags()
|
||||
val created = Engine(Gemma4E2B.engineConfig(modelFile, cacheDir))
|
||||
try {
|
||||
created.initialize()
|
||||
} catch (e: Exception) {
|
||||
runCatching { created.close() }
|
||||
throw IllegalStateException("Не удалось инициализировать локальную модель: ${e.message}", e)
|
||||
}
|
||||
log("llm", "локальный движок инициализирован: ${modelFile.name}")
|
||||
engine = created
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,29 @@ data class ChatMessage(
|
||||
val toolCalls: List<ToolCall>,
|
||||
val attachments: List<Attachment>,
|
||||
val createdAt: Long,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Фабрика для сообщений до записи в БД (тесты, сборка промпта):
|
||||
* id/sessionId/createdAt — 0, пустые toolCalls/attachments.
|
||||
*/
|
||||
fun of(
|
||||
role: ChatRole,
|
||||
content: String,
|
||||
toolCallId: String? = null,
|
||||
toolCalls: List<ToolCall> = emptyList(),
|
||||
) = ChatMessage(
|
||||
id = 0L,
|
||||
sessionId = 0L,
|
||||
role = role,
|
||||
content = content,
|
||||
toolCallId = toolCallId,
|
||||
toolCalls = toolCalls,
|
||||
attachments = emptyList(),
|
||||
createdAt = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Json для сериализации toolCalls/attachments в строковые колонки БД. */
|
||||
internal val agentJson: Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -41,6 +41,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.ChatRole
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.Session
|
||||
|
||||
/**
|
||||
@@ -151,6 +152,16 @@ fun ChatScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
||||
val llmChoice by app.llmChoice.collectAsState()
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Лента сообщений
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -24,6 +25,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.Gemma4E2B
|
||||
import pw.binom.viewmate.phone.agent.LlmModelChoice
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/** Экран «Информация об очках»: модель, батарея, связь, диск (медиа/свободно/прочее), версия. */
|
||||
@@ -137,6 +141,10 @@ fun GlassesInfoScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LlmModelSection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,3 +173,118 @@ private fun InfoRow(label: String, value: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок выбора модели LLM ассистента: серверная (llm.binom.pw) или локальная
|
||||
* Gemma 4 E2B (LiteRT-LM) + управление скачиванием модели (прогресс, отмена).
|
||||
*/
|
||||
@Composable
|
||||
private fun LlmModelSection() {
|
||||
val app = PhoneApp.instance
|
||||
val choice by app.llmChoice.collectAsState()
|
||||
val llmState by app.localLlmState.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp)) {
|
||||
SectionLabel("Модель LLM ассистента")
|
||||
StatusBlock {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LlmChoiceChip(
|
||||
label = "сервер",
|
||||
selected = choice == LlmModelChoice.REMOTE,
|
||||
onClick = { app.setLlmModelChoice(LlmModelChoice.REMOTE) },
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
LlmChoiceChip(
|
||||
label = "локальная",
|
||||
selected = choice == LlmModelChoice.LOCAL,
|
||||
onClick = { app.setLlmModelChoice(LlmModelChoice.LOCAL) },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (choice == LlmModelChoice.LOCAL) {
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Модель готова (Gemma 4 E2B, локально)",
|
||||
color = PhoneGreen,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Чип переключателя выбора модели (активный — акцентный фон). */
|
||||
@Composable
|
||||
private fun LlmChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (selected) PhoneAccent else PhoneSurface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (selected) PhoneBackground else PhoneTextDim,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Маленькая текстовая кнопка (скачать/отмена/повтор) в блоке LLM. */
|
||||
@Composable
|
||||
private fun LlmSmallAction(label: String, onClick: () -> Unit) {
|
||||
Text(
|
||||
text = label,
|
||||
color = PhoneAccent,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user