Ollama server настройка
Устанавливаем ollama server
brew install ollama
Ubuntu
#curl -fsSL https://ollama.com/install.sh | sh
Запускаем сервер. Если требуется, чтобы сервер был доступен не только на локальной машине, но и на эмуляторе или c любого телефона в сети надо добавить: OLLAMA_HOST=0.0.0.0 тогда к серверу можно будет обращаться через IP
OLLAMA_HOST=0.0.0.0:11434 ollama serve
Загружаем модели. Например gemma4:12b
ollama pull gemma4:12b
Проверяем что сервер запущен и модель доступна. Для MacBook на чипе с ARM архитектурой cкачиваем mlx модели.gemma4:12b-mlx.PROCESSOR 100% GPU означает, что вся модель загружена в память видеокарты (GPU), и все вычисления (генерация токенов) идут на GPU, а не на CPU.
ollama show gemma4:12b-mlx
Запущенная модель:
#ollama ps
NAME ID SIZE PROCESSOR CONTEXT RUNNER UNTIL
gemma4:e4b-mlx 5a08735df03c 9.4 GB 100% GPU 4096 mlx 4 minutes from now
Список всех установленных моделей:
#ollama list
NAME ID SIZE MODIFIED nomic-embed-text:latest 0a109f422b47 274 MB 24 hours ago qwen3.5:9b-mlx 203e30078279 8.9 GB 2 days ago gemma4:12b-mlx ded7a2735003 7.7 GB 2 days ago gemma4:e4b-mlx 5a08735df03c 9.5 GB 2 days ago
Проверить реальный запрос из локальной сети
#curl -s http://127.0.0.1:11434/api/generate -d '{ "model": "gemma4:e4b-mlx", "prompt": "Привет", "stream": false }'
Привет
{ "model": "gemma4:e4b-mlx", "created_at": "2026-11-06T18:05:23.518505Z", "response": "Привет! 😊 Чем я могу помочь?", "done": true, "done_reason": "stop", "context": [2,105,9731,...], "total_duration": 269059958, "load_duration": 638667, "prompt_eval_count": 18, "prompt_eval_cached_count": 17, "prompt_eval_duration": 9167, "eval_count": 8, "eval_duration": 266089083 }
Или так:
ollama run gemma4:12b "Объясни, что такое Kotlin"
Android Studio Agent
Можно пробовать подключать Агента в Android Studio

Android/iOS client ollama
Все настроено. Переходим к мобильному клиенту. Создаем новый проект на kmp с общим UI.

