Ollama server настройка

Устанавливаем ollama server

brew install ollama
Ubuntu

Запускаем сервер. Если требуется, чтобы сервер был доступен не только на локальной машине, но и на эмуляторе или 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

ollama AI Agent Android Studio

ollama AI 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 (локальный)

startKoin (глобальный)

KoinApplication (локальный)

koinInject<T>()

✅

✅

koinViewModel<T>()

✅

✅

by inject()

✅

❌

by viewModel()

✅

❌

get<T>() (top-level)

✅

❌

getKoin()

✅

❌

KoinComponent

✅

❌

androidContext()

✅

❌ (нужен startKoin)

Общая точка входа в приложение:

@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

Чашка кофе

Чашка кофе

Пишите!