![]()
GGUF Chat — Android LLM client
RU Русский · EN English · DE Deutsch
1. Что это
GGUF Chat — нативное Android-приложение (пакет com.devhorizon.online.ggufchat), которое:
- загружает локальные GGUF-модели и запускает генерацию на CPU через вендоренный
llama.cpp; - поддерживает зрение (изображения) через
mtmd+mmproj-проектор (Qwen2-VL, Gemma 4 E2B и др.); - умеет текстовый чат по документам, голосовой ввод (STT) и озвучку (TTS), подсказки-чипы, поиск/скачивание моделей с Hugging Face, монитор ОЗУ и кнопку «Освободить ОЗУ»;
- локализован на en / ru / de и имеет 6 тем.
2. Полный стек
| Слой | Технологии |
|---|---|
| Язык | Kotlin 2.2.10 (JVM target 17) |
| Сборка | Gradle + AGP 9.1.1, Kotlin Compose plugin |
| UI | Jetpack Compose (BOM 2024.09.00), Material3, Material Icons (core+extended), Navigation Compose 2.8.9 |
| Состояние | AndroidX Lifecycle/ViewModel 2.8.7, Kotlin Coroutines/Flow 1.10.2 |
| Данные | org.json (JSONObject/JSONArray), SharedPreferences, файлы сессий в files/sessions/*.json |
| Сеть | HttpURLConnection, REST API Hugging Face |
| Медиа | Android SpeechRecognizer (STT), TextToSpeech (TTS), ContentResolver/document picker, FileProvider (камера), BitmapFactory |
| Нативный слой | C++17, JNI, llama.cpp (app/src/main/cpp/llama.cpp-new) + tools/mtmd, CMake 3.22.1, NDK 28.2.13676358, только arm64-v8a |
| Платформа | minSdk 26, targetSdk 36, compileSdk 36, ANDROID_STL=c++_shared, нативная сборка всегда Release (-DCMAKE_BUILD_TYPE=Release) |
Ключевые зависимости (app/build.gradle.kts): androidx.compose.material3, androidx.navigation.compose, kotlinx.coroutines.android, kotlinx.serialization.json. Манифест: разрешения RECORD_AUDIO, INTERNET; FileProvider с authority ${applicationId}.fileprovider.
3. Архитектура APK
┌───────────────────────────── UI (Compose) ─────────────────────────────┐
│ MainActivity / AppNavigation (NavHost: chat, models, settings) │
│ ChatScreen · ModelPickerScreen · SettingsScreen · DrawerContent │
│ MarkdownText · Theme · Localization │
└───────────────▲───────────────────────────────────────────▲────────────┘
│ collectAsState() │ l(key, lang)
┌───────────────┴───────────── ViewModel ────────────────────┴────────────┐
│ ChatViewModel (AndroidViewModel): StateFlow-состояние, sendMessage, │
│ loadModel, freeRam, suggestions, documents, STT/TTS, настройки │
└───▲──────────▲───────────────▲───────────────────▲──────────────────────┘
│ │ │ │
┌───┴──────┐ ┌─┴───────────┐ ┌─┴──────────────┐ ┌──┴───────────────┐
│ Chat │ │ LlmEngine │ │ Suggestion │ │ SttManager / │
│ Repo │ │ (inference, │ │ Engine │ │ TtsManager │
│ (sessions│ │ models, │ │ (dedup, retry) │ │ (voice) │
│ docs) │ │ mmproj, │ └────────────────┘ └──────────────────┘
└──────────┘ │ settings) │
└──────┬──────┘
│ JNI (LocalLlmNative)
┌───────────────────┴────────────────────────────────────────────────────┐
│ llama_jni.cpp → llama.cpp (llama) + mtmd (vision) + ggml (CPU) │
│ libllama_jni.so (arm64-v8a) │
└────────────────────────────────────────────────────────────────────────┘
Поток данных: UI → ChatViewModel → LlmEngine.generate/generateVision → JNI → llama_decode → токены стримятся обратно через TokenCallback → StateFlow → Compose. Сессии/документы — ChatRepository (JSON на диске); модели/настройки — SharedPreferences (gguf_chat_prefs).
4. Функции и код
4.1 Точка входа и навигация — MainActivity.kt
| Функция | Что делает | Код |
|---|---|---|
MainActivity.onCreate |
Точка входа, включает edge-to-edge и контент Compose | setContent { AppNavigation() } |
AppNavigation() |
Создаёт ChatViewModel, NavHost, тему, локализатор l |
val l = { key -> Localization.getString(key, lang) } |
AppContent(...) |
Drawer + NavHost с маршрутами chat/models/settings |
navController.navigate("models") |
маршрут chat |
Экран чата | composable("chat") { ChatScreen(...) } |
4.2 ViewModel — ui/viewmodel/ChatViewModel.kt
| Функция | Что делает | Код |
|---|---|---|
importModel(uri) |
Импорт модели из файла | val result = llmEngine.importModel(uri, displayName) |
downloadModelFromUrl(url) |
Скачивание модели; mmproj-URL роутится в проектор | if (rawName.contains("mmproj", ignoreCase = true)) { ... } |
searchModels(q, maxMB, includeProj) |
Поиск на HF в IO-диспетчере | HuggingFaceClient.searchGgufModels(...) |
loadModel(model) |
Загрузка модели + её проектора | llmEngine.loadModel(modelInfo, modelInfo.mmprojPath) |
attachImage(uri) |
Копирует выбранную картинку в files/images |
java.io.File(dir, "img_${UUID.randomUUID()}.jpg") |
clearPendingImage() |
Сбрасывает выбранную картинку | _pendingImage.value = null |
unloadModel() |
Выгрузка текущей модели | llmEngine.unloadModel() |
refreshMemory() |
Читает total/avail ОЗУ | am.getMemoryInfo(mi) |
freeRam() |
Выгружает всё и возвращает память ОС | llmEngine.unloadModel(); System.gc(); LocalLlmNative.nativeFreeMemory() |
deleteModel(modelInfo) |
Удаляет модель и запись | llmEngine.deleteModel(modelInfo) |
createNewSession() |
Новая сессия чата | chatRepository.createSession() |
selectSession(id) |
Переключение сессии | chatRepository.selectSession(id) |
addDocument(uri) |
Читает документ и режет под бюджет контекста | val (text, truncated) = DocumentReader.truncate(raw.text, remaining) |
removeDocument(id) |
Удаляет документ из сессии | chatRepository.removeDocument(sessionId, id) |
documentBudgetTokens() |
Бюджет токенов под документы | DocumentReader.budgetTokens(...) |
sendMessage() |
Главный сценарий: выбор vision/text, генерация, сохранение, автоподсказки; фрагменты TTS ставятся в очередь по знакам препинания | val useVision = imagePath != null && llmEngine.hasVision |
lastTtsDelimiter(sb) |
Индекс последнего знака препинания в буфере TTS | '.', '!', '?', ';', ':', ',', '…' -> return i |
attachMmprojToModel(model, uri) |
Импорт и привязка проектора | llmEngine.importMmproj(uri).onSuccess { llmEngine.setModelMmproj(...) } |
downloadMmprojForModel(...) |
Скачивание проектора и привязка | llmEngine.downloadMmprojFromUrl(url, name) |
removeMmprojFromModel(model) |
Снимает привязку проектора | llmEngine.setModelMmproj(model.path, null) |
stopGeneration() |
Остановка генерации | llmEngine.cancelGeneration() |
generateSuggestions(onlyNew) |
Запуск подсказок через SuggestionEngine |
suggestionEngine.generate(...) |
onSuggestionClicked(s) |
Подставляет подсказку в поле ввода | _inputText.value = suggestion |
startVoiceInput()/stopVoiceInput() |
STT | sttManager.startListening(lang) { ... } |
speakText()/stopSpeaking() |
TTS | ttsManager.speak(text) |
updateSettings(new) / setAppLanguage(lang) |
Сохранение настроек | llmEngine.saveSettings(newSettings) |
onCleared() |
Освобождение ресурсов | llmEngine.unloadModel() |
4.3 Данные чата — data/chat/
| Функция | Что делает | Код |
|---|---|---|
ChatRepository.loadSessions() |
Читает files/sessions/*.json |
sessionsDir.listFiles { f -> f.extension == "json" } |
parseSession(json) |
Парсит сессию/сообщения | val arr = o.getJSONArray("messages") |
parseDocuments(o) |
Парсит документы сессии | val arr = o.optJSONArray("documents") ?: return emptyList() |
saveSession(session) |
Пишет сессию в JSON | File(sessionsDir, "${session.id}.json").writeText(o.toString()) |
createSession() |
Создаёт сессию и делает текущей | _currentSessionId.value = session.id |
addMessage(id, msg) |
Добавляет сообщение + авто-заголовок | message.content.take(40).replace("\n", " ") |
addDocument / removeDocument |
Изменяют документы сессии | session.copy(documents = session.documents + document) |
deleteSession(id) / clearAll() |
Удаление сессий | File(sessionsDir, "$id.json").delete() |
DocumentReader.estimateTokens(t) |
Эвристика токенов (кириллица дороже) | val charsPerToken = if (cyrRatio > 0.2) 1.8 else 3.5 |
DocumentReader.truncate(t, n) |
Обрезка под лимит | (remainingTokens * charsPerToken(text)).toInt() |
DocumentReader.read(ctx, uri) |
Читает txt/md/json/html/csv (≤8 МБ) | val ALLOWED_EXTENSIONS = setOf("txt","md","markdown","json","htm","html","csv") |
stripHtml(html) |
Превращает HTML в текст | s.replace(Regex("]+>"), " ") |
SuggestionEngine.generate(...) |
Подсказки с дедупом и ретраями по температуре | val temperatures = listOf(0.7f, 0.85f, 1.0f) |
SuggestionEngine.normalize/markShown/clearHistory |
Дедуп показанных подсказок | shownSuggestions.add(normalize(it)) |
4.4 Движок инференса — data/llm/LlmEngine.kt
| Функция | Что делает | Код |
|---|---|---|
loadModelsFromPrefs() |
Загрузка списка моделей; дроп отсутствующих; миграция stale-mmproj | if (m.mmprojPath != null && !ModelCompatibility.isVisionArch(m.architecture)) m = m.copy(mmprojPath = null) |
saveModelsToPrefs() |
Сохранение списка | prefs.edit().putString(KEY_MODELS, arr.toString()).apply() |
loadSettingsFromPrefs() / saveSettings() |
Настройки | o.put("temperature", ...) |
importModel(uri) |
Копирует GGUF в files/models с прогрессом |
context.contentResolver.openInputStream(uri) |
downloadModelFromUrl(url) |
Скачивает с проверкой размера/валидности | if (totalBytes > 0 && totalRead != totalBytes) |
loadModel(model, mmproj) |
Грузит модель, чистит RAM-гейт, привязывает vision-проектор | val isVision = ModelCompatibility.isVisionArch(arch) |
| Эффективный ctx | Запрошенный ctx ограничивается максимумом модели и применяется при загрузке | val effectiveCtx = s.contextLength.coerceIn(512, modelMaxCtx) |
unloadModel() |
nativeFreeModel + сброс состояния |
LocalLlmNative.nativeFreeModel(handle) |
loadMmproj(path) |
Привязка проектора к загруженной модели | LocalLlmNative.nativeLoadMmproj(handle, path) |
setModelMmproj(path, mmproj) |
Персистентная привязка проектора | it.copy(mmprojPath = mmprojPath) |
autoFindMmproj(modelPath) |
Ищет sibling mmproj*.gguf |
n.endsWith(".gguf") && n.startsWith("mmproj") |
importMmproj(uri) / downloadMmprojFromUrl(url) |
Импорт/скачивание проектора | ModelCompatibility.isProjectorFileName(name) |
deleteModel(model) |
Удаление файла+записи | File(modelInfo.path).delete() |
generate(messages, onToken, docs) |
Текстовый чат: chat-template + язык + стрим | val templated = LocalLlmNative.nativeApplyChatTemplate(...) |
generateVision(messages, imagePath, ...) |
Vision: RGB → mtmd | LocalLlmNative.nativeGenerateStreamingVision(handle, prompt, rgb, w, h, ...) |
buildVisionPrompt() |
Выбор шаблона по архитектуре | if (arch.startsWith("gemma")) buildGemmaTurnPrompt(...) else buildChatmlPrompt(...) |
buildChatmlPrompt() / buildGemmaTurnPrompt() |
Ручные шаблоны Qwen/Gemma с медиа-маркером | sb.append(mediaMarker) |
buildManualPrompt() |
Fallback, если у модели нет chat-template | when { arch.startsWith("qwen") -> ... } |
cancelGeneration() |
Отмена | LocalLlmNative.nativeCancel(handle) |
resolveAnswerLanguageCode()/answerLanguageName() |
Язык ответа (auto → язык приложения) | if (code.isBlank() \|\| code == "auto") s.appLanguage else code |
languageReminder()/languageDirective() |
Инструкции языка для модели | "Отвечай только на русском языке…" |
generateSuggestions(...) |
JSON-подсказки + фильтр языка | .filter { suggestionMatchesLanguage(it, langCode) } |
buildSuggestionPrompt(...) |
Промпт подсказок на языке-цели (ru/de/en) | private data class SuggestionStrings(...) |
suggestionMatchesLanguage(t, code) |
Отсев иноязычных чипов | "ru" -> hasCyr |
parsePartial() / parseSuggestions() / unescape() |
Парсинг JSON-подсказок (терпимый) | Regex("\\[(.*?)\\]", DOT_MATCHES_ALL) |
formatBytes() |
Человекочитаемый размер | "%.1f GB".format(bytes / 1_073_741_824.0) |
4.5 GGUF-парсер и совместимость — data/llm/
| Функция | Что делает | Код |
|---|---|---|
GgufReader.readHeaders(ctx, uri) / readHeadersFromFile(file) |
Чтение заголовка GGUF | context.contentResolver.openInputStream(uri) |
readHeadersFromStream() |
Magic/version/tensors/KV | if (String(magic) != "GGUF") |
readGgufValue(buf, type) |
Разбор типов значений GGUF | 8 -> { val len = buffer.long ... } |
deriveArchitecture(meta) |
Fallback-архитектура по ключам | metadata.keys.any { it.startsWith("$arch.") } |
ModelCompatibility.isArchSupported(arch) |
Гейт поддерживаемых архитектур | arch.lowercase() in SUPPORTED_ARCHS |
isModelFileName(name) |
Отсев mmproj/lora/split | NON_MODEL_MARKERS.any { n.contains(it) } |
isProjectorFileName(name) |
Определяет mmproj | n.endsWith(".gguf") && n.contains("mmproj") |
isVisionArch(arch) |
Vision-архитектуры | arch.lowercase() in VISION_ARCHS |
4.6 Сеть — data/llm/HuggingFaceClient.kt
| Функция | Что делает | Код |
|---|---|---|
searchGgufModels(q, maxBytes, ...) |
Параллельный поиск репозиториев | Semaphore(MAX_CONCURRENT_REQUESTS) |
collectRepoFiles(repo, ...) |
Обходит дерево репо, фильтрует .gguf | $BASE/api/models/${encodePath(repoId)}/tree/main?recursive=true |
encodePath(path) |
URL-кодирование пути | URLEncoder.encode(it, "UTF-8").replace("+", "%20") |
httpGetJsonArray(url) |
HTTP GET → JSONArray | conn.setRequestProperty("User-Agent", USER_AGENT) |
4.7 Голос — data/voice/
| Функция | Что делает | Код |
|---|---|---|
SttManager.startListening(lang, onResult) |
Распознавание речи | Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) |
SttManager.stopListening()/destroy() |
Остановка/освобождение STT | speechRecognizer.destroy() |
onResults / onPartialResults |
Результаты распознавания | results?.getStringArrayList(RESULTS_RECOGNITION) |
TtsManager.initialize() |
Инициализация TTS | TextToSpeech(context) { status -> ... } |
TtsManager.setLanguage(lang) |
Локаль ru/de/en | "ru" -> Locale("ru", "RU") |
TtsManager.speak(text) |
Озвучка полного текста (сбрасывает очередь) | fun speak(text: String) = speakChunk(text, flush = true) |
TtsManager.speakChunk(text, flush) |
Потоковая озвучка: добавить фрагмент или сбросить очередь | val mode = if (flush) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD |
TtsManager.stop()/shutdown() |
Стоп/освобождение | tts?.shutdown() |
4.8 UI-экраны и компоненты
| Функция | Что делает | Код |
|---|---|---|
ChatScreen(...) |
Чат: шапка с ОЗУ, список сообщений, ввод, чипы | val ramFree by viewModel.ramFree.collectAsState() |
| Автоскролл чата | Во время стрима держит конец ответа, после готовности чипов прыгает к началу ответа | listState.scrollToItem(index, overflow) |
AnswerFooter(message) |
Под каждым ответом: copy в буфер, полный timestamp (дата/время/секунды), время генерации (мм:сс), скорость (t/s); статистика из ChatMessage.generationMs/tokenCount |
clipboard.setText(AnnotatedString(message.content)) |
MarkdownMessage(text) / CodeBlock |
Код-блоки с подписью языка, подсветкой синтаксиса и кнопкой copy у блока | FENCE.findAll(text) |
takePhoto() |
Съёмка через FileProvider |
FileProvider.getUriForFile(appContext, "${appContext.packageName}.fileprovider", file) |
sendCurrent() |
Отправка + скрытие клавиатуры | viewModel.sendMessage() |
MessageBubble(...) |
Пузырь сообщения (+картинка) | val isUser = message.role == "user" |
chipsContent/suggestionsArea |
Чипы подсказок (wrap/single-row) | AssistChip(onClick = { viewModel.onSuggestionClicked(suggestion) }) |
| меню «+» | Одна кнопка → меню документ/галерея/камера | DropdownMenu(expanded = showAttachMenu, ...) |
ModelPickerScreen(...) |
Список моделей, импорт/скачивание/поиск/проекторы | filePickerLauncher.launch(arrayOf("*/*")) |
ModelCard(...) |
Карточка модели + статус проектора | val projectorName = model.mmprojPath?.let { File(it).name } |
DownloadModelDialog / SearchModelsDialog / ProjectorDownloadDialog |
Диалоги URL/поиска/проектора | viewModel.downloadModelFromUrl(url, name) |
SettingsScreen(...) |
Настройки (темп., языки, тема, layout) | viewModel.updateSettings(currentSettings) |
| Поле ctx size | Числовой ввод 512..131072; эффективное значение ограничивается моделью | currentSettings.copy(contextLength = v.coerceIn(512, 131072)) |
| Диалог быстрых загрузок | Загрузка в один тап: Gemma 4 E2B + mmproj Q8 и Qwen2-VL 2B + mmproj Q8 | QUICK_DOWNLOADS + viewModel.downloadModelFromUrl(url, fileName) |
DropdownSelector/SliderSetting/SwitchSetting/SectionTitle |
Виджеты настроек | var expanded by remember { mutableStateOf(false) } |
DrawerContent(...) |
Ящик: новый чат, модели, настройки, история | Text(l("models"), style = MaterialTheme.typography.bodyLarge) |
MarkdownText(...) / parseMarkdown / parseInline |
Лёгкий markdown-рендер | val annotated = parseMarkdown(text, textColor) |
GGUFChatTemplateTheme(...) |
Темы (light/dark/green/orange/light_blue/yellow) | enum class AppTheme { LIGHT, DARK, ... } |
Localization.getString(key, lang) |
Перевод строк en/ru/de | val selectedLang = if (translations.containsKey(lang)) lang else "en" |
fmtRam(bytes) |
Формат ОЗУ в шапке | "%.1f GB".format(bytes / 1_073_741_824.0) |
4.9 Нативный слой — app/src/main/cpp/llama_jni.cpp
| JNI-функция | Что делает | Код |
|---|---|---|
nativeLoadModel |
Загрузка модели + контекста | session->model = llama_model_load_from_file(path.c_str(), mparams) |
nativeApplyChatTemplate |
Применение chat-template модели | llama_chat_apply_template(tmpl, messages.data(), messages.size(), true, ...) |
nativeGenerateStreaming |
Текстовая генерация стримом | llama_tokenize(...), llama_decode(s->ctx, batch), llama_sampler_sample(...) |
nativeLoadMmproj |
Загрузка mmproj (mtmd) | s->mtmd = mtmd_init_from_file(path.c_str(), s->model, mp) |
nativeHasMmproj |
Есть ли проектор | s->mtmd != nullptr |
nativeMediaMarker |
Маркер места картинки | mtmd_default_marker() |
nativeGenerateStreamingVision |
Vision-инференс | mtmd_tokenize(...), mtmd_helper_eval_chunks(...) |
nativeCancel |
Флаг отмены | s->cancel_flag.store(true, ...) |
nativeFreeModel |
Освобождение модели/проектора | mtmd_free(s->mtmd) |
nativeFreeMemory |
Возврат свободной нативной памяти ОС | mallopt(M_PURGE, 0) |
nativeIsModelLoaded |
Проверка загрузки | s->ready |
Сэмплер (make_sampler): llama_sampler_init_penalties(...) → llama_sampler_init_temp(temp) → llama_sampler_init_top_p(topP, 1) → llama_sampler_init_dist(LLAMA_DEFAULT_SEED).
4.10 Сборка — CMakeLists.txt / Gradle
| Файл | Что делает | Код |
|---|---|---|
app/src/main/cpp/CMakeLists.txt |
Подключает llama.cpp-new + mtmd | set(LLAMA_CPP_DIR ".../llama.cpp-new") |
add_library(llama_jni ...) |
Собирает JNI-библиотеку | target_link_libraries(llama_jni mtmd llama ggml android log) |
app/build.gradle.kts |
Конфиг Android/NDK/Release | arguments += "-DCMAKE_BUILD_TYPE=Release" |
settings.gradle.kts |
Модуль приложения | include(":app") |
5. TODO (обновлено — статус на текущий момент)
| # | Фича | Статус | Комментарий |
|---|---|---|---|
| 1 | Чат (Compose, потоковая генерация) | ✅ готово | ChatScreen + nativeGenerateStreaming |
| 2 | Импорт локальной модели | ✅ готово | importModel |
| 3 | Скачивание модели по URL | ✅ готово | downloadModelFromUrl + проверка целостности |
| 4 | Поиск моделей на Hugging Face | ✅ готово | HuggingFaceClient.searchGgufModels |
| 5 | Vision-проектор (mmproj): привязка/импорт/скачивание | ✅ готово | attachMmprojToModel, importMmproj |
| 6 | Авто-привязка mmproj только для vision-архитектур + чистка stale-привязок | ✅ готово | isVisionArch в loadModel/loadModelFromPrefs |
| 7 | Multimodal-инференс (mtmd) Qwen2-VL / Gemma 4 E2B | ✅ проверено | ответ «7391» на тестовой картинке |
| 8 | Chat-templates: native + ручной fallback (Qwen/Gemma/Llama/Phi) | ✅ готово | buildManualPrompt |
| 9 | Язык ответа модели = настройка (text + vision) | ✅ готово | languageReminder в обоих путях |
| 10 | Подсказки-чипы (стрим, дедуп, язык-цель, фильтр языка) | ✅ готово | buildSuggestionPrompt, suggestionMatchesLanguage |
| 11 | Чат с документами (txt/md/json/html/csv) | ✅ готово | DocumentReader |
| 12 | STT (голосовой ввод) | ✅ готово | SttManager |
| 13 | TTS (озвучка) | ✅ готово | TtsManager — потоковая, по фрагментам при появлении знаков препинания |
| 14 | Настройки (temp, top_p, max tokens, ctx, threads, языки, темы, layout) | ✅ готово | SettingsScreen |
| 15 | Локализация en/ru/de + 6 тем | ✅ готово | Localization, AppTheme |
| 16 | Монитор ОЗУ + кнопка «Освободить ОЗУ» | ✅ готово | refreshMemory, freeRam |
| 17 | Кнопка «+» с меню (документ/галерея/камера) | ✅ готово | DropdownMenu |
| 18 | Гейт совместимости архитектур | ✅ готово | ModelCompatibility |
| 19 | Парсер GGUF-заголовков | ✅ готово | GgufReader |
| 20 | Q8_0 mmproj из ggml-org на новом mtmd | ✅ проверено | работает |
| 21 | GPU/NNAPI-ускорение инференса | ⏳ не сделано | сейчас CPU-only, mtmd use_gpu=false |
| 22 | UI для sampler (top_k, repeat/presence, seed) | ⏳ не сделано | в UI только temperature/top_p |
| 23 | Экспорт/импорт чатов | ⏳ не сделано | сессии только в files/sessions |
| 24 | RAG/эмбеддинги для документов | ⏳ не сделано | сейчас простая обрезка по бюджету |
| 25 | LoRA/adapter | ⏳ не сделано | маркеры есть, загрузки нет |
| 26 | Split-GGUF (-00001-of-0000N) |
⏳ не сделано | части отклоняются |
| 27 | Точный подсчёт токенов через нативную лексику | ⏳ не сделано | эвристика DocumentReader |
| 28 | Метаданные модели в UI | ⏳ не сделано | GgufInfo.metadata собирается, экрана нет |
| 29 | Vision для gemma3n/llama4/minicpm | ⏳ не проверено | в списке VISION_ARCHS, но не тестировалось |
| 30 | Офлайн-STT (whisper) вместо системного | ⏳ не сделано | используется SpeechRecognizer |
| 31 | Модель не грузится дважды / прогрев | ⏳ бэклог | freeRam выгружает модель |
| 32 | Unit/UI-тесты и CI | ⏳ не сделано | тестов нет |
| 33 | Потоковый TTS (озвучка фрагментов по мере генерации) | ✅ проверено | 8 запросов синтеза за один ответ |
| 34 | Умный автоскролл (за концом, к началу после чипов) | ✅ готово | LaunchedEffect в ChatScreen |
| 35 | Футер ответа: copy, полный timestamp, время генерации, скорость токенов | ✅ проверено | «2026-10-05 06:25:39 · 0:33 · 1.3 t/s» |
| 36 | Код-блоки markdown с подсветкой синтаксиса и copy у блока | ✅ готово | MarkdownMessage.kt |
| 37 | Настройка ctx size для всех моделей (512..131072, ограничение по максимуму модели) | ✅ проверено | coerceIn(512, modelMaxCtx), в шапке «· 8192 ctx» |
| 38 | Загрузка пресетов в один тап (Gemma 4 E2B, Qwen2-VL 2B + mmproj Q8) | ✅ собрано | проверка на устройстве ожидает |
Ссылки
- GitHub: https://github.com/sashok53011/gguf-chat-android
- git clone: https://github.com/sashok53011/gguf-chat-android.git