В качестве Di выбираем Koin. UI общий для двух платформ. Делаем максимально просто. Koin так же поддерживает ViewModel для kmp из коробки. Для запросов в сеть используется Ktor.
В KMP можно использовать глобальный startKoin, но для Compose Multiplatform удобнее koinApplication. Он создает локальный контейнер прямо внутри Composable-функции App(). Это спасает от конфликтов при использовании @Preview в Android Studio, гарантирует правильный жизненный цикл и позволяет обойтись без правок Swift-кода для iOS.
Различия koinApplication от startKoin:
koinApplication возвращает контейнер (и его scope) только вам; startKoin возвращает его ещё и в глобальный GlobalContext, делая scope видимыми и живущими на уровне всего процесса. Всё остальное — следствия этого одного различия
scope «возвращается»
// koinApplication — scope «возвращается» только вам val koinApp = koinApplication { modules(appModule) } val sessionScope = koinApp.koin.createScope("session") // никто, кроме этого кода, sessionScope не видит sessionScope.close() koinApp.close() // всё умерло // startKoin — scope «возвращается» в GlobalContext startKoin { modules(appModule) } val sessionScope = GlobalContext.get().createScope("session") // теперь scope виден всему процессу через GlobalContext stopKoin() // всё умерло
Доступность API: startKoin (глобальный) vs composable KoinApplication (локальный)
|
| |
|---|---|---|
| ✅ | ✅ |
| ✅ | ✅ |
| ✅ | ❌ |
| ✅ | ❌ |
| ✅ | ❌ |
| ✅ | ❌ |
| ✅ | ❌ |
| ✅ | ❌ (нужен |
Общая точка входа в приложение:
@Composable @Preview fun App( appDeclaration: KoinAppDeclaration = {} ) { KoinApplication(configuration = koinConfiguration { modules( viewModelModule, commonModule ) }) { MaterialTheme { RootScreen() } } }
Для примера достаточно. Еще один плюс — не надо править Swift-код (например, ContentView.swift), который сгенерировала Android Studio. Мы пишем в Android Studio и оттуда же запускаем на Android и iPhone.
Однако для сборки под iOS всё равно понадобится Xcode и macOS. KMP освобождает нас от написания UI на Swift, но не отменяет инструменты Apple. Кроме того, если понадобится настроить Info.plist (например, добавить NSAllowsArbitraryLoads для HTTP) или подключить нативные SDK, заходить в Xcode всё же придется.
Стоит отметить, что у KoinApplication могут быть свои подводные камни. В частности, я пока не разобрался, как правильно разбивать на модули большое приложение в такой архитектуре. Также открытым остается вопрос: достаточно ли использовать только koinInject() для Compose, или где-то всё же обязательно понадобится классический inject(). Буду рад, если кто-то поделится опытом в комментариях.
Подход вызывать initKoin из iOS и Android:
initKoin()
fun MainViewController() = ComposeUIViewController( configure = { initKoin() } ) { App() }
// android:name=".MyApp" class MyApp : Application(), KoinComponent { override fun onCreate() { super.onCreate() initKoin { androidLogger() androidContext(this@MyApp) } } }
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin { appDeclaration() modules( databaseModule, networkModule, repositoryModule, viewmodelModule ) }
Поскольку сервер у нас на http вместо https, не забываем usesCleartextTraffic. Кстати, эмулятор iPhone разрешил доступ без изменений. Помнится я добавлял NSAllowsArbitraryLoads.
usesCleartextTraffic
android:usesCleartextTraffic="true"
usesCleartextTraffic — это атрибут в AndroidManifest.xml (начиная с API 23), который определяет, разрешает ли приложение незашифрованный HTTP-трафик (cleartext)
iPhone
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>
Далее добавил Navigation3. Хорошая статья Navigation3 + Koin в Compose Multiplatform. Понравились комментарии в которых обсуждалось, стоит ли хранить последнее состояние экранов.
SavedStateConfiguration
val config = remember { SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { subclassesOfSealed<Route>() } } } } val backStack = rememberNavBackStack(config, HomeRoute) // Текущий маршрут (последний в стеке) — для title / bottom bar val currentRoute: NavKey? = backStack.lastOrNull()
encodeDefaults
Стоит упомянуть о двух режимах ответа Ollama - потоковый (стриминг) и целый
В Ollama это управляется полем stream в запросе к /api/generate, /api/chat. Нельзя ставить stream = false по умолчанию! Сериализатор затрет это поле если вы зададите ему false. Ollama по умолчанию отдаст вам поток, что приведет к неожиданному результату. Это можно поправить настройками сериализатора, но я потратил время что бы понять, куда делся stream из запроса. View->Tool Windows->App Inspector->Network Inspector в помощь.
OllamaRequest(model = settings.getCurrentModel().id, prompt = prompt, stream = false)
Модель запроса:
@Serializable data class OllamaRequest( val model: String, val prompt: String, val stream: Boolean // не присваивать false в этом месте )
Можно поправить настройками сериализатора:
json(Json { ignoreUnknownKeys = true isLenient = true encodeDefaults = true // ← гарантирует, что stream попадёт в JSON })
«По умолчанию Kotlinx.Serialization не сериализует поля, значения которых совпадают с дефолтными (в данном случае
false). Ollama, не найдя в JSON явного указанияstream: false, включает потоковый режим по умолчанию. НастройкаencodeDefaults = trueспасает ситуацию».
Если обращаться к чату, то требуется задать роль. В /api/chat роль обязательна для каждого сообщения. В /api/generate ролей нет вообще — там просто prompt
Магия тут
package com.kmp.ollamechat.repository import com.example.chatkmpdontshareui.repository.model.OllamaRequest import com.example.chatkmpdontshareui.repository.model.OllamaResponse import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType class OllamaClient( private val settings: Settings, private val client: HttpClient ) { suspend fun sendPrompt(serverUrl: String, prompt: String): Result<String> { return try { val response: OllamaResponse = client.post("$serverUrl/api/generate") { contentType(ContentType.Application.Json) // Вставляем объект запроса. Ktor сам его сериализует в JSON. setBody( OllamaRequest( model = settings.getCurrentModel().id, prompt = prompt, stream = false ) ) // Если используете Ollama Cloud, раскомментируйте: // header(HttpHeaders.Authorization, "Bearer ВАШ_КЛЮЧ") }.body() // Получаем и десериализуем ответ Result.success(response.response ?: "Unknown Error") } catch (e: Exception) { Result.failure(e) } } }
Ответы потока
Привет
{"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.399462Z","message":{"role":"assistant","content":"Привет"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.411176Z","message":{"role":"assistant","content":"!"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.474175Z","message":{"role":"assistant","content":" Чем"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.532257Z","message":{"role":"assistant","content":" могу"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.596336Z","message":{"role":"assistant","content":" помочь"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.596359Z","message":{"role":"assistant","content":"?"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.596427Z","message":{"role":"assistant","content":" 😊"},"done":false} {"model":"gemma4:e4b-mlx","created_at":"2026-11-09T03:41:29.596487Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":358141959,"load_duration":1705667,"prompt_eval_count":18,"prompt_eval_cached_count":17,"prompt_eval_duration":236000,"eval_count":7,"eval_duration":353180958}
Магия потока
package com.kmp.ollamechat.client import com.kmp.ollamechat.chat.ChatRequest import com.kmp.ollamechat.chat.OllamaChatChunk import com.kmp.ollamechat.chat.OllamaChatRequest import com.kmp.ollamechat.repository.Settings import com.kmp.ollamechat.repository.model.StreamOllamaEvent import io.ktor.client.HttpClient import io.ktor.client.plugins.ResponseException import io.ktor.client.request.preparePost import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsChannel import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.utils.io.readLine import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.serialization.json.Json /** * streamChat() * Стриминговый запрос к /api/chat. * Возвращает Flow<OllamaEvent>: токены по мере генерации + Done/Error. * Flow отменяемый — отмена корутины закрывает соединение и останавливает генерацию. */ class StreamOllamaClient( private val settings: Settings, private val client: HttpClient, private val json: Json ) { fun streamChat( serverUrl: String, messages: List<ChatRequest>, modelId: String, ): Flow<StreamOllamaEvent> = flow { // Переменная для отслеживания финального статуса: SUCCESS, ERROR или PREMATURE_CLOSE var finalEvent: StreamOllamaEvent? = null try { client.preparePost("$serverUrl/api/chat") { contentType(ContentType.Application.Json) setBody( OllamaChatRequest( model = modelId, messages = messages, keepAlive = "10m", stream = true ) ) }.execute { response -> val channel = response.bodyAsChannel() while (!channel.isClosedForRead) { val line = channel.readLine() ?: break if (line.isBlank()) continue val chunk = try { json.decodeFromString<OllamaChatChunk>(line) } catch (e: Exception) { finalEvent = StreamOllamaEvent.Error("Не удалось распарсить чанк: $line", e) break // Просто выходим из цикла, выполнение пойдет ниже } if (chunk.error != null) { finalEvent = StreamOllamaEvent.Error(chunk.error) break } val msg = chunk.message msg?.thinking?.takeIf { it.isNotEmpty() }?.let { emit(StreamOllamaEvent.Thinking(it)) } msg?.content?.takeIf { it.isNotEmpty() }?.let { emit(StreamOllamaEvent.Token(it)) } if (chunk.done) { finalEvent = StreamOllamaEvent.Done(chunk.evalCount) break } } // Если цикл прервался, но finalEvent не выставлен (например, сервер закрыл канал раньше времени) val eventToSend = finalEvent ?: StreamOllamaEvent.Done(null) emit(eventToSend) } } catch (e: ResponseException) { emit(StreamOllamaEvent.Error("HTTP ${e.response.status.value}", e)) } catch (e: Exception) { emit(StreamOllamaEvent.Error(e.message ?: "Unknown error", e)) } } }
На экране это выглядит так:

Ответ в потоке сразу не заработал. Довольно быстро выяснил, что данные отдаются целиком а не кусками. Все обновляется в самом конце ответа. Решал дооолго. Несколько часов. Сначала думал, что OkHttp буферизирует данные. Вспомнил expect/actual, разнес по платформам. Не помогло.
Оказалось, использовал не тот метод: вместо post() надо preparePost(). Хотя у post() под капотом тоже есть execute().
После замены на preparePost() печать пошла.
Помимо основного ответа, обрабатывается также ответ Thinking.
Посмотреть код на git:
https://github.com/app-z/OllameChatKmp
Требуется немного довести до ума UI и добавить сохранение истории чатов.
Осталось скормить модели документацию компании — и идеальный ИИ-чат-бот поддержки готов. Ну, почти...
Также я написал клиента для облачных моделей cloud.ru (с загрузкой картинок и аудио). Правда, это уже совсем другая история. Главное, что стоит знать: токены в облаке сгорают очень быстро. Но и скорость впечатляет — на задачу по переименованию проекта модель потратила всего 5 минут и около 50 рублей. Конечная цена и скорость напрямую зависят от выбранной модели.
Если статья была полезной — можете угостить меня чашкой кофе. Автор будет пить его, пока пишет продолжение про cloud.ru

Пишите!

