agent: связь с сервером + UI-экраны + ASR Qwen3 + settings
This commit is contained in:
+27
-1
@@ -41,8 +41,34 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Ядро агента (Nexus caffeine). Тянет :proto, ktor-client-core и т.д.
|
// Ядро агента (Nexus caffeine). Тянет :proto, :journal-api (транзитивно в v9), ktor-client-core и т.д.
|
||||||
implementation(libs.agentik.client)
|
implementation(libs.agentik.client)
|
||||||
|
// journal-api объявлен отдельно, чтобы IDE/компилятор видел MessageRecord в AgentMappers
|
||||||
|
// (транзитивная зависимость в v9, но прямой импорт делаем явным).
|
||||||
|
implementation(libs.agentik.journal.api)
|
||||||
|
// InMemoryJournalStore — RAM-кэш истории для HttpChatSession. Персистентный
|
||||||
|
// кэш (SQLite) подключим отдельной задачей.
|
||||||
|
implementation(libs.agentik.journal.inmemory)
|
||||||
|
// Persistent journal-кеш через ksqlite. Подменяет InMemoryJournalStore —
|
||||||
|
// история сообщений переживает kill приложения. Начиная с journal-ksqlite:12
|
||||||
|
// конструктор `KsqliteJournalStore(path)` публичный — рефлексия и
|
||||||
|
// явная зависимость `pw.binom.db:ksqlite` больше не нужны.
|
||||||
|
implementation(libs.agentik.journal.ksqlite)
|
||||||
|
// ksqlite опубликован в Maven Central (0.1.2). Наш ConversationMetaRepository
|
||||||
|
// работает с SQLiteConnection напрямую — общая абстракция
|
||||||
|
// :journal-ksqlite (MutableJournalStore) про метаданные диалогов не знает.
|
||||||
|
// Декларируем явно, чтобы транзитивная compile-api за journal-ksqlite не
|
||||||
|
// подсунула несовместимую версию.
|
||||||
|
implementation(libs.ksqlite)
|
||||||
|
|
||||||
|
// ASR-стек: модель Qwen3-ASR на устройстве (auto-download через QwenModelProvider).
|
||||||
|
implementation(libs.asr.qwen3)
|
||||||
|
implementation(libs.mic.api)
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
|
||||||
|
// Markdown-парсер (ru.otpbank.ai:markdown) — нужно для рендеринга
|
||||||
|
// сообщений ассистента. Хостится в нашем Nexus (см. settings.gradle.kts).
|
||||||
|
implementation(libs.ru.otpbank.markdown)
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": "06",
|
||||||
|
"source": "http://static.binom.pw/models/qwen3_06/",
|
||||||
|
"totalBytes": 987015347,
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "conv_frontend.onnx",
|
||||||
|
"size": 44148281,
|
||||||
|
"sha256": "d22dc4423e0940e49884e903d2ea2f7e5567c14fc1aed97e4e26d6b8f208ef9e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "encoder.int8.onnx",
|
||||||
|
"size": 182491662,
|
||||||
|
"sha256": "60748d3e6744a57c9c91e1b17424a6c2990567e8adceb0783940c03ed98fa9d9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "decoder.int8.onnx",
|
||||||
|
"size": 755914231,
|
||||||
|
"sha256": "4f6885be5959ae26af3089d38ee7972c5fafbeeb1cf8d5e76eab6d8b61ca5771"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tokenizer/merges.txt",
|
||||||
|
"size": 1671853,
|
||||||
|
"sha256": "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tokenizer/tokenizer_config.json",
|
||||||
|
"size": 12487,
|
||||||
|
"sha256": "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "tokenizer/vocab.json",
|
||||||
|
"size": 2776833,
|
||||||
|
"sha256": "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,9 +1,77 @@
|
|||||||
package pw.binom.agentik.android
|
package pw.binom.agentik.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.real.HttpAgentConnection
|
||||||
|
import pw.binom.agentik.android.asr.QwenDownloadWorker
|
||||||
|
import pw.binom.agentik.android.settings.SettingsRepository
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Точка входа приложения. Пока пустая: сюда будут подниматься настройки
|
* Точка входа приложения. Поднимает фоновые сервисы:
|
||||||
* (адрес агента и токен) и хранилище.
|
* - периодический [QwenDownloadWorker]: раз в 6 часов проверяет/докачивает голосовую модель
|
||||||
|
* Qwen3-ASR по Wi-Fi (UNMETERED). Зарегистрирован через [ExistingPeriodicWorkPolicy.KEEP],
|
||||||
|
* чтобы перезапуск приложения не сбрасывал уже запланированный запуск и не дёргал воркер
|
||||||
|
* лишний раз.
|
||||||
|
* - singleton-фабрика [agentConnection] — единая точка подключения к агенту,
|
||||||
|
* живёт до вызова `close()` (никогда в этой PR — приложение короткоживущее).
|
||||||
|
* В фазах тестирования можно подменить на stub: `(application as AgentikApp).agentConnection = StubAgentConnection(...)`.
|
||||||
|
* - [settingsRepository] — JSON-персистентность [pw.binom.agentik.android.settings.Settings]
|
||||||
|
* в `filesDir/settings.json`. Передаётся в UI через [MainActivity].
|
||||||
*/
|
*/
|
||||||
class AgentikApp : Application()
|
class AgentikApp : Application() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton-фабрика агента-фасада. Создаётся лениво при первом обращении,
|
||||||
|
* живёт до вызова `close()` (никогда в этой PR — приложение короткоживущее).
|
||||||
|
*
|
||||||
|
* NB: используем `lateinit` (а не `by Delegates.notNull()`), чтобы
|
||||||
|
* работал `::agentConnection.isInitialized` — `Delegates.notNull()`
|
||||||
|
* делегат не поддерживает эту проверку.
|
||||||
|
*/
|
||||||
|
lateinit var agentConnection: AgentConnection
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton-репозиторий для JSON-персистентности настроек в `filesDir/settings.json`.
|
||||||
|
* Создаётся лениво при первом обращении (после `onCreate()`) — `Application`
|
||||||
|
* сам передаёт себя как `Context`.
|
||||||
|
*/
|
||||||
|
val settingsRepository: SettingsRepository by lazy { SettingsRepository(this) }
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
if (!::agentConnection.isInitialized) {
|
||||||
|
agentConnection = HttpAgentConnection(context = this)
|
||||||
|
}
|
||||||
|
scheduleQwenDownload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleQwenDownload() {
|
||||||
|
val request = PeriodicWorkRequestBuilder<QwenDownloadWorker>(6, TimeUnit.HOURS)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
// Wi-Fi only — мобильная сеть не качает 940 МБ.
|
||||||
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||||
|
UNIQUE_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val UNIQUE_WORK_NAME = "qwen-model-download"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,42 +4,21 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Column
|
import pw.binom.agentik.android.ui.screen.AppRoot
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import pw.binom.agentik.android.ui.theme.AgentikTheme
|
import pw.binom.agentik.android.ui.theme.AgentikTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
val app = application as AgentikApp
|
||||||
setContent {
|
setContent {
|
||||||
AgentikTheme {
|
AgentikTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
AppRoot(
|
||||||
StartScreen(Modifier.padding(innerPadding))
|
connection = app.agentConnection,
|
||||||
}
|
settingsRepository = app.settingsRepository,
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StartScreen(modifier: Modifier = Modifier) {
|
|
||||||
Column(modifier = modifier.padding(24.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Agentik",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Клиент готов к работе. Дальше — список диалогов и чат.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package pw.binom.agentik.android.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фасад над `pw.binom.agentik.proto.Agent`. Скрывает proto-типы от UI.
|
||||||
|
*
|
||||||
|
* Реализации: [stub.StubAgentConnection] (текущая фаза) и HTTP-импл на
|
||||||
|
* `AgentikAgent(...)` (следующая фаза). Сам контракт lifecycle-а:
|
||||||
|
* один [AgentConnection] на приложение, на каждый диалог — отдельный
|
||||||
|
* [ChatSession], живущий до явного [close].
|
||||||
|
*/
|
||||||
|
interface AgentConnection : AutoCloseable {
|
||||||
|
|
||||||
|
/** Текущее состояние подключения к агенту. */
|
||||||
|
val state: StateFlow<ConnectionState>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view на список диалогов. В v12 клиентский [AgentikAgent]
|
||||||
|
* оборачивает [pw.binom.agentik.proto.Agent.conversationStore] в локальный
|
||||||
|
* in-memory кэш, синхронизируемый через outbox-события — UI через
|
||||||
|
* [ConversationStore.listFlow] получает auto-refresh без ручного refetch.
|
||||||
|
*
|
||||||
|
* Доступ к [conversationStore] валиден только после успешного [configure].
|
||||||
|
*/
|
||||||
|
val conversationStore: ConversationStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключиться/переподключиться к агенту. Бросает на ошибке конфигурации
|
||||||
|
* (например, недоступный URL). На стабе — no-op после валидации URL.
|
||||||
|
*/
|
||||||
|
suspend fun configure(id: String, name: String, baseUrl: String, token: String?)
|
||||||
|
|
||||||
|
/** Список диалогов из локального кэша. */
|
||||||
|
suspend fun listConversations(): List<ConversationSummary>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть существующий диалог (если [convId] != null) или создать новый.
|
||||||
|
* Возвращённый [ChatSession] живёт, пока не закрыт через `close()`.
|
||||||
|
*/
|
||||||
|
fun openConversation(convId: String? = null): ChatSession
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface ConnectionState {
|
||||||
|
|
||||||
|
/** Не сконфигурирован. */
|
||||||
|
data object Disconnected : ConnectionState
|
||||||
|
|
||||||
|
/** Подключаемся / проверяем связь. */
|
||||||
|
data class Connecting(val agentId: String) : ConnectionState
|
||||||
|
|
||||||
|
/** Связь есть, агент отвечает. [agentName] — как его зовёт сервер/клиент. */
|
||||||
|
data class Connected(val agentId: String, val agentName: String) : ConnectionState
|
||||||
|
|
||||||
|
/** Связи нет / конфиг плохой. */
|
||||||
|
data class Failed(val reason: String) : ConnectionState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Локально-кэшированная «карточка» диалога. Превью последнего сообщения и
|
||||||
|
* счётчик непрочитанных берутся из локального кэша, не из протокола —
|
||||||
|
* десктопные требования R24/R26 (нет данных — нет цифры).
|
||||||
|
*
|
||||||
|
* [unreadCount] теперь **всегда известен** (v15+):
|
||||||
|
* - диалог ни разу не открывали (`last_seen_message_at` отсутствует) →
|
||||||
|
* `journal.count(convId)` (все сообщения — непрочитанные);
|
||||||
|
* - открывали → `journal.count(convId, lastSeen)` (только новые после lastSeen).
|
||||||
|
*
|
||||||
|
* `0` — открывали, но новых сообщений нет. UI бейдж скрывается.
|
||||||
|
*/
|
||||||
|
data class ConversationSummary(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val updatedAt: kotlin.time.Instant,
|
||||||
|
val lastMessagePreview: String?,
|
||||||
|
val unreadCount: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package pw.binom.agentik.android.agent
|
||||||
|
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг `outbox.Event` → `UiEvent` и `journal.MessageRecord` → `UiMessage`.
|
||||||
|
*
|
||||||
|
* Сигнатуры сверялись по `proto-jvm-9-sources.jar`, `outbox-api-jvm-9-sources.jar`
|
||||||
|
* и `journal-api-jvm-9-sources.jar`.
|
||||||
|
*
|
||||||
|
* **NB**: `Event` в v9 живёт в `pw.binom.agentik.outbox.*`. В `pw.binom.agentik.proto.*`
|
||||||
|
* есть `typealias Event = pw.binom.agentik.outbox.Event`, но Kotlin не
|
||||||
|
* резолвит nested-классы через typealias при `is`-проверках, поэтому импортируем
|
||||||
|
* `outbox.Event` напрямую.
|
||||||
|
*
|
||||||
|
* - `Event` — расширяемый протокольный sealed-тип; `when` НЕ exhaustive
|
||||||
|
* (`else -> null`): клиент должен пережить добавление новых вариантов на
|
||||||
|
* сервере без падений маппинга.
|
||||||
|
* - `MessageRecord` — стабильный (наш кэш); `when` exhaustive, без `else`.
|
||||||
|
* Если journal-api получит новый вариант, маппер упадёт на этапе компиляции —
|
||||||
|
* это сигнал разработчику добавить ветку.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг `outbox.Event` → `UiEvent`. Возвращает `null` для вариантов, которые
|
||||||
|
* UI сейчас игнорирует (`StartResponse`/`StartReasoning`/`Interrupted`).
|
||||||
|
*
|
||||||
|
* **NB по сигнатурам v9** (сверялся по `outbox-api-jvm-9-sources.jar`):
|
||||||
|
* - `Event.AppendText(date, body)` → [UiEvent.AppendText]
|
||||||
|
* - `Event.AppendImage(date, body, mime)` → [UiEvent.AppendImage] (mime игнорируем на UI-слое)
|
||||||
|
* - `Event.ToolCall(date, id, title, toolName, toolArgs)` → [UiEvent.ToolCall]
|
||||||
|
* - `Event.ToolResult(date, id, result)` → [UiEvent.ToolResult]. В v9
|
||||||
|
* `Event.ToolResult` НЕ несёт `toolName` — UI связывает по `id`.
|
||||||
|
* - `Event.Error(date, message, code)` → [UiEvent.Error]
|
||||||
|
*/
|
||||||
|
@Suppress("REDUNDANT_ELSE_IN_WHEN") // `else` оставлен намеренно: когда протокол добавит вариант, наш маппер не сломается.
|
||||||
|
fun Event.toUiEvent(): UiEvent? = when (this) {
|
||||||
|
is Event.StartResponse -> null // UI на это не реагирует
|
||||||
|
is Event.StartReasoning -> null // рассуждения не рендерим (пока)
|
||||||
|
is Event.AppendText -> UiEvent.AppendText(body)
|
||||||
|
is Event.AppendImage -> UiEvent.AppendImage(body)
|
||||||
|
is Event.ToolCall -> UiEvent.ToolCall(id, toolName, title, toolArgs)
|
||||||
|
is Event.ToolResult -> UiEvent.ToolResult(toolCallId = toolCallId, toolName = toolName, result = result)
|
||||||
|
is Event.End -> UiEvent.End
|
||||||
|
is Event.Interrupted -> null // ход «закрывается» без сообщения
|
||||||
|
is Event.Error -> UiEvent.Error(message, code)
|
||||||
|
else -> null // неизвестный вариант — пропустить
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Маппинг `pw.binom.agentik.journal.MessageRecord` → `UiMessage`.
|
||||||
|
*
|
||||||
|
* `MessageRecord` — sealed, стабильный: `when` exhaustive без `else`.
|
||||||
|
* Если journal-api получит новый вариант — компилятор скажет добавить ветку.
|
||||||
|
*
|
||||||
|
* `interrupted` у [UiMessage.Assistant] всегда `false`: interrupted живёт
|
||||||
|
* только в live-`Event.Interrupted`, в журнал не пишется.
|
||||||
|
*/
|
||||||
|
fun MessageRecord.toUiMessage(): UiMessage = when (this) {
|
||||||
|
is MessageRecord.UserMessage -> UiMessage.User(id, createdAt, content.toPlainText())
|
||||||
|
is MessageRecord.AssistantMessage -> UiMessage.Assistant(
|
||||||
|
id, createdAt,
|
||||||
|
content.toPlainText(),
|
||||||
|
interrupted = false,
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolCall -> UiMessage.ToolCall(
|
||||||
|
id, createdAt,
|
||||||
|
toolName,
|
||||||
|
title = toolTitle,
|
||||||
|
args = toolArgsJson,
|
||||||
|
)
|
||||||
|
is MessageRecord.ToolResult -> UiMessage.ToolResult(
|
||||||
|
id, createdAt,
|
||||||
|
toolCallId,
|
||||||
|
toolName = toolName,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
is MessageRecord.Error -> UiMessage.Error(
|
||||||
|
id, createdAt,
|
||||||
|
message,
|
||||||
|
code = code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Склеивает текстовые куски [pw.binom.agentik.journal.Content.Text] в
|
||||||
|
* одну строку — для плоского [UiMessage.User.text]/[UiMessage.Assistant.text].
|
||||||
|
* Изображения и прочие не-текстовые content-ы игнорируются (для показа
|
||||||
|
* картинок в истории будет отдельный UI-элемент).
|
||||||
|
*/
|
||||||
|
private fun List<pw.binom.agentik.journal.Content>.toPlainText(): String =
|
||||||
|
filterIsInstance<pw.binom.agentik.journal.Content.Text>()
|
||||||
|
.joinToString(separator = "") { it.body }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package pw.binom.agentik.android.agent
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сессия одного диалога. Хранит локальный кэш истории (для backfill),
|
||||||
|
* подписку на live-события текущего хода и снимок метаданных диалога.
|
||||||
|
*
|
||||||
|
* Создаётся через [AgentConnection.openConversation], закрывается
|
||||||
|
* владельцем (или через [AgentConnection.close]).
|
||||||
|
*/
|
||||||
|
interface ChatSession : AutoCloseable {
|
||||||
|
|
||||||
|
val conversationId: String
|
||||||
|
|
||||||
|
/** Снимок диалога (метаданные). Обновляется на rename / send. */
|
||||||
|
val snapshot: StateFlow<ConversationSnapshot?>
|
||||||
|
|
||||||
|
/** История из локального кэша. Не содержит ещё не «закрытые» ходы (нет `End`). */
|
||||||
|
val messages: StateFlow<List<UiMessage>>
|
||||||
|
|
||||||
|
/** Live-события текущего хода. Используется для streaming-отрисовки. */
|
||||||
|
val liveEvents: Flow<UiEvent>
|
||||||
|
|
||||||
|
/** Отправить текстовое сообщение. */
|
||||||
|
suspend fun send(text: String)
|
||||||
|
|
||||||
|
/** Прервать текущий ход агента (R11). */
|
||||||
|
suspend fun interrupt()
|
||||||
|
|
||||||
|
/** Переименовать диалог. */
|
||||||
|
suspend fun rename(newTitle: String)
|
||||||
|
|
||||||
|
/** Явно докачать новые записи с сервера (после End или ручного refresh). */
|
||||||
|
suspend fun refresh()
|
||||||
|
|
||||||
|
override fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метаданные диалога. Зеркалит `pw.binom.agentik.proto.ConversationSnapshot`,
|
||||||
|
* но без зависимости на proto-тип. Имена полей соответствуют REST DTO
|
||||||
|
* `:client` (`isSupportImageInput`/`isSupportImageOutput`/`isTemporal`).
|
||||||
|
*/
|
||||||
|
data class ConversationSnapshot(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
val supportsImageInput: Boolean,
|
||||||
|
val supportsImageOutput: Boolean,
|
||||||
|
val isTemporal: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package pw.binom.agentik.android.agent
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-проекция записи в истории диалога. Полностью отвязана от протокольных
|
||||||
|
* типов `:client`/`proto`/`journal` — маппинг делается в [AgentMappers].
|
||||||
|
*
|
||||||
|
* Все варианты несут стабильный [id] и [timestamp] для ключей Compose-списков
|
||||||
|
* и виртуализации.
|
||||||
|
*/
|
||||||
|
sealed interface UiMessage {
|
||||||
|
val id: String
|
||||||
|
val timestamp: Instant
|
||||||
|
|
||||||
|
data class User(
|
||||||
|
override val id: String,
|
||||||
|
override val timestamp: Instant,
|
||||||
|
val text: String,
|
||||||
|
) : UiMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ассистентский ход. [interrupted] на исторических записях **всегда**
|
||||||
|
* `false`: interrupted живёт в `Event.Interrupted` в live-стриме, в журнал
|
||||||
|
* не попадает (см. [AgentMappers]).
|
||||||
|
*/
|
||||||
|
data class Assistant(
|
||||||
|
override val id: String,
|
||||||
|
override val timestamp: Instant,
|
||||||
|
val text: String,
|
||||||
|
val interrupted: Boolean = false, // прерван кнопкой «Стоп» — R39
|
||||||
|
) : UiMessage
|
||||||
|
|
||||||
|
data class ToolCall(
|
||||||
|
override val id: String,
|
||||||
|
override val timestamp: Instant,
|
||||||
|
val toolName: String,
|
||||||
|
val title: String? = null,
|
||||||
|
val args: String? = null,
|
||||||
|
) : UiMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат инструмента. [toolCallId] — FK на [ToolCall.id], для UI-связи.
|
||||||
|
* [toolName] nullable для обратной совместимости: в v10 добавлено, в
|
||||||
|
* исторических записях (до v10) может быть `null`.
|
||||||
|
*/
|
||||||
|
data class ToolResult(
|
||||||
|
override val id: String,
|
||||||
|
override val timestamp: Instant,
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String? = null,
|
||||||
|
) : UiMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ошибочный ход. [code] опционален — в v9 у `MessageRecord.Error` это
|
||||||
|
* `String?`, исторически могло быть `null`.
|
||||||
|
*/
|
||||||
|
data class Error(
|
||||||
|
override val id: String,
|
||||||
|
override val timestamp: Instant,
|
||||||
|
val message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
) : UiMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-события текущего хода. Эмитятся в [ChatSession.liveEvents] по мере
|
||||||
|
* того, как агент стримит ответ. В UI накапливаются в текущий «бабл»
|
||||||
|
* ассистента до прихода [End] / [Error] / `interrupted`.
|
||||||
|
*
|
||||||
|
* **NB**: `Interrupted` намеренно отсутствует — на стабе ход просто
|
||||||
|
* завершается, а реальный протокол эмитит его отдельным
|
||||||
|
* `proto.Event.Interrupted`. В фазе «жир» добавим отдельный вариант,
|
||||||
|
* когда UI начнёт его рендерить.
|
||||||
|
*/
|
||||||
|
sealed interface UiEvent {
|
||||||
|
data class AppendText(val body: String) : UiEvent
|
||||||
|
data class AppendImage(val body: ByteArray) : UiEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-call из live-стрима. [id] — стабильный идентификатор вызова
|
||||||
|
* (из `Event.ToolCall.id`); по нему UI свяжет последующий
|
||||||
|
* [ToolResult.id] с этим вызовом.
|
||||||
|
*/
|
||||||
|
data class ToolCall(
|
||||||
|
val id: String,
|
||||||
|
val toolName: String,
|
||||||
|
val title: String? = null,
|
||||||
|
val args: String? = null,
|
||||||
|
) : UiEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-result из live-стрима. [toolCallId] — FK на [ToolCall.id]
|
||||||
|
* (бывшее поле `id` в v9, переименовано в v10 для консистентности с
|
||||||
|
* [MessageRecord.ToolResult.toolCallId] и [UiMessage.ToolResult.toolCallId]).
|
||||||
|
* [toolName] nullable, доступно с v10 — раньше UI держал локальный
|
||||||
|
* `Map<toolCallId, toolName>`, теперь не нужен.
|
||||||
|
*/
|
||||||
|
data class ToolResult(
|
||||||
|
val toolCallId: String,
|
||||||
|
val toolName: String? = null,
|
||||||
|
val result: String? = null,
|
||||||
|
) : UiEvent
|
||||||
|
|
||||||
|
data object End : UiEvent
|
||||||
|
data class Error(val message: String, val code: String? = null) : UiEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package pw.binom.agentik.android.agent.real
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.journal.MessageRecord
|
||||||
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import pw.binom.agentik.outbox.AgentEvent
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background-sync из `agent.outbox.events()` в локальный SQLite-кэш.
|
||||||
|
*
|
||||||
|
* Запускается в [HttpAgentConnection.configure] сразу после успешного
|
||||||
|
* `AgentikAgent(...)` + [ReconnectingOutbox]. Не требует открытия чата —
|
||||||
|
* слушает глобальный поток событий агента и переносит их в:
|
||||||
|
*
|
||||||
|
* - [conversationStore] (таблица `conversation`) — `Created` /
|
||||||
|
* `Deleted` / `Renamed` / `Touched`;
|
||||||
|
* - [journal] (таблица `message`) — полный refresh на каждый `Event.End`;
|
||||||
|
* - [metaRepo] (таблица `conversation_meta`) — `markSeen` после End для
|
||||||
|
* корректного счётчика unread в [HttpAgentConnection.listConversations].
|
||||||
|
*
|
||||||
|
* **Не пишет tool-call/tool-result/error в фоне** — они придут через
|
||||||
|
* полный refresh после `Event.End` (там же захватятся streamed
|
||||||
|
* `UserMessage`/`AssistantMessage`, которые через outbox передаются только
|
||||||
|
* инкрементально как `AppendText`, а в журнал фиксируются целиком при
|
||||||
|
* завершении хода). Это зеркалит поведение `HttpChatSession.Event.End`.
|
||||||
|
*
|
||||||
|
* **Один subscribe на поток**: используем тот же `ReconnectingOutbox`,
|
||||||
|
* который уже создан в [HttpAgentConnection]; повторный consumer у общего
|
||||||
|
* `MutableSharedFlow` безопасен (broadcast).
|
||||||
|
*
|
||||||
|
* **Закрытие**: [close] отменяет [syncJob] — потребители `journal` /
|
||||||
|
* `conversationStore` / `metaRepo` не закрываются (они переиспользуются
|
||||||
|
* на уровне соединения и закрываются в [HttpAgentConnection.close]).
|
||||||
|
*/
|
||||||
|
internal class AgentEventSync(
|
||||||
|
private val agent: Agent,
|
||||||
|
private val conversationStore: MutableConversationStore,
|
||||||
|
private val journal: MutableJournalStore,
|
||||||
|
private val metaRepo: ConversationMetaRepository,
|
||||||
|
private val parentScope: CoroutineScope,
|
||||||
|
private val withSqliteLock: suspend ((suspend () -> Unit) -> Unit),
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val syncJob: Job = parentScope.launch {
|
||||||
|
try {
|
||||||
|
agent.outbox.events(Instant.DISTANT_PAST).collect { commonEvent ->
|
||||||
|
// Сериализуем с HttpChatSession и listConversations — без мьютекса
|
||||||
|
// параллельный writer может вклиниться между cache.append и
|
||||||
|
// markSeen, оставив несогласованный счётчик unread.
|
||||||
|
withSqliteLock {
|
||||||
|
when (commonEvent) {
|
||||||
|
is CommonEvent.Agent -> handleAgent(commonEvent.event)
|
||||||
|
is CommonEvent.Conversation -> handleConversation(commonEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// outbox-стрим закрывается штатно после Failed; логируем на debug,
|
||||||
|
// чтобы не засорять logcat на ожидаемых кейсах (disconnect/long offline).
|
||||||
|
Log.d(TAG, "events stream terminated: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleAgent(ev: AgentEvent) {
|
||||||
|
when (ev) {
|
||||||
|
is AgentEvent.Created -> {
|
||||||
|
// Достаём полный record через локальный in-memory кэш Agent'а
|
||||||
|
// (см. `wrapWithLocalConversationCache` в `:client`) — там же
|
||||||
|
// происходит merge из этого же outbox. Если seed в кэше ещё не
|
||||||
|
// случился (rare: connect при offline), get() вернёт null —
|
||||||
|
// следующее Touched/refresh догонит.
|
||||||
|
agent.conversationStore.get(ev.conversationId)
|
||||||
|
?.let { conversationStore.upsert(it) }
|
||||||
|
}
|
||||||
|
is AgentEvent.Deleted -> {
|
||||||
|
conversationStore.delete(ev.id)
|
||||||
|
}
|
||||||
|
is AgentEvent.Renamed -> {
|
||||||
|
val existing = conversationStore.get(ev.id) ?: return
|
||||||
|
conversationStore.upsert(existing.copy(title = ev.title))
|
||||||
|
}
|
||||||
|
is AgentEvent.Touched -> {
|
||||||
|
conversationStore.touch(ev.id, ev.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleConversation(ev: CommonEvent.Conversation) {
|
||||||
|
val convId = ev.conversationId
|
||||||
|
when (val event = ev.event) {
|
||||||
|
is Event.End -> refreshAfterEnd(convId, event.date)
|
||||||
|
// StartResponse / StartReasoning / AppendText / AppendImage /
|
||||||
|
// Interrupted / ToolCall / ToolResult / ToolFailed / Error /
|
||||||
|
// ConversationClosing / CompactionTriggered — промежуточные или
|
||||||
|
// системные события; полная транскрипция восстановится через
|
||||||
|
// refreshAfterEnd на ближайшем End (или при следующем открытии чата).
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshAfterEnd(convId: String, endDate: Instant) {
|
||||||
|
// Cursor bug fix (как в HttpChatSession): читаем из metaRepo,
|
||||||
|
// а не из cache.list(limit=1).lastOrNull() (последний возвращает OLDEST
|
||||||
|
// из-за ASC-сортировки — см. memory `ui-wireup-state`).
|
||||||
|
val lastSeen = metaRepo.getLastSeen(convId) ?: Instant.DISTANT_PAST
|
||||||
|
try {
|
||||||
|
agent.journal.list(convId, lastSeen, offset = 0, limit = REFRESH_LIMIT)
|
||||||
|
.forEach { record -> appendIfAbsent(record) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Оффлайн / 5xx — пропускаем refresh, остальные writers (markSeen
|
||||||
|
// / upsert ConversationRecord) тоже не выполняем, чтобы не оставлять
|
||||||
|
// несогласованного состояния.
|
||||||
|
Log.w(TAG, "refresh-on-End for $convId failed: ${e.message}", e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// markSeen ДО upsert ConversationRecord — порядок не критичен, но так
|
||||||
|
// логично: сначала фиксируем прогресс чтения, потом обновляем карточку диалога.
|
||||||
|
metaRepo.markSeen(convId, endDate)
|
||||||
|
// updatedAt диалога мог измениться в этом ходе — синхронизируем snapshot.
|
||||||
|
agent.conversationStore.get(convId)?.let { conversationStore.upsert(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append с защитой от повторной доставки (reconnect/retry): `message.id` —
|
||||||
|
* PRIMARY KEY, повторный INSERT бросает constraint violation. Ловим и идём
|
||||||
|
* дальше — естественный dedup без модификации `KsqliteJournalStore` и без
|
||||||
|
* отдельного SQL-connection.
|
||||||
|
*
|
||||||
|
* Альтернатива — INSERT OR IGNORE через прямой SQL потребовала бы
|
||||||
|
* собственного SQLitePreparedStatement и отдельного connection'а;
|
||||||
|
* в сценарии с правильной cursor-дисциплиной (`lastSeen`-based fetch +
|
||||||
|
* `markSeen`) дубликаты редки, поэтому простой try/catch достаточен.
|
||||||
|
*/
|
||||||
|
private suspend fun appendIfAbsent(record: MessageRecord) {
|
||||||
|
try {
|
||||||
|
journal.append(record)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "skip duplicate ${record.id}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
syncJob.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "AgentEventSync"
|
||||||
|
const val REFRESH_LIMIT = 200
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package pw.binom.agentik.android.agent.real
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import pw.binom.db.ksqlite.SQLiteConnection
|
||||||
|
import pw.binom.db.ksqlite.SQLitePreparedStatement
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище метаданных диалогов: `last_seen_message_at` per-conversation.
|
||||||
|
*
|
||||||
|
* Живёт в той же SQLite-БД, что и [KsqliteJournalStore]
|
||||||
|
* (`filesDir/journal/agentik.db`), но **отдельная таблица** `conversation_meta`.
|
||||||
|
* Три отдельные абстракции per desktop-требований R41.4:
|
||||||
|
* messages / settings / conversation-list snapshot — каждая со своим owner'ом.
|
||||||
|
*
|
||||||
|
* **Не блокирует**: `markSeen` / `getLastSeen` — O(1) по primary key.
|
||||||
|
*
|
||||||
|
* ## Schema
|
||||||
|
*
|
||||||
|
* ```sql
|
||||||
|
* CREATE TABLE IF NOT EXISTS conversation_meta (
|
||||||
|
* conversation_id TEXT PRIMARY KEY,
|
||||||
|
* last_seen_message_at INTEGER NOT NULL -- epoch millis (Instant.toEpochMilliseconds)
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Версионирование** — `Schema.CURRENT_VERSION` инкрементируется при ЛЮБОМ
|
||||||
|
* изменении DDL. Сейчас 1.
|
||||||
|
*
|
||||||
|
* ## API-источник
|
||||||
|
*
|
||||||
|
* Реальные сигнатуры ksqlite взяты из upstream `KsqliteJournalStore`:
|
||||||
|
* - `SQLiteConnection.open(path)` / `SQLiteConnection.memory(name)`
|
||||||
|
* - `connection.prepare(sql)` → `SQLitePreparedStatement`
|
||||||
|
* - `connection.exec(sql)` — raw DDL
|
||||||
|
* - `stmt.bindText(idx, value)` — для строк
|
||||||
|
* - `stmt.bindLong(idx, value)` — для Long
|
||||||
|
* - `stmt.executeUpdate()` — для INSERT/UPDATE/DELETE
|
||||||
|
* - `stmt.executeQuery()` → `SQLiteResultSet` (closeable)
|
||||||
|
* - `rs.next()`: Boolean, `rs.getLong(idx)`, `rs.getText(idx)` — nullable!
|
||||||
|
*/
|
||||||
|
class ConversationMetaRepository private constructor(
|
||||||
|
private val connection: SQLiteConnection,
|
||||||
|
private val ownsConnection: Boolean,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
/** Открывает файловое соединение и берёт на себя его закрытие в [close]. */
|
||||||
|
constructor(path: String) : this(
|
||||||
|
connection = SQLiteConnection.open(path = path),
|
||||||
|
ownsConnection = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Внешнее соединение — репо НЕ закрывает его в [close]. Для shared-connection bundles. */
|
||||||
|
constructor(connection: SQLiteConnection) : this(
|
||||||
|
connection = connection,
|
||||||
|
ownsConnection = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
Schema.migrate(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val markSeenStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"INSERT OR REPLACE INTO conversation_meta (conversation_id, last_seen_message_at) VALUES (?, ?)"
|
||||||
|
)
|
||||||
|
private val getLastSeenStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"SELECT last_seen_message_at FROM conversation_meta WHERE conversation_id = ?"
|
||||||
|
)
|
||||||
|
private val getAllLastSeenStmt: SQLitePreparedStatement = connection.prepare(
|
||||||
|
"SELECT conversation_id, last_seen_message_at FROM conversation_meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** UPSERT: запоминает, что пользователь видел диалог до момента [at]. */
|
||||||
|
suspend fun markSeen(convId: String, at: Instant): Unit = withContext(Dispatchers.IO) {
|
||||||
|
val ts = at.toEpochMilliseconds()
|
||||||
|
markSeenStmt.reset()
|
||||||
|
markSeenStmt.clearBindings()
|
||||||
|
markSeenStmt.bindText(1, convId)
|
||||||
|
markSeenStmt.bindLong(2, ts)
|
||||||
|
markSeenStmt.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает `last_seen` или null (никогда не открывали). */
|
||||||
|
suspend fun getLastSeen(convId: String): Instant? = withContext(Dispatchers.IO) {
|
||||||
|
getLastSeenStmt.reset()
|
||||||
|
getLastSeenStmt.clearBindings()
|
||||||
|
getLastSeenStmt.bindText(1, convId)
|
||||||
|
getLastSeenStmt.executeQuery().use { rs ->
|
||||||
|
if (!rs.next()) return@use null
|
||||||
|
val ts = rs.getLong(1) ?: return@use null
|
||||||
|
Instant.fromEpochMilliseconds(ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk-чтение для списка диалогов (избегаем N+1 запросов). */
|
||||||
|
suspend fun getAllLastSeen(): Map<String, Instant> = withContext(Dispatchers.IO) {
|
||||||
|
getAllLastSeenStmt.reset()
|
||||||
|
getAllLastSeenStmt.clearBindings()
|
||||||
|
getAllLastSeenStmt.executeQuery().use { rs ->
|
||||||
|
val result = mutableMapOf<String, Instant>()
|
||||||
|
while (rs.next()) {
|
||||||
|
val convId = rs.getText(1) ?: continue
|
||||||
|
val ts = rs.getLong(2) ?: continue
|
||||||
|
result[convId] = Instant.fromEpochMilliseconds(ts)
|
||||||
|
}
|
||||||
|
result.toMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
runCatching { markSeenStmt.close() }
|
||||||
|
runCatching { getLastSeenStmt.close() }
|
||||||
|
runCatching { getAllLastSeenStmt.close() }
|
||||||
|
if (ownsConnection) connection.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDL для таблицы `conversation_meta`. По аналогии с
|
||||||
|
* `pw.binom.agentik.journal.ksqlite.Schema`. Идемпотентна
|
||||||
|
* (`IF NOT EXISTS`), можно звать повторно.
|
||||||
|
*/
|
||||||
|
object Schema {
|
||||||
|
const val CURRENT_VERSION: Int = 1
|
||||||
|
|
||||||
|
fun migrate(conn: SQLiteConnection) {
|
||||||
|
// DDL без prepared statements — однократный CREATE TABLE,
|
||||||
|
// exec() достаточно.
|
||||||
|
conn.exec(
|
||||||
|
"CREATE TABLE IF NOT EXISTS conversation_meta (" +
|
||||||
|
"conversation_id TEXT PRIMARY KEY," +
|
||||||
|
"last_seen_message_at INTEGER NOT NULL" +
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package pw.binom.agentik.android.agent.real
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.ktor.client.engine.HttpClientEngineFactory
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.ChatSession
|
||||||
|
import pw.binom.agentik.android.agent.ConnectionState
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSummary
|
||||||
|
import pw.binom.agentik.client.AgentikAgent
|
||||||
|
import pw.binom.agentik.client.ConnectionStatus
|
||||||
|
import pw.binom.agentik.client.ReconnectingOutbox
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import pw.binom.agentik.journal.ksqlite.KsqliteJournalStore
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реальная реализация [AgentConnection] поверх `pw.binom.agentik:client:12`.
|
||||||
|
*
|
||||||
|
* Lifecycle: создаётся один раз, держит [Agent] (а через него — `HttpClient`)
|
||||||
|
* до [close]. На каждый [configure] закрывает старый [Agent] и создаёт новый.
|
||||||
|
*
|
||||||
|
* **Не использует Compose, не использует DI.**
|
||||||
|
* Настройки хранятся в памяти в локальном поле configure-аргументов;
|
||||||
|
* их JSON-персистентность — за [SettingsRepository] (отдельный слой).
|
||||||
|
*
|
||||||
|
* **Persistent journal cache.** В конструктор требуется [Context] —
|
||||||
|
* используется для пути к SQLite-файлу кэша (`context.filesDir/journal/agentik.db`).
|
||||||
|
* Один [MutableJournalStore] живёт на всё соединение и переиспользуется
|
||||||
|
* всеми [HttpChatSession] (изоляция — по `conversation_id` в схеме).
|
||||||
|
* В v12 secondary constructor `KsqliteJournalStore(path)` стал public —
|
||||||
|
* reflection-workaround больше не нужен (см. memory `journal-ksqlite-reflection-workaround`).
|
||||||
|
*
|
||||||
|
* **Auto-reconnect.** В v10 поверх `agent.outbox` оборачивается
|
||||||
|
* [ReconnectingOutbox] — он обеспечивает два независимых потока:
|
||||||
|
* `events()` (live-события с курсором между реконнектами) и
|
||||||
|
* `connectionStatus()` (lifecycle Connecting/Connected/Disconnected/Failed).
|
||||||
|
* Это критично на мобильной сети — обрыв SSE не теряет события и не требует
|
||||||
|
* ручного переподключения.
|
||||||
|
*
|
||||||
|
* **EngineFactory.** По умолчанию — OkHttp. Для тестов / платформ, где
|
||||||
|
* OkHttp недоступен, в конструктор можно передать другую фабрику (например,
|
||||||
|
* CIO в unit-тестах на JVM). В production Android это OkHttp.
|
||||||
|
*/
|
||||||
|
class HttpAgentConnection(
|
||||||
|
private val context: Context,
|
||||||
|
private val engineFactory: HttpClientEngineFactory<*> = OkHttp,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) : AgentConnection {
|
||||||
|
|
||||||
|
@Volatile private var agent: Agent? = null
|
||||||
|
@Volatile private var reconnectingOutbox: ReconnectingOutbox? = null
|
||||||
|
@Volatile private var currentAgentId: String? = null
|
||||||
|
@Volatile private var currentAgentName: String? = null
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||||
|
|
||||||
|
override val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle-эмиты диалогов агента (Created / Deleted / Renamed / Touched).
|
||||||
|
* Для UI-подписчиков списка диалогов: на каждый эмит перезапрашиваем
|
||||||
|
* `conversationStore.listFlow()` (см. ConversationListScreen). Под капотом
|
||||||
|
* фильтрует [CommonEvent.Agent] из `reconnectingOutbox.events()`.
|
||||||
|
*/
|
||||||
|
private val _agentEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
|
||||||
|
val agentEvents: Flow<Unit> = _agentEvents.asSharedFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only view на список диалогов. В v12 [AgentikAgent] оборачивает
|
||||||
|
* свой [Agent.conversationStore] в локальный in-memory кэш,
|
||||||
|
* синхронизируемый через outbox-события.
|
||||||
|
* UI читает `conversationStore.listFlow()` — auto-refresh без ручного refetch.
|
||||||
|
*
|
||||||
|
* Доступ валиден только после успешного [configure] — до этого бросает
|
||||||
|
* [IllegalStateException] (экран списка диалогов показывается только при
|
||||||
|
* [ConnectionState.Connected]).
|
||||||
|
*/
|
||||||
|
override val conversationStore: ConversationStore
|
||||||
|
get() = agent?.conversationStore
|
||||||
|
?: throw IllegalStateException("AgentConnection not configured (state=${_state.value})")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Путь к shared SQLite-файлу journal-кэша. Создаётся лениво при первом
|
||||||
|
* [openConversation], один на всё соединение.
|
||||||
|
*/
|
||||||
|
private val journalDbPath: String = run {
|
||||||
|
val dir = File(context.filesDir, "journal")
|
||||||
|
dir.mkdirs()
|
||||||
|
File(dir, "agentik.db").absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single shared [MutableJournalStore] на всё соединение. Создаётся лениво
|
||||||
|
* в [openConversation], закрывается в [close]. Все [HttpChatSession]
|
||||||
|
* используют один экземпляр (single-DB подход — изоляция по
|
||||||
|
* `conversation_id` в схеме).
|
||||||
|
*/
|
||||||
|
@Volatile private var sharedCache: MutableJournalStore? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Репозиторий метаданных диалогов (per-conversation `last_seen_message_at`).
|
||||||
|
* Таблица `conversation_meta` живёт в той же БД, что и [sharedCache]
|
||||||
|
* (отдельная абстракция — desktop R41.4 требует три отдельных слоя:
|
||||||
|
* messages / settings / conversation-list snapshot). Доступ к metaRepo
|
||||||
|
* есть всегда (создаётся лениво при первом обращении), даже если ни один
|
||||||
|
* чат ещё не открывался — нужен для `listConversations()`, который
|
||||||
|
* вызывается ДО любого [openConversation].
|
||||||
|
*
|
||||||
|
* `internal` — [HttpChatSession] дёргает `markSeen` после backfill,
|
||||||
|
* на каждом `Event.End` и при close().
|
||||||
|
*/
|
||||||
|
internal val metaRepo: ConversationMetaRepository by lazy {
|
||||||
|
ConversationMetaRepository(journalDbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent SQLite-реестр диалогов агента (таблица `conversation`).
|
||||||
|
* Один экземпляр на всё соединение — обновляется в фоне через
|
||||||
|
* [AgentEventSync] из `outbox.events()`, читается напрямую из
|
||||||
|
* [listConversations] без обращения к серверу.
|
||||||
|
*
|
||||||
|
* Тонкая переупаковка над [KsqliteMutableConversationStore] из
|
||||||
|
* `:journal-ksqlite:15` (см. typealias [LocalConversationRepository]):
|
||||||
|
* эта либа уже владеет таблицей `conversation` — дублировать DDL
|
||||||
|
* не нужно.
|
||||||
|
*/
|
||||||
|
private val localConversations: LocalConversationRepository by lazy {
|
||||||
|
LocalConversationRepository(journalDbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background-sync из `outbox.events()` в локальный кэш
|
||||||
|
* ([localConversations] + [sharedCache] + [metaRepo]).
|
||||||
|
* Не требует открытия чата. Создаётся в [configure] после успешного
|
||||||
|
* `ReconnectingOutbox`, отменяется в [close] ДО закрытия outbox.
|
||||||
|
*/
|
||||||
|
private var eventSync: AgentEventSync? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открытые [HttpChatSession] для безопасного закрытия в [close] —
|
||||||
|
* иначе принудительное отключение во время активного чата оставит
|
||||||
|
* session-корутину и кэш живыми.
|
||||||
|
*/
|
||||||
|
private val openSessions = CopyOnWriteArraySet<HttpChatSession>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сериализует доступ к SQLite (`KsqliteJournalStore` + [metaRepo],
|
||||||
|
* оба пишут в `filesDir/journal/agentik.db`). Без этого при конкурентных
|
||||||
|
* записях возможен SQLITE_BUSY — у SQLite file-level locking, и второй
|
||||||
|
* writer может ждать или получить ошибку. Использовать через
|
||||||
|
* [withSqliteLock] из всех мест, где дёргаются [sharedCache] и/или [metaRepo].
|
||||||
|
*/
|
||||||
|
private val sqliteMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запускает [block] под [sqliteMutex]. Используется для атомарных
|
||||||
|
* операций над SQLite-кэшем (backfill + publishMessages, Event.End
|
||||||
|
* fetch + publish + markSeen, refresh-merge и т.д.) — чтобы между
|
||||||
|
* этими шагами не вклинился другой writer.
|
||||||
|
*/
|
||||||
|
suspend fun <T> withSqliteLock(block: suspend () -> T): T = sqliteMutex.withLock { block() }
|
||||||
|
|
||||||
|
override suspend fun configure(id: String, name: String, baseUrl: String, token: String?) {
|
||||||
|
// Валидация URL — пустой или не http(s) сразу бросает IllegalArgumentException,
|
||||||
|
// не выставляя state.
|
||||||
|
require(baseUrl.isNotBlank() && (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))) {
|
||||||
|
"baseUrl must be non-empty and start with http:// or https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть старые chat-sessions + agent + outbox, если были.
|
||||||
|
openSessions.forEach { it.close() }
|
||||||
|
openSessions.clear()
|
||||||
|
reconnectingOutbox?.close()
|
||||||
|
reconnectingOutbox = null
|
||||||
|
agent?.close()
|
||||||
|
agent = null
|
||||||
|
currentAgentId = null
|
||||||
|
currentAgentName = null
|
||||||
|
_state.value = ConnectionState.Connecting(id)
|
||||||
|
|
||||||
|
val newAgent: Agent = try {
|
||||||
|
AgentikAgent(
|
||||||
|
id = id,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
engineFactory = engineFactory,
|
||||||
|
token = token,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val reason = e.message ?: e::class.simpleName ?: "unknown"
|
||||||
|
_state.value = ConnectionState.Failed(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agent = newAgent
|
||||||
|
currentAgentId = id
|
||||||
|
currentAgentName = name
|
||||||
|
|
||||||
|
// v10+: ReconnectingOutbox оборачивает agent.outbox и даёт два потока —
|
||||||
|
// events() (с курсором между реконнектами) и connectionStatus()
|
||||||
|
// (lifecycle Connecting/Connected/Disconnected/Failed). Probe через
|
||||||
|
// getConversations() больше не нужен — первый эмитт
|
||||||
|
// ConnectionStatus.Connected сам индикатор успешной связи.
|
||||||
|
val outbox = ReconnectingOutbox(newAgent.outbox, scope)
|
||||||
|
reconnectingOutbox = outbox
|
||||||
|
|
||||||
|
// ConnectionStatus → наш ConnectionState. Connecting/Disconnected
|
||||||
|
// мапятся в Connecting (transient — клиент переподключится сам через
|
||||||
|
// BackoffPolicy). Failed — терминальная ошибка после maxAttempts.
|
||||||
|
scope.launch {
|
||||||
|
outbox.connectionStatus().collect { status ->
|
||||||
|
_state.value = when (status) {
|
||||||
|
is ConnectionStatus.Connecting -> ConnectionState.Connecting(currentAgentId ?: id)
|
||||||
|
is ConnectionStatus.Connected -> ConnectionState.Connected(
|
||||||
|
currentAgentId ?: id,
|
||||||
|
currentAgentName ?: name,
|
||||||
|
)
|
||||||
|
is ConnectionStatus.Disconnected -> ConnectionState.Connecting(currentAgentId ?: id)
|
||||||
|
is ConnectionStatus.Failed -> ConnectionState.Failed(
|
||||||
|
status.cause.message
|
||||||
|
?: status.cause::class.simpleName
|
||||||
|
?: "outbox failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle-эмиты диалогов (Created / Deleted / Renamed / Touched)
|
||||||
|
// — единичный тик в [agentEvents]. Подписчик UI (ConversationListScreen)
|
||||||
|
// перезапрашивает список через `conversationStore.listFlow()`.
|
||||||
|
scope.launch {
|
||||||
|
outbox.events(Instant.DISTANT_PAST).collect { commonEvent ->
|
||||||
|
if (commonEvent is CommonEvent.Agent) {
|
||||||
|
_agentEvents.tryEmit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background-sync `outbox.events()` → локальный SQLite-кэш.
|
||||||
|
// Делает listConversations() мгновенным (читает локальную таблицу
|
||||||
|
// `conversation` вместо обращения к серверу). Запускаем ПОСЛЕ outbox —
|
||||||
|
// иначе первый эмитт событий может уйти до того, как sync готов.
|
||||||
|
// `sharedCache` lazy — к моменту первого refresh события уже инициализирован
|
||||||
|
// (см. acquireJournalStore); metaRepo / localConversations — ленивые,
|
||||||
|
// init при первом touch в AgentEventSync.
|
||||||
|
eventSync = AgentEventSync(
|
||||||
|
agent = newAgent,
|
||||||
|
conversationStore = localConversations,
|
||||||
|
journal = sharedCache ?: acquireJournalStore(),
|
||||||
|
metaRepo = metaRepo,
|
||||||
|
parentScope = scope,
|
||||||
|
withSqliteLock = { block -> withSqliteLock<Unit>(block) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listConversations(): List<ConversationSummary> {
|
||||||
|
// Инициализация sharedCache нужна до listConversations — без него unread-цифры
|
||||||
|
// были бы нулевыми (раньше этот путь считал через `agent.journal.count`,
|
||||||
|
// теперь — через локальный SQLite-кэш).
|
||||||
|
val cache = acquireJournalStore()
|
||||||
|
return withSqliteLock {
|
||||||
|
try {
|
||||||
|
// Локальный snapshot бесед — без HTTP. AgentEventSync поддерживает
|
||||||
|
// эту таблицу в актуальном состоянии в фоне (Created/Deleted/Renamed/Touched),
|
||||||
|
// плюс подтягивает записи из `journal.list(...)` на каждый `Event.End`.
|
||||||
|
val convs = localConversations.listFlow().toList()
|
||||||
|
val meta = metaRepo.getAllLastSeen()
|
||||||
|
convs.map { rec ->
|
||||||
|
val lastSeen = meta[rec.id]
|
||||||
|
// Unread считаем из локального кэша. cache.list(...) грузит
|
||||||
|
// страницу в память (в т.ч. Int.MAX_VALUE для never-opened);
|
||||||
|
// при текущих масштабах это приемлемо. Оптимизация через SQL
|
||||||
|
// COUNT — будущая задача.
|
||||||
|
val unread: Int = if (lastSeen == null) {
|
||||||
|
cache.list(rec.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE).size
|
||||||
|
} else {
|
||||||
|
cache.list(rec.id, lastSeen, 0, Int.MAX_VALUE).size
|
||||||
|
}
|
||||||
|
ConversationSummary(
|
||||||
|
id = rec.id,
|
||||||
|
title = rec.title,
|
||||||
|
updatedAt = rec.updatedAt,
|
||||||
|
lastMessagePreview = null, // preview из кэша — отдельная задача
|
||||||
|
unreadCount = unread,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList() // SQLite недоступен — пусть UI покажет старый список
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openConversation(convId: String?): ChatSession {
|
||||||
|
val a = agent
|
||||||
|
?: throw IllegalStateException("AgentConnection not configured (state=${_state.value})")
|
||||||
|
val ob = reconnectingOutbox
|
||||||
|
?: throw IllegalStateException("AgentConnection outbox not initialized (state=${_state.value})")
|
||||||
|
val cache = acquireJournalStore()
|
||||||
|
val session = HttpChatSession(a, ob, convId, cache, scope, this)
|
||||||
|
openSessions += session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// Background-sync отменяем ДО outbox — иначе pending emissions после
|
||||||
|
// reconnect-cancel могут дёрнуть writer'ы уже закрытого SQLite-стора.
|
||||||
|
runCatching { eventSync?.close() }
|
||||||
|
eventSync = null
|
||||||
|
|
||||||
|
// Закрываем chat-sessions первыми — они ссылаются на sharedCache и
|
||||||
|
// могут держать активные корутины в scope.
|
||||||
|
openSessions.forEach { it.close() }
|
||||||
|
openSessions.clear()
|
||||||
|
|
||||||
|
// reconnectingOutbox первым — он запускает background-loop в scope,
|
||||||
|
// должен быть отменён до того как мы отменим сам scope.
|
||||||
|
reconnectingOutbox?.close()
|
||||||
|
reconnectingOutbox = null
|
||||||
|
agent?.close()
|
||||||
|
agent = null
|
||||||
|
currentAgentId = null
|
||||||
|
currentAgentName = null
|
||||||
|
_state.value = ConnectionState.Disconnected
|
||||||
|
|
||||||
|
// Local conversation registry — persistent SQLite-таблица в том же
|
||||||
|
// файле, что и sharedCache. Закрываем перед sharedCache, чтобы
|
||||||
|
// backfill-end race не дёрнул writer во время teardown.
|
||||||
|
runCatching { localConversations.close() }
|
||||||
|
|
||||||
|
// Shared journal cache — единственный владелец SQLite connection
|
||||||
|
// для всего соединения. Закрываем здесь; все session'ы уже
|
||||||
|
// отменены и больше не дернут кэш.
|
||||||
|
runCatching { sharedCache?.close() }
|
||||||
|
sharedCache = null
|
||||||
|
|
||||||
|
// ConversationMetaRepository — отдельная SQLite-таблица в том же файле,
|
||||||
|
// держит свой SQLitePreparedStatement (markSeen / getAllLastSeen). Закрываем
|
||||||
|
// после sharedCache (порядок не критичен — обе таблицы в одном файле, но
|
||||||
|
// sharedCache мог быть lazy-неинициализирован, тогда metaRepo остался
|
||||||
|
// единственным открывателем соединения).
|
||||||
|
runCatching { metaRepo.close() }
|
||||||
|
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление сессии из tracking-множества. Вызывается из [HttpChatSession.close]
|
||||||
|
* — иначе повторный close сессии или накопление мёртвых ссылок.
|
||||||
|
*/
|
||||||
|
internal fun onSessionClosed(session: HttpChatSession) {
|
||||||
|
openSessions.remove(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ленивая инициализация кэш-стора. Все [HttpChatSession] делят один
|
||||||
|
* экземпляр (single-DB подход). Double-checked locking: contention только
|
||||||
|
* на самом первом вызове.
|
||||||
|
*/
|
||||||
|
private fun acquireJournalStore(): MutableJournalStore {
|
||||||
|
sharedCache?.let { return it }
|
||||||
|
return synchronized(this) {
|
||||||
|
sharedCache ?: KsqliteJournalStore(path = journalDbPath).also { sharedCache = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package pw.binom.agentik.android.agent.real
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.android.agent.ChatSession
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSnapshot
|
||||||
|
import pw.binom.agentik.android.agent.UiEvent
|
||||||
|
import pw.binom.agentik.android.agent.UiMessage
|
||||||
|
import pw.binom.agentik.android.agent.toUiEvent
|
||||||
|
import pw.binom.agentik.android.agent.toUiMessage
|
||||||
|
import pw.binom.agentik.client.ReconnectingOutbox
|
||||||
|
import pw.binom.agentik.journal.MutableJournalStore
|
||||||
|
import pw.binom.agentik.outbox.CommonEvent
|
||||||
|
import pw.binom.agentik.outbox.Event
|
||||||
|
import pw.binom.agentik.proto.Agent
|
||||||
|
import pw.binom.agentik.proto.Content
|
||||||
|
import pw.binom.agentik.proto.Conversation
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Реальная реализация [ChatSession] поверх [Conversation] + persistent
|
||||||
|
* [MutableJournalStore] (SQLite через `:journal-ksqlite`).
|
||||||
|
*
|
||||||
|
* **Persistent cache.** Переданный снаружи [cache] живёт дольше сессии
|
||||||
|
* — это общий кэш на уровне [HttpAgentConnection], переиспользуется
|
||||||
|
* между диалогами (изоляция — по `conversation_id` в схеме). История
|
||||||
|
* переживает kill приложения.
|
||||||
|
*
|
||||||
|
* Init flow:
|
||||||
|
* 1. get-or-create conversation (внутри [parentScope].launch).
|
||||||
|
* 2. Backfill через `agent.journal.listFlow(c.id, Instant.DISTANT_PAST)` —
|
||||||
|
* авто-пагинация, Flow завершается когда сервер отдал пустую страницу.
|
||||||
|
* Каждая запись пишется в [cache], потом публикуется в [messages]
|
||||||
|
* (через `MessageRecord.toUiMessage()`).
|
||||||
|
* 3. Live через `outbox.events(Instant.DISTANT_PAST)` — единый поток всех
|
||||||
|
* событий агента с авто-reconnect (см. [ReconnectingOutbox]). Фильтруем
|
||||||
|
* по `CommonEvent.Conversation` с нашим `convId`. На каждый [Event.End]
|
||||||
|
* догоняем последние записи через `agent.journal.list(c.id, latest, 0, 100)`
|
||||||
|
* и обновляем [messages]. Другие события маппятся в [UiEvent] и эмитятся
|
||||||
|
* в [liveEvents].
|
||||||
|
*
|
||||||
|
* **Закрытие сессии НЕ закрывает [cache]** — он переиспользуется. Закрытие
|
||||||
|
* делает [HttpAgentConnection.close]. На [close] уведомляем [owner], чтобы
|
||||||
|
* тот убрал сессию из tracking-множества и не пытался закрыть её повторно.
|
||||||
|
*/
|
||||||
|
internal class HttpChatSession(
|
||||||
|
private val agent: Agent,
|
||||||
|
private val outbox: ReconnectingOutbox,
|
||||||
|
private val initialConvId: String?,
|
||||||
|
private val cache: MutableJournalStore,
|
||||||
|
private val parentScope: CoroutineScope,
|
||||||
|
private val owner: HttpAgentConnection,
|
||||||
|
) : ChatSession {
|
||||||
|
|
||||||
|
private val sessionJob: Job = SupervisorJob(parentScope.coroutineContext[Job])
|
||||||
|
private val sessionScope = CoroutineScope(parentScope.coroutineContext + sessionJob + Dispatchers.IO)
|
||||||
|
|
||||||
|
private var conv: Conversation? = null
|
||||||
|
private val _snapshot = MutableStateFlow<ConversationSnapshot?>(null)
|
||||||
|
private val _messages = MutableStateFlow<List<UiMessage>>(emptyList())
|
||||||
|
private val _liveEvents = MutableSharedFlow<UiEvent>(extraBufferCapacity = 16)
|
||||||
|
|
||||||
|
override val conversationId: String
|
||||||
|
get() = conv?.id ?: initialConvId.orEmpty()
|
||||||
|
|
||||||
|
override val snapshot: StateFlow<ConversationSnapshot?> = _snapshot.asStateFlow()
|
||||||
|
override val messages: StateFlow<List<UiMessage>> = _messages.asStateFlow()
|
||||||
|
override val liveEvents: Flow<UiEvent> = _liveEvents.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
sessionScope.launch { initialize() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initialize() {
|
||||||
|
val c: Conversation = try {
|
||||||
|
initialConvId?.let { agent.getConversation(it) }
|
||||||
|
?: agent.createConversation(temp = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Логировать (Log.e) и закрыть сессию — UI увидит snapshot=null и поймёт.
|
||||||
|
Log.e(TAG, "failed to open conversation: ${e.message}", e)
|
||||||
|
return
|
||||||
|
} ?: run {
|
||||||
|
Log.w(TAG, "conversation $initialConvId not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conv = c
|
||||||
|
_snapshot.value = ConversationSnapshot(
|
||||||
|
id = c.id,
|
||||||
|
title = c.title,
|
||||||
|
updatedAt = c.updatedAt,
|
||||||
|
supportsImageInput = c.isSupportImageInput,
|
||||||
|
supportsImageOutput = c.isSupportImageOutput,
|
||||||
|
isTemporal = c.isTemporal,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backfill. listFlow — auto-paginating Flow<MessageRecord>, завершается
|
||||||
|
// когда сервер отдал пустую страницу. Атомарно под sqliteMutex:
|
||||||
|
// append + publishMessages + markSeen должны видеть консистентное
|
||||||
|
// состояние SQLite (cache.append пишет в ту же БД, что и metaRepo).
|
||||||
|
try {
|
||||||
|
agent.journal.listFlow(c.id, Instant.DISTANT_PAST)
|
||||||
|
.collect { rec ->
|
||||||
|
owner.withSqliteLock { cache.append(rec) }
|
||||||
|
}
|
||||||
|
owner.withSqliteLock {
|
||||||
|
val records = cache.list(c.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
_messages.value = records.map { it.toUiMessage() }
|
||||||
|
// Mark seen: после полного backfill'а диалог считается прочитанным.
|
||||||
|
// Берём свежайший timestamp из кэша (Int.MAX_VALUE limit + .lastOrNull()
|
||||||
|
// — ASC-сортировка cache.list означает, что .lastOrNull() вернёт
|
||||||
|
// именно максимальный createdAt). null → кэш пуст → markSeen не зовём.
|
||||||
|
val lastCached = records.lastOrNull()?.createdAt
|
||||||
|
if (lastCached != null) {
|
||||||
|
owner.metaRepo.markSeen(c.id, lastCached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "backfill failed: ${e.message}", e)
|
||||||
|
// Продолжаем — live всё равно работает
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live events + refresh-on-End. v10: подписываемся на
|
||||||
|
// `ReconnectingOutbox.events()` (единый поток всех событий агента,
|
||||||
|
// с авто-reconnect и курсором) и фильтруем по нашему convId —
|
||||||
|
// берём только `CommonEvent.Conversation` с нужным conversationId.
|
||||||
|
try {
|
||||||
|
outbox.events(Instant.DISTANT_PAST).collect { commonEvent ->
|
||||||
|
val convEv = commonEvent as? CommonEvent.Conversation ?: return@collect
|
||||||
|
if (convEv.conversationId != c.id) return@collect
|
||||||
|
val ev = convEv.event
|
||||||
|
ev.toUiEvent()?.let { uiEvent ->
|
||||||
|
_liveEvents.tryEmit(uiEvent)
|
||||||
|
}
|
||||||
|
if (ev is Event.End) {
|
||||||
|
// **Cursor bug fix.** Раньше здесь читался
|
||||||
|
// свежайший timestamp из кэша через вызов
|
||||||
|
// `cache.list(... limit = 1).lastOrNull()`.
|
||||||
|
// Это ВСЕГДА возвращало OLDEST-сообщение (journal-api
|
||||||
|
// сортирует ASC; limit=1 + .lastOrNull() = единственная
|
||||||
|
// запись = самая старая) → journal.list(after=oldest)
|
||||||
|
// тащил почти всё заново → дубликаты в кэше.
|
||||||
|
// Теперь читаем `last_seen_message_at` из metaRepo —
|
||||||
|
// именно тот cursor, который обновляет markSeen.
|
||||||
|
val lastSeen = owner.metaRepo.getLastSeen(c.id) ?: Instant.DISTANT_PAST
|
||||||
|
// Атомарно под sqliteMutex: fetch → cache.append →
|
||||||
|
// cache.list → publishMessages → markSeen. Без мьютекса
|
||||||
|
// параллельный refresh (или новый backfill после reconnect)
|
||||||
|
// может вклиниться между append и publishMessages →
|
||||||
|
// рассинхрон UI vs SQLite.
|
||||||
|
owner.withSqliteLock {
|
||||||
|
try {
|
||||||
|
agent.journal.list(c.id, lastSeen, offset = 0, limit = 100)
|
||||||
|
.forEach { cache.append(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "refresh on End failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
val records = cache.list(c.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
_messages.value = records.map { it.toUiMessage() }
|
||||||
|
// Mark seen: после очередного завершённого хода ассистента
|
||||||
|
// пользователь всё, что пришло в этот End, уже увидел.
|
||||||
|
// ev.date — серверный timestamp момента завершения хода.
|
||||||
|
owner.metaRepo.markSeen(c.id, ev.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "live events failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun send(text: String) {
|
||||||
|
val c = conv ?: throw IllegalStateException("ChatSession not yet initialized")
|
||||||
|
c.send(listOf(Content.Text(text)), context = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() {
|
||||||
|
conv?.interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(newTitle: String) {
|
||||||
|
val c = conv ?: throw IllegalStateException("ChatSession not yet initialized")
|
||||||
|
c.rename(newTitle)
|
||||||
|
// snapshot обновится по событию outbox (AgentEvent.Renamed) при следующем
|
||||||
|
// events-цикле или ручном refresh — для UX приемлемо.
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refresh() {
|
||||||
|
val c = conv ?: return
|
||||||
|
// **Cursor bug fix.** Тот же баг, что и в Event.End блоке: см. KDoc
|
||||||
|
// там. Заменено на owner.metaRepo.getLastSeen() вместо чтения
|
||||||
|
// из cache через limit=1 + lastOrNull (что возвращало OLDEST).
|
||||||
|
val lastSeen = owner.metaRepo.getLastSeen(c.id) ?: Instant.DISTANT_PAST
|
||||||
|
try {
|
||||||
|
owner.withSqliteLock {
|
||||||
|
agent.journal.list(c.id, lastSeen, offset = 0, limit = 200)
|
||||||
|
.forEach { cache.append(it) }
|
||||||
|
val records = cache.list(c.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
_messages.value = records.map { it.toUiMessage() }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "manual refresh failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
// cancel только наш sessionJob, не parentScope (connection живёт дальше).
|
||||||
|
// cache не закрываем — он переиспользуется между сессиями и закроется
|
||||||
|
// в HttpAgentConnection.close().
|
||||||
|
sessionJob.cancel()
|
||||||
|
|
||||||
|
// Финальный markSeen — best-effort, на случай если End-событие не
|
||||||
|
// пришло (стрим оборвался, пользователь нажал «назад»).
|
||||||
|
// Запускаем на parentScope (переживает sessionJob.cancel()): close()
|
||||||
|
// не-suspend, поэтому блокирующий markSeen здесь — анти-паттерн.
|
||||||
|
// parentScope в свою очередь переживёт до HttpAgentConnection.close() —
|
||||||
|
// если он будет вызван совсем сразу, markSeen отвалится в runCatching
|
||||||
|
// (acceptable: best-effort).
|
||||||
|
conv?.let { c ->
|
||||||
|
parentScope.launch {
|
||||||
|
try {
|
||||||
|
owner.withSqliteLock {
|
||||||
|
val lastCached = cache.list(c.id, Instant.DISTANT_PAST, 0, Int.MAX_VALUE)
|
||||||
|
.lastOrNull()?.createdAt
|
||||||
|
if (lastCached != null) {
|
||||||
|
owner.metaRepo.markSeen(c.id, lastCached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "markSeen on close failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убираем себя из owner.openSessions, чтобы не копиться мёртвыми
|
||||||
|
// ссылками и не получить повторный close через owner.close().
|
||||||
|
owner.onSessionClosed(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "HttpChatSession"
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package pw.binom.agentik.android.agent.real
|
||||||
|
|
||||||
|
import pw.binom.agentik.journal.ksqlite.KsqliteMutableConversationStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent SQLite-backed реестр диалогов на Android.
|
||||||
|
*
|
||||||
|
* `:journal-ksqlite:15` уже владеет таблицей `conversation` (см.
|
||||||
|
* `Schema.migrate` / `Schema.TABLE_CONVERSATION`). Тип
|
||||||
|
* [KsqliteMutableConversationStore] — реализация `MutableConversationStore`
|
||||||
|
* с двумя формами конструктора: `(path)` — открывает файловое соединение
|
||||||
|
* и владеет им, `(connection, ownsConnection)` — внешнее соединение
|
||||||
|
* (для shared-connection bundles).
|
||||||
|
*
|
||||||
|
* На Android держим ОДНО файловое соединение на таблицу `conversation`
|
||||||
|
* (`filesDir/journal/agentik.db`) — изоляция от других таблиц в той же БД
|
||||||
|
* (`message`, `conversation_meta`) обеспечивается отдельными connections.
|
||||||
|
* Конкуренция сериализуется через [HttpAgentConnection.sqliteMutex].
|
||||||
|
*
|
||||||
|
* Почему не свой класс по образцу [KsqliteJournalStore]: явное дублирование
|
||||||
|
* `Schema.migrate` / набора prepared-statement'ов — лишний код, который
|
||||||
|
* придётся поддерживать в двух местах. Typealias даёт имя уровня
|
||||||
|
* Android-слоя (отражает роль в lifecycle [HttpAgentConnection]) без
|
||||||
|
* потери связи с upstream-реализацией.
|
||||||
|
*/
|
||||||
|
typealias LocalConversationRepository = KsqliteMutableConversationStore
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package pw.binom.agentik.android.agent.stub
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.ChatSession
|
||||||
|
import pw.binom.agentik.android.agent.ConnectionState
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSummary
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSnapshot
|
||||||
|
import pw.binom.agentik.android.agent.UiMessage
|
||||||
|
import pw.binom.agentik.journal.ConversationRecord
|
||||||
|
import pw.binom.agentik.journal.ConversationStore
|
||||||
|
import pw.binom.agentik.journal.MutableConversationStore
|
||||||
|
import pw.binom.agentik.journal.inmemory.InMemoryMutableConversationStore
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заглушка [AgentConnection]: возвращает фейковые диалоги и валидирует
|
||||||
|
* конфиг (URL). Эмулирует handshake `Disconnected → Connecting → Connected`.
|
||||||
|
*
|
||||||
|
* Используется до подключения HTTP-импла на `AgentikAgent(...)`.
|
||||||
|
*
|
||||||
|
* [conversationStore] — [InMemoryMutableConversationStore], предзаполненный
|
||||||
|
* тремя фейковыми [ConversationRecord] (аналогично [listConversations]).
|
||||||
|
* Сидинг через [runBlocking] в init — список из трёх записей, инициализация
|
||||||
|
* занимает микросекунды, поэтому main-thread блокировка приемлема для stub'а.
|
||||||
|
* Это нужно, чтобы UI через `listFlow()` сразу видел данные без видимой
|
||||||
|
* вспышки "empty state" при первом запуске.
|
||||||
|
*/
|
||||||
|
internal class StubAgentConnection : AgentConnection {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||||
|
override val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val openSessions = CopyOnWriteArraySet<StubChatSession>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable-импл для сидинга; наружу торчит как read-only [ConversationStore].
|
||||||
|
* Доступен сразу после конструирования — список видим через `listFlow()`.
|
||||||
|
*/
|
||||||
|
private val mutableConversationStore: MutableConversationStore = InMemoryMutableConversationStore()
|
||||||
|
override val conversationStore: ConversationStore = mutableConversationStore
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Предзаполняем кэш фейковыми диалогами — UI через `listFlow()`
|
||||||
|
// сразу получает данные, без вспышки "empty state".
|
||||||
|
runBlocking { seedStubConversations(mutableConversationStore) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun seedStubConversations(store: MutableConversationStore) {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
store.upsert(
|
||||||
|
ConversationRecord(
|
||||||
|
id = "stub-conv-1",
|
||||||
|
title = "Ремонт на кухне",
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = now - 1.hours,
|
||||||
|
updatedAt = now - 1.hours,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
store.upsert(
|
||||||
|
ConversationRecord(
|
||||||
|
id = "stub-conv-2",
|
||||||
|
title = "Парсер логов",
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = now - 3.hours,
|
||||||
|
updatedAt = now - 3.hours,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
store.upsert(
|
||||||
|
ConversationRecord(
|
||||||
|
id = "stub-conv-3",
|
||||||
|
title = null, // новый диалог без названия — REQUIREMENTS.md
|
||||||
|
isTemporal = false,
|
||||||
|
createdAt = now - 1.days,
|
||||||
|
updatedAt = now - 1.days,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun configure(id: String, name: String, baseUrl: String, token: String?) {
|
||||||
|
require(baseUrl.isNotBlank()) { "baseUrl must not be blank" }
|
||||||
|
require(baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) {
|
||||||
|
"baseUrl must start with http:// or https:// (got '$baseUrl')"
|
||||||
|
}
|
||||||
|
_state.value = ConnectionState.Connecting(id)
|
||||||
|
// Имитация round-trip'а к серверу.
|
||||||
|
delay(HANDSHAKE_DELAY_MS)
|
||||||
|
_state.value = ConnectionState.Connected(agentId = id, agentName = name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listConversations(): List<ConversationSummary> {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
return listOf(
|
||||||
|
ConversationSummary(
|
||||||
|
id = "stub-conv-1",
|
||||||
|
title = "Ремонт на кухне",
|
||||||
|
updatedAt = now - 1.hours,
|
||||||
|
lastMessagePreview = "А если столешница из массива?",
|
||||||
|
unreadCount = 2,
|
||||||
|
),
|
||||||
|
ConversationSummary(
|
||||||
|
id = "stub-conv-2",
|
||||||
|
title = "Парсер логов",
|
||||||
|
updatedAt = now - 3.hours,
|
||||||
|
lastMessagePreview = "Закончили, отчёт готов",
|
||||||
|
unreadCount = 0,
|
||||||
|
),
|
||||||
|
ConversationSummary(
|
||||||
|
id = "stub-conv-3",
|
||||||
|
title = null, // новый диалог без названия — REQUIREMENTS.md
|
||||||
|
updatedAt = now - 1.days,
|
||||||
|
lastMessagePreview = null,
|
||||||
|
unreadCount = 0, // v15: non-null, never-opened показываем «0»
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openConversation(convId: String?): ChatSession {
|
||||||
|
val id = convId ?: "stub-conv-${UUID.randomUUID()}"
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val session = StubChatSession(
|
||||||
|
conversationId = id,
|
||||||
|
initialSnapshot = ConversationSnapshot(
|
||||||
|
id = id,
|
||||||
|
title = convId?.let { titleForKnownConv(it) },
|
||||||
|
updatedAt = now,
|
||||||
|
supportsImageInput = false,
|
||||||
|
supportsImageOutput = false,
|
||||||
|
isTemporal = false,
|
||||||
|
),
|
||||||
|
initialMessages = if (convId != null && convId in KNOWN_CONV_IDS) {
|
||||||
|
historyForKnownConv(convId, now)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
openSessions += session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
openSessions.forEach { it.close() }
|
||||||
|
openSessions.clear()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun titleForKnownConv(id: String): String? = when (id) {
|
||||||
|
"stub-conv-1" -> "Ремонт на кухне"
|
||||||
|
"stub-conv-2" -> "Парсер логов"
|
||||||
|
"stub-conv-3" -> null
|
||||||
|
else -> "Stub ${id.takeLast(6)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5–7 фейковых сообщений: U/A/TC/TR с реальной хронологией, последнее
|
||||||
|
* по времени — `Assistant`. Используется как стартовая история для
|
||||||
|
* известных диалогов.
|
||||||
|
*/
|
||||||
|
private fun historyForKnownConv(id: String, now: kotlin.time.Instant): List<UiMessage> {
|
||||||
|
val base = now - 1.hours
|
||||||
|
return when (id) {
|
||||||
|
"stub-conv-1" -> listOf(
|
||||||
|
UiMessage.User("u1", base, "Привет, планирую ремонт на кухне."),
|
||||||
|
UiMessage.Assistant("a1", base + 1.minutes, "Здравствуйте! С чего начнём — столешница или фартук?"),
|
||||||
|
UiMessage.User("u2", base + 2.minutes, "Столешница. Хочу кварц, но боюсь, что дорого."),
|
||||||
|
UiMessage.ToolCall("tc1", base + 2.minutes, toolName = "calc_price", args = "{material:quartz}"),
|
||||||
|
UiMessage.ToolResult("tr1", base + 3.minutes, toolCallId = "tc1", result = "{price: 45000}"),
|
||||||
|
UiMessage.Assistant("a2", base + 4.minutes, "Кварц — от 45 000 ₽/п.м. Альтернатива — искусственный камень, дешевле на 30%."),
|
||||||
|
UiMessage.User("u3", base + 50.minutes, "А если столешница из массива?"),
|
||||||
|
)
|
||||||
|
"stub-conv-2" -> listOf(
|
||||||
|
UiMessage.User("u1", base, "Распарси access.log за вчера"),
|
||||||
|
UiMessage.ToolCall("tc1", base + 1.minutes, toolName = "parse_log", args = "{date:2025-09-19}"),
|
||||||
|
UiMessage.ToolResult("tr1", base + 2.minutes, toolCallId = "tc1", result = "{lines:12834}"),
|
||||||
|
UiMessage.Assistant("a1", base + 3.minutes, "Готово, 12 834 строк. 4 ошибки 5xx, см. отчёт."),
|
||||||
|
)
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HANDSHAKE_DELAY_MS = 200L
|
||||||
|
|
||||||
|
val KNOWN_CONV_IDS: Set<String> = setOf("stub-conv-1", "stub-conv-2", "stub-conv-3")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package pw.binom.agentik.android.agent.stub
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import pw.binom.agentik.android.agent.ChatSession
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSnapshot
|
||||||
|
import pw.binom.agentik.android.agent.UiEvent
|
||||||
|
import pw.binom.agentik.android.agent.UiMessage
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заглушка [ChatSession]: держит в памяти фейковую историю и эмулирует
|
||||||
|
* стрим ответа с задержками. Используется, пока HTTP-фасад не подключён.
|
||||||
|
*
|
||||||
|
* **Lifecycle**: `openConversation()` создаёт сессию и регистрирует её у
|
||||||
|
* [StubAgentConnection]; [close] отменяет свой scope и снимает регистрацию.
|
||||||
|
* Конкурентные вызовы `send`/`interrupt` сериализуются мьютексом, чтобы
|
||||||
|
* предыдущая эмуляция корректно отменялась до старта новой.
|
||||||
|
*/
|
||||||
|
internal class StubChatSession(
|
||||||
|
override val conversationId: String,
|
||||||
|
initialSnapshot: ConversationSnapshot,
|
||||||
|
initialMessages: List<UiMessage>,
|
||||||
|
) : ChatSession {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val _snapshot = MutableStateFlow(initialSnapshot)
|
||||||
|
override val snapshot: StateFlow<ConversationSnapshot?> = _snapshot.asStateFlow()
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow(initialMessages)
|
||||||
|
override val messages: StateFlow<List<UiMessage>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
private val _liveEvents = MutableSharedFlow<UiEvent>(extraBufferCapacity = 16)
|
||||||
|
override val liveEvents: Flow<UiEvent> = _liveEvents.asSharedFlow()
|
||||||
|
|
||||||
|
/** Текущая «идущая» эмуляция хода. Новая отменяет старую. */
|
||||||
|
private var runningJob: Job? = null
|
||||||
|
|
||||||
|
override suspend fun send(text: String) = mutex.withLock {
|
||||||
|
if (text.isBlank()) return@withLock
|
||||||
|
val sendTime = Clock.System.now()
|
||||||
|
_messages.value = _messages.value + UiMessage.User(
|
||||||
|
id = "user-${System.currentTimeMillis()}",
|
||||||
|
timestamp = sendTime,
|
||||||
|
text = text,
|
||||||
|
)
|
||||||
|
runningJob?.cancel()
|
||||||
|
runningJob = scope.launch { runFakeTurn(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interrupt() = mutex.withLock {
|
||||||
|
runningJob?.cancel()
|
||||||
|
runningJob = null
|
||||||
|
_messages.value = _messages.value + UiMessage.Assistant(
|
||||||
|
id = "assistant-${System.currentTimeMillis()}",
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
text = "[прервано пользователем]",
|
||||||
|
interrupted = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(newTitle: String) {
|
||||||
|
_snapshot.value = _snapshot.value.copy(title = newTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op: стаб ничего не догоняет. */
|
||||||
|
override suspend fun refresh() = Unit
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмулирует один ход: эмитит чанки текста + tool-call/tool-result + `End`,
|
||||||
|
* затем добавляет финальный [UiMessage.Assistant] в историю. Задержка
|
||||||
|
* 150мс между чанками — чтобы UI мог порисовать streaming-эффект.
|
||||||
|
*/
|
||||||
|
private suspend fun runFakeTurn(userText: String) {
|
||||||
|
_liveEvents.emit(UiEvent.AppendText("Думаю над "))
|
||||||
|
delay(STREAM_DELAY_MS)
|
||||||
|
_liveEvents.emit(UiEvent.AppendText("вопросом \"$userText\"..."))
|
||||||
|
delay(STREAM_DELAY_MS)
|
||||||
|
_liveEvents.emit(UiEvent.ToolCall(id = STUB_TOOL_ID, toolName = "echo"))
|
||||||
|
delay(STREAM_DELAY_MS)
|
||||||
|
_liveEvents.emit(UiEvent.ToolResult(toolCallId = STUB_TOOL_ID, toolName = "echo", result = ""))
|
||||||
|
delay(STREAM_DELAY_MS)
|
||||||
|
_liveEvents.emit(UiEvent.End)
|
||||||
|
_messages.value = _messages.value + UiMessage.Assistant(
|
||||||
|
id = "assistant-${System.currentTimeMillis()}",
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
text = "Думаю над вопросом \"$userText\"...",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STREAM_DELAY_MS = 150L
|
||||||
|
/** Стабильный id для связи stub-ToolCall ↔ stub-ToolResult в live-стриме. */
|
||||||
|
const val STUB_TOOL_ID = "stub-tool-1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package pw.binom.agentik.android.asr
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import androidx.work.ListenableWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic-воркер, который доводит голосовую модель Qwen3-ASR до [ModelState.Ready]
|
||||||
|
* при наличии Wi-Fi (UNMETERED constraint задаётся на стороне [androidx.work.WorkRequest]).
|
||||||
|
*
|
||||||
|
* Поведение:
|
||||||
|
* 1. Сразу поднимает foreground-сервис с нотификацией, чтобы ОС не убила процесс во время
|
||||||
|
* скачивания ~940 МБ. Без `setForeground(...)` Android почти гарантированно убьёт процесс
|
||||||
|
* при сворачивании приложения.
|
||||||
|
* 2. Параллельно: запускает [QwenModelProvider.ensureReady] и слушает [QwenModelProvider.state],
|
||||||
|
* обновляя нотификацию по мере прогресса.
|
||||||
|
* 3. По завершении — отменяет коллектор и возвращает [ListenableWorker.Result.success] /
|
||||||
|
* [ListenableWorker.Result.retry].
|
||||||
|
*
|
||||||
|
* Воркер живёт недолго и держит собственный экземпляр [QwenModelProvider] (у того свой
|
||||||
|
* `CoroutineScope(SupervisorJob + Dispatchers.IO)`), поэтому гонки с другими вызовами провайдера
|
||||||
|
* из UI не возникает.
|
||||||
|
*/
|
||||||
|
class QwenDownloadWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): ListenableWorker.Result {
|
||||||
|
ensureChannel(applicationContext)
|
||||||
|
val provider = QwenModelProvider(applicationContext)
|
||||||
|
|
||||||
|
// Шаг 1: сразу поднимаем foreground-сервис с текущим состоянием модели.
|
||||||
|
// Это критично для 940 МБ — без foreground-сервиса Android убьёт процесс.
|
||||||
|
setForeground(makeForegroundInfo(provider.state.value))
|
||||||
|
|
||||||
|
return try {
|
||||||
|
coroutineScope {
|
||||||
|
val downloadJob = async {
|
||||||
|
try {
|
||||||
|
val outcome = provider.ensureReady()
|
||||||
|
if (outcome.isSuccess) {
|
||||||
|
ListenableWorker.Result.success()
|
||||||
|
} else {
|
||||||
|
// Сетевая/IO-ошибка — WorkManager перезапланирует по constraints + backoff.
|
||||||
|
ListenableWorker.Result.retry()
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val collectorJob = async {
|
||||||
|
try {
|
||||||
|
provider.state.collect { state ->
|
||||||
|
setForeground(makeForegroundInfo(state))
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Коллектор отменяется по завершении загрузки — это норма.
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = downloadJob.await()
|
||||||
|
// ensureReady() завершилась — останавливаем коллектор, чтобы он не висел вечно.
|
||||||
|
// Foreground-сервис сам остановится при возврате из doWork().
|
||||||
|
collectorJob.cancel()
|
||||||
|
result
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// WorkManager остановил воркер (constraints изменились, пользователь отменил и т.п.).
|
||||||
|
// Пробрасываем — WorkManager сам разберётся со статусом.
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- нотификация -------------------------------------------------------
|
||||||
|
|
||||||
|
private fun makeForegroundInfo(state: ModelState): ForegroundInfo {
|
||||||
|
val notification = buildNotification(state)
|
||||||
|
// Android 14+ требует явный foreground service type.
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
ForegroundInfo(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ForegroundInfo(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(state: ModelState): Notification {
|
||||||
|
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(makeContentIntent())
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
ModelState.NotDownloaded, ModelState.Ready -> {
|
||||||
|
builder.setContentTitle("Проверка голосовой модели")
|
||||||
|
if (state is ModelState.Ready) {
|
||||||
|
builder.setContentText("Голосовая модель готова")
|
||||||
|
}
|
||||||
|
// Без прогресс-бара.
|
||||||
|
builder.setProgress(0, 0, false)
|
||||||
|
}
|
||||||
|
is ModelState.Downloading -> {
|
||||||
|
val percent = if (state.bytesTotal > 0L) {
|
||||||
|
((state.bytesDone.toDouble() / state.bytesTotal) * 100.0).roundToInt()
|
||||||
|
.coerceIn(0, 100)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
builder.setContentTitle("Скачивание голосовой модели")
|
||||||
|
builder.setContentText(
|
||||||
|
"${toMb(state.bytesDone)} / ${toMb(state.bytesTotal)} МБ",
|
||||||
|
)
|
||||||
|
builder.setProgress(100, percent, false)
|
||||||
|
}
|
||||||
|
is ModelState.Failed -> {
|
||||||
|
builder.setContentTitle("Ошибка скачивания голосовой модели")
|
||||||
|
builder.setContentText(state.reason)
|
||||||
|
builder.setProgress(0, 0, false)
|
||||||
|
// Не выкидывается автоматически — пользователь сам закроет.
|
||||||
|
builder.setOngoing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeContentIntent(): PendingIntent {
|
||||||
|
val intent: Intent = applicationContext.packageManager
|
||||||
|
.getLaunchIntentForPackage(applicationContext.packageName)
|
||||||
|
?: Intent().apply {
|
||||||
|
setClassName(
|
||||||
|
applicationContext,
|
||||||
|
"pw.binom.agentik.android.MainActivity",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toMb(bytes: Long): Long = bytes / (1024L * 1024L)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "qwen-model-download"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
private const val CHANNEL_NAME = "Голосовая модель"
|
||||||
|
private const val CHANNEL_DESCRIPTION =
|
||||||
|
"Скачивание и проверка голосовой модели Qwen3-ASR"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var channelEnsured = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт канал нотификации один раз за жизнь процесса. На pre-O (API < 26) каналов нет —
|
||||||
|
* no-op. Канал создаётся здесь, а не в `Application.onCreate`, чтобы не зависеть от
|
||||||
|
* инициализации WorkManager: воркер может запуститься и без явной регистрации канала,
|
||||||
|
* и канал всё равно будет готов.
|
||||||
|
*/
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
if (channelEnsured) return
|
||||||
|
synchronized(this) {
|
||||||
|
if (channelEnsured) return
|
||||||
|
val nm = context.getSystemService<NotificationManager>() ?: return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = CHANNEL_DESCRIPTION
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
channelEnsured = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package pw.binom.agentik.android.asr
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.security.DigestOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Манифест модели Qwen3-ASR: версия, базовый URL и список файлов с контрольными суммами.
|
||||||
|
* Лежит в `assets/qwen3_manifest.json` и парсится по требованию.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class QwenManifest(
|
||||||
|
val version: String,
|
||||||
|
val source: String,
|
||||||
|
val totalBytes: Long,
|
||||||
|
val files: List<ManifestFile>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Один файл модели. `path` может содержать `/` (например `tokenizer/vocab.json`) —
|
||||||
|
* подкаталог при скачивании сохраняется.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ManifestFile(
|
||||||
|
val path: String,
|
||||||
|
val size: Long,
|
||||||
|
val sha256: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние модели на устройстве. В `Downloading.bytesDone / bytesTotal`
|
||||||
|
* отдаётся глобальный прогресс по всем файлам из манифеста.
|
||||||
|
*/
|
||||||
|
sealed interface ModelState {
|
||||||
|
/** Модель ещё не скачана (или была удалена / загрузка отменена). */
|
||||||
|
data object NotDownloaded : ModelState
|
||||||
|
|
||||||
|
/** Идёт скачивание или проверка уже существующих файлов. */
|
||||||
|
data class Downloading(val bytesDone: Long, val bytesTotal: Long) : ModelState
|
||||||
|
|
||||||
|
/** Все файлы из манифеста на месте и прошли проверку SHA-256. */
|
||||||
|
data object Ready : ModelState
|
||||||
|
|
||||||
|
/** Скачивание упало. `reason` — короткое описание для логов и UI. */
|
||||||
|
data class Failed(val reason: String) : ModelState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Менеджер загрузки модели Qwen3-ASR. Сам ASR-движок модель не качает — это наша обёртка:
|
||||||
|
* читает манифест из assets, проверяет файлы по SHA-256, докачивает недостающие через OkHttp.
|
||||||
|
*
|
||||||
|
* Потокобезопасен: внутренний [Mutex] сериализует вызовы [ensureReady], а [cancelDownload]
|
||||||
|
* прерывает текущую загрузку снаружи. Состояние наружу — только через [state] (StateFlow).
|
||||||
|
*/
|
||||||
|
class QwenModelProvider(context: Context) {
|
||||||
|
|
||||||
|
private val appContext: Context = context.applicationContext
|
||||||
|
|
||||||
|
/** Куда кладём файлы модели: `filesDir/models/qwen3-asr/`. */
|
||||||
|
val modelDir: File = File(appContext.filesDir, "models/qwen3-asr")
|
||||||
|
|
||||||
|
private val client: OkHttpClient = OkHttpClient()
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val mutex: Mutex = Mutex()
|
||||||
|
|
||||||
|
private val _state: MutableStateFlow<ModelState> = MutableStateFlow(ModelState.NotDownloaded)
|
||||||
|
|
||||||
|
/** Состояние модели. Горячий поток, текущее значение всегда доступно через `.value`. */
|
||||||
|
val state: StateFlow<ModelState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** Текущая загрузка — чтобы [cancelDownload] мог её прервать. */
|
||||||
|
@Volatile
|
||||||
|
private var currentJob: Deferred<Unit>? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// На старте один раз проверяем файлы: если всё уже на месте — сразу Ready.
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
if (checkAllReady()) {
|
||||||
|
_state.value = ModelState.Ready
|
||||||
|
Log.i(TAG, "init: все файлы модели уже на месте, состояние Ready")
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "init: модель не готова, ждём вызова ensureReady()")
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "init: не удалось проверить готовность: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идемпотентно доводит модель до [ModelState.Ready]: проверяет все файлы по SHA-256,
|
||||||
|
* докачивает недостающие или повреждённые. Прогресс пишется в [state].
|
||||||
|
*
|
||||||
|
* @return `Result.success(modelDir)` если все файлы валидны;
|
||||||
|
* `Result.failure(e)` при сетевой/IO-ошибке.
|
||||||
|
* При отмене (CancellationException) состояние становится [ModelState.NotDownloaded],
|
||||||
|
* исключение пробрасывается вызывающему.
|
||||||
|
*/
|
||||||
|
suspend fun ensureReady(): Result<File> = mutex.withLock {
|
||||||
|
val job = scope.async<Unit> {
|
||||||
|
try {
|
||||||
|
val manifest = readManifest()
|
||||||
|
val total = manifest.totalBytes
|
||||||
|
var done: Long = 0L
|
||||||
|
|
||||||
|
for (entry in manifest.files) {
|
||||||
|
ensureFile(entry, manifest) { currentBytes ->
|
||||||
|
_state.value = ModelState.Downloading(done + currentBytes, total)
|
||||||
|
}
|
||||||
|
done += entry.size
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = ModelState.Ready
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"модель готова: версия ${manifest.version}, " +
|
||||||
|
"${manifest.files.size} файлов, $total байт",
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
_state.value = ModelState.NotDownloaded
|
||||||
|
Log.i(TAG, "загрузка модели отменена")
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
val reason = e.message ?: e::class.simpleName ?: "unknown error"
|
||||||
|
Log.e(TAG, "загрузка модели не удалась: $reason", e)
|
||||||
|
_state.value = ModelState.Failed(reason)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentJob = job
|
||||||
|
|
||||||
|
try {
|
||||||
|
job.await()
|
||||||
|
Result.success(modelDir)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// cancelDownload() или отмена скоупа вызывающего — гасим job и пробрасываем.
|
||||||
|
job.cancel()
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Result.failure(e)
|
||||||
|
} finally {
|
||||||
|
currentJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отменяет текущую загрузку, если она идёт. Безопасно вызывать, когда загрузки нет. */
|
||||||
|
fun cancelDownload() {
|
||||||
|
currentJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет каталог модели и сбрасывает состояние в [ModelState.NotDownloaded]. */
|
||||||
|
suspend fun delete() {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (modelDir.exists()) {
|
||||||
|
val deleted = modelDir.deleteRecursively()
|
||||||
|
Log.i(TAG, "каталог модели удалён: success=$deleted")
|
||||||
|
}
|
||||||
|
_state.value = ModelState.NotDownloaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- внутреннее --------------------------------------------------------
|
||||||
|
|
||||||
|
private fun readManifest(): QwenManifest {
|
||||||
|
val text = appContext.assets.open(MANIFEST_ASSET)
|
||||||
|
.bufferedReader()
|
||||||
|
.use { it.readText() }
|
||||||
|
return JSON.decodeFromString(QwenManifest.serializer(), text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяет, что все файлы из манифеста существуют и их SHA-256 совпадает. */
|
||||||
|
private suspend fun checkAllReady(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val manifest = readManifest()
|
||||||
|
manifest.files.all { entry ->
|
||||||
|
val target = File(modelDir, entry.path)
|
||||||
|
target.exists() && sha256Of(target) == entry.sha256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гарантирует, что [entry] присутствует в [modelDir] и совпадает по SHA-256.
|
||||||
|
* Если файл уже валиден — пропускает. Если повреждён — удаляет и качает заново.
|
||||||
|
*/
|
||||||
|
private suspend fun ensureFile(
|
||||||
|
entry: ManifestFile,
|
||||||
|
manifest: QwenManifest,
|
||||||
|
onProgress: (bytesInCurrentFile: Long) -> Unit,
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
val target = File(modelDir, entry.path)
|
||||||
|
target.parentFile?.let { parent ->
|
||||||
|
if (!parent.exists() && !parent.mkdirs()) {
|
||||||
|
throw IOException("не удалось создать каталог ${parent.absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.exists()) {
|
||||||
|
val existing = sha256Of(target)
|
||||||
|
if (existing == entry.sha256) {
|
||||||
|
Log.i(TAG, "[${entry.path}] уже на месте, SHA совпал")
|
||||||
|
onProgress(entry.size)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
Log.w(TAG, "[${entry.path}] SHA не совпал (есть ${existing.take(12)}…), перекачиваем")
|
||||||
|
if (!target.delete()) {
|
||||||
|
throw IOException("не удалось удалить повреждённый ${target.absolutePath}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
download(entry, manifest, target, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(
|
||||||
|
entry: ManifestFile,
|
||||||
|
manifest: QwenManifest,
|
||||||
|
target: File,
|
||||||
|
onProgress: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val url = manifest.source + entry.path
|
||||||
|
Log.i(TAG, "[${entry.path}] скачиваем $url")
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("HTTP ${response.code} при скачивании $url")
|
||||||
|
}
|
||||||
|
val body = response.body ?: throw IOException("пустое тело ответа для $url")
|
||||||
|
|
||||||
|
FileOutputStream(target).use { fos ->
|
||||||
|
DigestOutputStream(fos, digest).use { dos ->
|
||||||
|
val input = body.byteStream()
|
||||||
|
val buf = ByteArray(READ_BUFFER)
|
||||||
|
var written: Long = 0L
|
||||||
|
while (true) {
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
dos.write(buf, 0, n)
|
||||||
|
written += n
|
||||||
|
onProgress(written)
|
||||||
|
}
|
||||||
|
dos.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hash = digest.digest().toHex()
|
||||||
|
if (hash != entry.sha256) {
|
||||||
|
target.delete()
|
||||||
|
throw IOException(
|
||||||
|
"[${entry.path}] SHA не совпал: ожидался ${entry.sha256}, получили $hash",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "[${entry.path}] скачан и проверен, ${entry.size} байт")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Of(file: File): String {
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
file.inputStream().use { input ->
|
||||||
|
DigestOutputStream(NULL_OUTPUT_STREAM, digest).use { dos ->
|
||||||
|
input.copyTo(dos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digest.digest().toHex()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.toHex(): String =
|
||||||
|
joinToString(separator = "") { byte -> "%02x".format(byte) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "QwenModelProvider"
|
||||||
|
private const val MANIFEST_ASSET = "qwen3_manifest.json"
|
||||||
|
private const val READ_BUFFER = 64 * 1024
|
||||||
|
|
||||||
|
private val JSON: Json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// Свой null-приёмник вместо java.io.OutputStream.nullOutputStream(),
|
||||||
|
// который доступен в Android только с API 33.
|
||||||
|
private val NULL_OUTPUT_STREAM: OutputStream = object : OutputStream() {
|
||||||
|
override fun write(b: Int) {}
|
||||||
|
override fun write(b: ByteArray, off: Int, len: Int) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package pw.binom.agentik.android.settings
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Персистируемые настройки подключения к агенту. Хранятся в
|
||||||
|
* `filesDir/settings.json` через [SettingsRepository] — соответствует
|
||||||
|
* desktop-варианту (R41.5), где settings живут в JSON-файле, а не в БД.
|
||||||
|
*
|
||||||
|
* @property agentId стабильный client-id (UUID-строка, генерируется
|
||||||
|
* один раз при первом подключении и переиспользуется).
|
||||||
|
* @property agentName отображаемое имя клиента.
|
||||||
|
* @property baseUrl базовый URL агента (например, `https://agent.example.com`).
|
||||||
|
* @property token bearer-токен для авторизации (опционально, может быть null).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Settings(
|
||||||
|
val agentId: String,
|
||||||
|
val agentName: String,
|
||||||
|
val baseUrl: String,
|
||||||
|
val token: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pw.binom.agentik.android.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository для JSON-персистентности [Settings] в `filesDir/settings.json`.
|
||||||
|
*
|
||||||
|
* По аналогии с desktop-вариантом (R41.5): настройки в JSON-файле, не в БД.
|
||||||
|
* Простая read/write — без шифрования, без миграций, без бэкапов.
|
||||||
|
*
|
||||||
|
* Все операции файлового ввода/вывода уходят в [Dispatchers.IO] —
|
||||||
|
* вызовы из UI-потока не блокируют рекомпозицию.
|
||||||
|
*
|
||||||
|
* @param context Android context; используется только `context.filesDir`.
|
||||||
|
*/
|
||||||
|
class SettingsRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private val file: File
|
||||||
|
get() = File(context.filesDir, FILE_NAME)
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прочитать настройки. Возвращает `null`, если файла нет или он
|
||||||
|
* нечитаем/повреждён (повреждённый JSON не должен ронять приложение —
|
||||||
|
* пользователю проще ввести заново, чем ловить крэш при старте).
|
||||||
|
*/
|
||||||
|
suspend fun load(): Settings? = withContext(Dispatchers.IO) {
|
||||||
|
val f = file
|
||||||
|
if (!f.exists()) return@withContext null
|
||||||
|
runCatching { json.decodeFromString<Settings>(f.readText()) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить настройки. Перезаписывает файл целиком (atomic write —
|
||||||
|
* `writeText` под Android эквивалентен truncate+write для маленького
|
||||||
|
* файла, что приемлемо для настроек).
|
||||||
|
*/
|
||||||
|
suspend fun save(settings: Settings): Unit = withContext(Dispatchers.IO) {
|
||||||
|
val f = file
|
||||||
|
f.parentFile?.mkdirs()
|
||||||
|
f.writeText(json.encodeToString(settings))
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FILE_NAME = "settings.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package pw.binom.agentik.android.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.agentik.android.ui.theme.AgentikTheme
|
||||||
|
|
||||||
|
/** Состояние строки ввода; маппится [InputBar]'ом на один из трёх макетов. */
|
||||||
|
sealed interface InputBarState {
|
||||||
|
data class Idle(
|
||||||
|
val text: String = "",
|
||||||
|
val canSend: Boolean = false,
|
||||||
|
val micState: MicState = MicState.Idle,
|
||||||
|
) : InputBarState
|
||||||
|
data class Recording(val elapsedMillis: Long = 0L) : InputBarState
|
||||||
|
data object Streaming : InputBarState
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InputBar(
|
||||||
|
state: InputBarState,
|
||||||
|
onTextChange: (String) -> Unit = {},
|
||||||
|
onStartRecording: () -> Unit = {},
|
||||||
|
onStopRecording: () -> Unit = {},
|
||||||
|
onCancelRecording: () -> Unit = {},
|
||||||
|
onSend: () -> Unit = {},
|
||||||
|
onStopAgent: () -> Unit = {},
|
||||||
|
onMicDisabledClick: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 14.dp),
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is InputBarState.Idle -> IdleLayout(
|
||||||
|
state, onTextChange, onStartRecording, onStopRecording, onSend, onMicDisabledClick,
|
||||||
|
)
|
||||||
|
is InputBarState.Recording -> RecordingLayout(state, onStopRecording, onCancelRecording)
|
||||||
|
is InputBarState.Streaming -> StreamingLayout(onStopAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IdleLayout(
|
||||||
|
state: InputBarState.Idle,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
onStartRecording: () -> Unit,
|
||||||
|
onStopRecording: () -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onMicDisabledClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val alpha = if (state.canSend) 1f else 0.35f
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
FieldBox(
|
||||||
|
text = state.text, placeholder = "Сообщение…", onValueChange = onTextChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
MicButton(
|
||||||
|
state = state.micState, onStart = onStartRecording, onStop = onStopRecording,
|
||||||
|
onDisabledClick = onMicDisabledClick,
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
onClick = onSend, enabled = state.canSend, shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize(), Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Отправить",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = alpha),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordingLayout(
|
||||||
|
state: InputBarState.Recording,
|
||||||
|
onStopRecording: () -> Unit,
|
||||||
|
onCancelRecording: () -> Unit,
|
||||||
|
) {
|
||||||
|
RecordingPanel(
|
||||||
|
elapsedMillis = state.elapsedMillis, onCancel = onCancelRecording, onSend = onStopRecording,
|
||||||
|
modifier = Modifier.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
|
FieldBox(
|
||||||
|
text = "", placeholder = "Идёт запись…", onValueChange = {},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
MicButton(state = MicState.Idle, onStart = {}, onStop = onStopRecording, recording = true)
|
||||||
|
}
|
||||||
|
RecNote("Говорите — текст появится в поле. ✕ отменяет запись целиком.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StreamingLayout(onStopAgent: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onStopAgent, shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.error.copy(alpha = 0.10f),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(9.dp, Alignment.CenterHorizontally),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Stop, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Text("Стоп", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RecNote("Агент отвечает — можно прервать")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FieldBox(
|
||||||
|
text: String, placeholder: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = text, onValueChange = onValueChange,
|
||||||
|
modifier = modifier
|
||||||
|
.height(44.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(14.dp))
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
|
textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
singleLine = true,
|
||||||
|
decorationBox = { inner ->
|
||||||
|
Box(Modifier.fillMaxSize(), Alignment.CenterStart) {
|
||||||
|
if (text.isEmpty()) Text(placeholder, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
inner()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecNote(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable private fun InputBarPreviewIdle() = AgentikTheme {
|
||||||
|
InputBar(state = InputBarState.Idle(text = "Привет", canSend = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable private fun InputBarPreviewIdleMicDisabled() = AgentikTheme {
|
||||||
|
InputBar(state = InputBarState.Idle(micState = MicState.Disabled("Нет доступа к микрофону")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable private fun InputBarPreviewRecording() = AgentikTheme {
|
||||||
|
InputBar(state = InputBarState.Recording(elapsedMillis = 7_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable private fun InputBarPreviewStreaming() = AgentikTheme {
|
||||||
|
InputBar(state = InputBarState.Streaming)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package pw.binom.agentik.android.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.agentik.android.ui.theme.AgentikTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние кнопки микрофона в [InputBarState.Idle].
|
||||||
|
* - [Idle] — готова к записи;
|
||||||
|
* - [Disabled] — недоступна (например, нет разрешения на микрофон); при нажатии
|
||||||
|
* показывается [tooltip].
|
||||||
|
*/
|
||||||
|
sealed interface MicState {
|
||||||
|
data object Idle : MicState
|
||||||
|
data class Disabled(val tooltip: String) : MicState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Круглая 44×44 кнопка микрофона. Только визуал — наружу колбэки.
|
||||||
|
*
|
||||||
|
* Визуальные режимы:
|
||||||
|
* - `state == MicState.Idle` и `recording == false` — серая, иконка микрофона,
|
||||||
|
* клик вызывает [onStart];
|
||||||
|
* - `state is MicState.Disabled` — серая, иконка микрофона, клик вызывает
|
||||||
|
* [onDisabledClick] и показывает тултип с [MicState.Disabled.tooltip];
|
||||||
|
* - `recording == true` — красная, иконка «стоп», клик вызывает [onStop].
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MicButton(
|
||||||
|
state: MicState,
|
||||||
|
onStart: () -> Unit,
|
||||||
|
onStop: () -> Unit,
|
||||||
|
onDisabledClick: () -> Unit = {},
|
||||||
|
recording: Boolean = false,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isDisabled = !recording && state is MicState.Disabled
|
||||||
|
val tooltip = (state as? MicState.Disabled)?.tooltip
|
||||||
|
|
||||||
|
val containerColor =
|
||||||
|
if (recording) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
val borderColor =
|
||||||
|
if (recording) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.outline
|
||||||
|
val iconTint =
|
||||||
|
if (recording) Color.White
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val iconVector = if (recording) Icons.Default.Stop else Icons.Default.Mic
|
||||||
|
val description = when {
|
||||||
|
recording -> "Остановить запись"
|
||||||
|
isDisabled -> tooltip.orEmpty()
|
||||||
|
else -> "Начать запись"
|
||||||
|
}
|
||||||
|
val onClick = when {
|
||||||
|
recording -> onStop
|
||||||
|
isDisabled -> onDisabledClick
|
||||||
|
else -> onStart
|
||||||
|
}
|
||||||
|
|
||||||
|
val button: @Composable () -> Unit = {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = CircleShape,
|
||||||
|
color = containerColor,
|
||||||
|
border = BorderStroke(1.dp, borderColor),
|
||||||
|
modifier = modifier.size(44.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = iconVector,
|
||||||
|
contentDescription = description,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooltip != null && !recording) {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||||
|
tooltip = { PlainTooltip { Text(tooltip) } },
|
||||||
|
state = rememberTooltipState(isPersistent = false),
|
||||||
|
) {
|
||||||
|
button()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun MicButtonPreviewIdle() = AgentikTheme {
|
||||||
|
MicButton(state = MicState.Idle, onStart = {}, onStop = {})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun MicButtonPreviewRecording() = AgentikTheme {
|
||||||
|
MicButton(state = MicState.Idle, onStart = {}, onStop = {}, recording = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun MicButtonPreviewDisabled() = AgentikTheme {
|
||||||
|
MicButton(
|
||||||
|
state = MicState.Disabled("Нет доступа к микрофону"),
|
||||||
|
onStart = {},
|
||||||
|
onStop = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package pw.binom.agentik.android.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pw.binom.agentik.android.ui.theme.AgentikTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель, которая показывается над строкой ввода во время записи голоса
|
||||||
|
* ([InputBarState.Recording]). Содержит декоративную «волну» из 9 полосок,
|
||||||
|
* таймер записи в формате m:ss, кнопки «отменить» и «отправить».
|
||||||
|
*
|
||||||
|
* Только визуал: таймер тикает извне через [elapsedMillis], за отправку/отмену
|
||||||
|
* отвечает родитель.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RecordingPanel(
|
||||||
|
elapsedMillis: Long,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val seconds = elapsedMillis / 1000L
|
||||||
|
val timeText = "%d:%02d".format(seconds / 60, seconds % 60)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
onClick = {},
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 13.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||||
|
) {
|
||||||
|
Wave(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
text = timeText,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"),
|
||||||
|
)
|
||||||
|
CancelAction(onClick = onCancel)
|
||||||
|
SendAction(onClick = onSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BarHeights = listOf(0.30f, 0.70f, 0.45f, 1.00f, 0.55f, 0.80f, 0.35f, 0.65f, 0.25f)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Wave(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.height(22.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
BarHeights.forEach { ratio ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.fillMaxHeight(ratio)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.error),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CancelAction(onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.size(34.dp),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Отменить запись",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendAction(onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(38.dp),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "Отправить запись",
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun RecordingPanelPreview() = AgentikTheme {
|
||||||
|
Box(modifier = Modifier.padding(12.dp)) {
|
||||||
|
RecordingPanel(elapsedMillis = 7_000, onCancel = {}, onSend = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pw.binom.agentik.android.ui.markdown
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единая точка входа для UI: парсит `text` как Markdown и рендерит через
|
||||||
|
* скопированный [renderMarkdownSegments]. Цвет/размер берутся из
|
||||||
|
* [androidx.compose.material3.MaterialTheme.colorScheme.onSurface] и
|
||||||
|
* 14.sp, если вызывающий не передал явно — этого достаточно для большинства
|
||||||
|
* пузырей чата; тонкая настройка — через `textColor`/`fontSize`.
|
||||||
|
*
|
||||||
|
* Клик по ссылке открывает её системным браузером через [MarkdownRenderer.openUrl]
|
||||||
|
* (Intent.ACTION_VIEW + FLAG_ACTIVITY_NEW_TASK).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MarkdownText(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textColor: Color = Color.Unspecified,
|
||||||
|
fontSize: TextUnit = 14.sp,
|
||||||
|
isBubbleScrolled: Boolean = false,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// [renderMarkdownSegments] не принимает Modifier — оборачиваем в Box,
|
||||||
|
// чтобы вызывающий мог влиять на размеры/отступы снаружи.
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
renderMarkdownSegments(
|
||||||
|
content = text,
|
||||||
|
textColor = textColor,
|
||||||
|
fontSize = fontSize,
|
||||||
|
isBubbleScrolled = isBubbleScrolled,
|
||||||
|
onUrlClick = { url -> MarkdownRenderer.openUrl(context, url) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package pw.binom.agentik.android.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.ConnectionState
|
||||||
|
import pw.binom.agentik.android.settings.SettingsRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Корневой композабл: переключает SettingsScreen / ConversationListScreen /
|
||||||
|
* ChatScreen по текущему [ConnectionState] и локальному `selectedConvId`.
|
||||||
|
*
|
||||||
|
* Без навигационной библиотеки — простой `when` + rememberSaveable.
|
||||||
|
*
|
||||||
|
* - state ≠ Connected → [SettingsScreen] (форма подключения).
|
||||||
|
* - state = Connected, convId = null → [ConversationListScreen] (список диалогов).
|
||||||
|
* - state = Connected, convId != null → [ChatScreen] (открытый диалог).
|
||||||
|
*
|
||||||
|
* Сброс [selectedConvId] в null на любой не-Connected state — чтобы при
|
||||||
|
* разрыве соединения пользователь не оказался внутри ChatScreen с мёртвой
|
||||||
|
* сессией.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppRoot(
|
||||||
|
connection: AgentConnection,
|
||||||
|
settingsRepository: SettingsRepository,
|
||||||
|
) {
|
||||||
|
val state by connection.state.collectAsState()
|
||||||
|
var selectedConvId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state !is ConnectionState.Connected) selectedConvId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
state !is ConnectionState.Connected -> {
|
||||||
|
SettingsScreen(
|
||||||
|
connection = connection,
|
||||||
|
settingsRepository = settingsRepository,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selectedConvId == null -> {
|
||||||
|
ConversationListScreen(
|
||||||
|
connection = connection,
|
||||||
|
onOpenChat = { id -> selectedConvId = id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
ChatScreen(
|
||||||
|
connection = connection,
|
||||||
|
convId = selectedConvId!!,
|
||||||
|
onBack = { selectedConvId = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package pw.binom.agentik.android.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.ChatSession
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSnapshot
|
||||||
|
import pw.binom.agentik.android.agent.UiEvent
|
||||||
|
import pw.binom.agentik.android.agent.UiMessage
|
||||||
|
import pw.binom.agentik.android.ui.markdown.MarkdownText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран одного открытого диалога. Жизненный цикл:
|
||||||
|
* 1. `LaunchedEffect(convId)` → `connection.openConversation(convId)`. Если
|
||||||
|
* convId не найден на сервере — `session` остаётся null, после таймаута
|
||||||
|
* показываем "Диалог не найден" + кнопку "Назад".
|
||||||
|
* 2. `LaunchedEffect(session)` → собираем `session.liveEvents` в локальный
|
||||||
|
* `liveBuffer` (буфер «в процессе»). На `UiEvent.End` буфер очищается —
|
||||||
|
* при следующем `End` к этому моменту [ChatSession.messages] уже
|
||||||
|
* содержит закрытый ход.
|
||||||
|
* 3. `DisposableEffect(Unit) { onDispose { session?.close() } }` —
|
||||||
|
* закрываем сессию при уходе с экрана (AppRoot переключает обратно на
|
||||||
|
* список диалогов при `onBack()`, либо при выходе из приложения).
|
||||||
|
*
|
||||||
|
* UI:
|
||||||
|
* - TopAppBar: заголовок = `snapshot.title` или "Новый диалог", кнопка "Назад"
|
||||||
|
* вызывает `onBack()` → AppRoot рекомпозится в ConversationListScreen.
|
||||||
|
* - Тело: LazyColumn с историей + «живой» бабл внизу, пока
|
||||||
|
* `liveBuffer` не пуст.
|
||||||
|
* - Низ: простой input bar (`OutlinedTextField` + Send). Voice (MicButton /
|
||||||
|
* RecordingPanel / InputBar из ui/common) — отдельная задача.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(
|
||||||
|
connection: AgentConnection,
|
||||||
|
convId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var session by remember { mutableStateOf<ChatSession?>(null) }
|
||||||
|
var sessionError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var notFound by remember { mutableStateOf(false) }
|
||||||
|
var liveBuffer by remember { mutableStateOf<List<UiEvent>>(emptyList()) }
|
||||||
|
var inputText by remember { mutableStateOf("") }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(convId) {
|
||||||
|
try {
|
||||||
|
session = connection.openConversation(convId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sessionError = e.message ?: e::class.simpleName ?: "Не удалось открыть диалог"
|
||||||
|
}
|
||||||
|
// Если после секунды session всё ещё null и ошибки не было — диалог
|
||||||
|
// не найден на сервере. openConversation у нас не различает "не найден"
|
||||||
|
// от "создаю новый с convId", поэтому явный флаг через секунду.
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
if (session == null && sessionError == null) {
|
||||||
|
notFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(session) {
|
||||||
|
val current = session ?: return@LaunchedEffect
|
||||||
|
current.liveEvents.collect { ev ->
|
||||||
|
liveBuffer = if (ev is UiEvent.End) emptyList() else liveBuffer + ev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
session?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписки на сообщения и метаданные. Когда session ещё null,
|
||||||
|
// collectAsState не вызывается (короткое замыкание по `?.`).
|
||||||
|
val messages: List<UiMessage> =
|
||||||
|
session?.messages?.collectAsState(initial = emptyList())?.value ?: emptyList()
|
||||||
|
val snapshot: ConversationSnapshot? =
|
||||||
|
session?.snapshot?.collectAsState()?.value
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(snapshot?.title ?: "Новый диалог") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Назад",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
ChatInputBar(
|
||||||
|
text = inputText,
|
||||||
|
onTextChange = { inputText = it },
|
||||||
|
enabled = session != null,
|
||||||
|
onSend = {
|
||||||
|
val s = session ?: return@ChatInputBar
|
||||||
|
val text = inputText.trim()
|
||||||
|
if (text.isEmpty()) return@ChatInputBar
|
||||||
|
inputText = ""
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
s.send(text)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Подавляем: на следующем End в messages появится
|
||||||
|
// UiMessage.Error (если сервер пришлёт), а Failed
|
||||||
|
// уже обрабатывается в AppRoot. Здесь просто не
|
||||||
|
// теряем набранное.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val err = sessionError
|
||||||
|
when {
|
||||||
|
notFound -> {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Text(text = "Диалог не найден")
|
||||||
|
Button(onClick = onBack) { Text("Назад") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err != null -> {
|
||||||
|
Text(
|
||||||
|
text = "Ошибка: $err",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
session == null -> {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Авто-скролл вниз при появлении нового сообщения или live-блока.
|
||||||
|
LaunchedEffect(messages.size, liveBuffer.size) {
|
||||||
|
if (messages.isNotEmpty() || liveBuffer.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(messages.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessagesList(
|
||||||
|
messages = messages,
|
||||||
|
liveBuffer = liveBuffer,
|
||||||
|
listState = listState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessagesList(
|
||||||
|
messages: List<UiMessage>,
|
||||||
|
liveBuffer: List<UiEvent>,
|
||||||
|
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(messages, key = { it.id }) { msg ->
|
||||||
|
MessageBubble(msg)
|
||||||
|
}
|
||||||
|
if (liveBuffer.isNotEmpty()) {
|
||||||
|
item(key = "live-bubble") {
|
||||||
|
LiveBubble(liveBuffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChatInputBar(
|
||||||
|
text: String,
|
||||||
|
onTextChange: (String) -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = onTextChange,
|
||||||
|
placeholder = { Text("Сообщение…") },
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 4,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
enabled = enabled && text.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||||
|
contentDescription = "Отправить",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------- bubble composables ----------------------------- */
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(msg: UiMessage) {
|
||||||
|
when (msg) {
|
||||||
|
is UiMessage.User -> UserBubble(msg)
|
||||||
|
is UiMessage.Assistant -> AssistantBubble(msg)
|
||||||
|
is UiMessage.ToolCall -> ToolCallBubble(msg)
|
||||||
|
is UiMessage.ToolResult -> ToolResultBubble(msg)
|
||||||
|
is UiMessage.Error -> ErrorBubble(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BubbleSurface(
|
||||||
|
color: Color,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = color,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.widthIn(max = 320.dp),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UserBubble(msg: UiMessage.User) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
BubbleSurface(color = MaterialTheme.colorScheme.primaryContainer) {
|
||||||
|
Text(msg.text, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AssistantBubble(msg: UiMessage.Assistant) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
BubbleSurface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
|
// Markdown-рендеринг вместо plain Text: кодовые блоки, таблицы,
|
||||||
|
// ссылки, инлайн-стили (жирный/курсив/strikethrough). Цвет —
|
||||||
|
// стандартный onSurface от MaterialTheme (MarkdownText сам
|
||||||
|
// подберёт дефолты; тут bubble color — surfaceVariant).
|
||||||
|
MarkdownText(text = msg.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolCallBubble(msg: UiMessage.ToolCall) {
|
||||||
|
BubbleSurface(color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "🔧 ${msg.toolName}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
msg.title?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
msg.args?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolResultBubble(msg: UiMessage.ToolResult) {
|
||||||
|
BubbleSurface(color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Text(
|
||||||
|
text = "✓ ${msg.toolName ?: "tool"}: ${msg.result.orEmpty()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorBubble(msg: UiMessage.Error) {
|
||||||
|
Text(
|
||||||
|
text = "⚠ ${msg.message}",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveBubble(events: List<UiEvent>) {
|
||||||
|
val text = events.filterIsInstance<UiEvent.AppendText>().joinToString("") { it.body }
|
||||||
|
val toolCalls = events.filterIsInstance<UiEvent.ToolCall>()
|
||||||
|
val toolResults = events.filterIsInstance<UiEvent.ToolResult>()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
BubbleSurface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
Text(text, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
toolCalls.forEach { tc ->
|
||||||
|
Text(
|
||||||
|
text = "🔧 ${tc.toolName}(${tc.args.orEmpty()})",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
toolResults.forEach { tr ->
|
||||||
|
Text(
|
||||||
|
text = "✓ ${tr.toolName ?: "tool"}: ${tr.result.orEmpty()}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package pw.binom.agentik.android.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubbleOutline
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.ConversationSummary
|
||||||
|
import pw.binom.agentik.android.agent.real.HttpAgentConnection
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран списка диалогов. Три состояния:
|
||||||
|
* - загрузка (пустой list + "Загрузка…" — короткая фаза до первого эмита);
|
||||||
|
* - пустой (EmptyState с CTA "Новый диалог");
|
||||||
|
* - список (`LazyColumn` с `ConversationRow`).
|
||||||
|
*
|
||||||
|
* **Источник данных (v12):** [AgentConnection.listConversations] — обогащённый
|
||||||
|
* список из connection layer (id / title / updatedAt / lastMessagePreview /
|
||||||
|
* unreadCount). Внутри `HttpAgentConnection.listConversations()` ходит в
|
||||||
|
* `conversationStore.listFlow()` + `metaRepo.getAllLastSeen()` + локальный
|
||||||
|
* кэш для подсчёта `unreadCount`. Stub-импл возвращает статический список.
|
||||||
|
*
|
||||||
|
* **Live-refresh:** [HttpAgentConnection.agentEvents] фильтрует
|
||||||
|
* `CommonEvent.Agent` (Created / Deleted / Renamed / Touched) из outbox — на
|
||||||
|
* каждый эмит перезапрашиваем список через `listConversations()`. Это
|
||||||
|
* сохраняет v10-семантику "UI обновляется на lifecycle диалога" поверх
|
||||||
|
* нового API. Для Stub-режима signal недоступен — refresh только при mount.
|
||||||
|
*
|
||||||
|
* Действия:
|
||||||
|
* - FAB "+" / EmptyState-кнопка → `connection.openConversation(null)` →
|
||||||
|
* `onOpenChat(newId)`.
|
||||||
|
* - TopAppBar "Disconnect" → `connection.close()` → AppRoot рекомпозится
|
||||||
|
* в SettingsScreen (state → Disconnected).
|
||||||
|
* - Row-tap → `onOpenChat(convId)`.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConversationListScreen(
|
||||||
|
connection: AgentConnection,
|
||||||
|
onOpenChat: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var summaries by remember { mutableStateOf<List<ConversationSummary>>(emptyList()) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
suspend fun reload() {
|
||||||
|
try {
|
||||||
|
// HttpAgentConnection.listConversations() внутри делает
|
||||||
|
// listFlow().toList() + metaRepo.getAllLastSeen() + cache lookup.
|
||||||
|
// StubAgentConnection возвращает статику.
|
||||||
|
summaries = connection.listConversations()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Сервер недоступен / seed ещё не прошёл — оставляем предыдущий список.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первичная загрузка.
|
||||||
|
LaunchedEffect(connection) { reload() }
|
||||||
|
|
||||||
|
// Live-refresh на lifecycle-события диалога (только HTTP). Каст к
|
||||||
|
// HttpAgentConnection: интерфейс AgentConnection этот signal не
|
||||||
|
// экспонирует.
|
||||||
|
val httpConnection = connection as? HttpAgentConnection
|
||||||
|
LaunchedEffect(httpConnection) {
|
||||||
|
httpConnection?.agentEvents?.collect { reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Диалоги") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { connection.close() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
contentDescription = "Отключиться",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val session = connection.openConversation(convId = null)
|
||||||
|
onOpenChat(session.conversationId)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Не критично — пользователь попробует ещё раз
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("Новый диалог") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
summaries.isEmpty() -> {
|
||||||
|
// Первичная загрузка — короткая фаза, listFlow пейджит
|
||||||
|
// через list() пока не упрётся в пустую страницу.
|
||||||
|
Text(
|
||||||
|
text = "Загрузка…",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
items(summaries, key = { it.id }) { summary ->
|
||||||
|
ConversationRow(
|
||||||
|
summary = summary,
|
||||||
|
onClick = { onOpenChat(summary.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConversationRow(summary: ConversationSummary, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Avatar-кружок — placeholder, цвет из темы (R1.1).
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = (summary.title ?: "Б").take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = summary.title ?: "Без названия",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatTimestamp(summary.updatedAt),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// v12: lastMessagePreview ещё не подключён (отдельная задача).
|
||||||
|
// Пока показываем placeholder.
|
||||||
|
Spacer(Modifier.size(2.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = summary.lastMessagePreview ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
// Unread badge: показывается только при unreadCount > 0.
|
||||||
|
// В v15 значение всегда известно (server-side count()).
|
||||||
|
UnreadBadge(unread = summary.unreadCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Бейдж непрочитанных сообщений. Маленький кружок с цифрой
|
||||||
|
* (или «99+» если > 99). Прячется при `unread == 0` (всё прочитано,
|
||||||
|
* либо never-opened — в v15 server-side count() даёт 0 для пустых
|
||||||
|
* never-opened диалогов).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UnreadBadge(unread: Int) {
|
||||||
|
if (unread <= 0) return
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = if (unread > 99) "99+" else unread.toString(),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует [Instant] как `HH:mm` для сегодняшних дат, иначе `dd.MM`.
|
||||||
|
* Использует `java.time` (встроен в Android начиная с API 26 — наш `minSdk`).
|
||||||
|
* Сравнение с "сегодня" — через системный [Clock] (тестируемо).
|
||||||
|
*
|
||||||
|
* Конвертация `kotlin.time.Instant` → `java.time.Instant` через
|
||||||
|
* `toEpochMilliseconds()` + `Instant.ofEpochMilli(...)` — единственный
|
||||||
|
* способ пересечь границу типов.
|
||||||
|
*/
|
||||||
|
private fun formatTimestamp(instant: Instant): String {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val javaNow = java.time.Instant.ofEpochMilli(Clock.System.now().toEpochMilliseconds())
|
||||||
|
val javaInstant = java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds())
|
||||||
|
val now = LocalDateTime.ofInstant(javaNow, zone).toLocalDate()
|
||||||
|
val dt = LocalDateTime.ofInstant(javaInstant, zone)
|
||||||
|
return if (dt.toLocalDate() == now) {
|
||||||
|
"%02d:%02d".format(dt.hour, dt.minute)
|
||||||
|
} else {
|
||||||
|
"%02d.%02d".format(dt.dayOfMonth, dt.monthValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package pw.binom.agentik.android.ui.screen
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pw.binom.agentik.android.agent.AgentConnection
|
||||||
|
import pw.binom.agentik.android.agent.ConnectionState
|
||||||
|
import pw.binom.agentik.android.settings.Settings
|
||||||
|
import pw.binom.agentik.android.settings.SettingsRepository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Экран подключения к агенту. Показывается в трёх случаях:
|
||||||
|
* - старт приложения ([ConnectionState.Disconnected]);
|
||||||
|
* - в процессе [ConnectionState.Connecting] — показываем прогресс;
|
||||||
|
* - при [ConnectionState.Failed] — показываем причину ошибки.
|
||||||
|
*
|
||||||
|
* Persistence: настройки читаются из [SettingsRepository] на старте
|
||||||
|
* (предзаполняют форму), и сохраняются после успешного `configure(...)`
|
||||||
|
* (когда state становится Connected — см. note в Connect-handler).
|
||||||
|
* Если файл `settings.json` отсутствует или повреждён, генерируется
|
||||||
|
* новый `agentId` (UUID) и поля показываются пустыми.
|
||||||
|
*
|
||||||
|
* agentId: при первом запуске генерируется как `agentik-android-<UUID>`,
|
||||||
|
* дальше переиспользуется из persisted settings.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
connection: AgentConnection,
|
||||||
|
settingsRepository: SettingsRepository,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val state by connection.state.collectAsState()
|
||||||
|
val isConnecting = state is ConnectionState.Connecting
|
||||||
|
|
||||||
|
// Дефолты для первого запуска. Если settings.json существует — будут
|
||||||
|
// перезаписаны в LaunchedEffect ниже.
|
||||||
|
val initialAgentId = remember { "agentik-android-${UUID.randomUUID()}" }
|
||||||
|
var agentId by remember { mutableStateOf(initialAgentId) }
|
||||||
|
var agentName by remember { mutableStateOf("Android") }
|
||||||
|
var baseUrl by remember { mutableStateOf("") }
|
||||||
|
var token by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Загрузка persisted настроек. runCatching в repository уже глотает
|
||||||
|
// любые ошибки (битый JSON, отсутствующий файл) — здесь дополнительной
|
||||||
|
// обработки не нужно.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val saved = settingsRepository.load() ?: return@LaunchedEffect
|
||||||
|
agentId = saved.agentId
|
||||||
|
agentName = saved.agentName
|
||||||
|
baseUrl = saved.baseUrl
|
||||||
|
token = saved.token.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Подключение к агенту") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Agentik",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Введите адрес агента для подключения. После успешного коннекта откроется чат.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = agentId,
|
||||||
|
onValueChange = { agentId = it },
|
||||||
|
label = { Text("Agent ID (client)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = agentName,
|
||||||
|
onValueChange = { agentName = it },
|
||||||
|
label = { Text("Имя клиента") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = { baseUrl = it },
|
||||||
|
label = { Text("URL агента") },
|
||||||
|
placeholder = { Text("https://agent.example.com") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = { token = it },
|
||||||
|
label = { Text("Bearer token (опционально)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
connection.configure(
|
||||||
|
id = agentId,
|
||||||
|
name = agentName,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
token = token.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
// configure() может либо бросить (валидация URL)
|
||||||
|
// либо вернуть нормально с state = Connecting /
|
||||||
|
// Failed. Сохраняем настройки в обоих не-бросковых
|
||||||
|
// случаях — пользователь явно их ввёл, при следующем
|
||||||
|
// запуске они должны быть видны (особенно если
|
||||||
|
// первая попытка коннекта провалилась из-за сети).
|
||||||
|
settingsRepository.save(
|
||||||
|
Settings(
|
||||||
|
agentId = agentId,
|
||||||
|
agentName = agentName,
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
token = token.takeIf { it.isNotBlank() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// HttpAgentConnection.configure бросает IllegalArgumentException
|
||||||
|
// ДО выставления state на некорректном URL — логируем,
|
||||||
|
// пользователь увидит стейт в Disconnected и сможет исправить.
|
||||||
|
Log.w(TAG, "configure() failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = baseUrl.isNotBlank() && !isConnecting,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (isConnecting) "Подключение…" else "Подключиться")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Диагностика под полями: прогресс / ошибка.
|
||||||
|
when (val s = state) {
|
||||||
|
is ConnectionState.Connecting -> {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Подключение к ${s.agentId}…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ConnectionState.Failed -> {
|
||||||
|
Text(
|
||||||
|
text = "Ошибка: ${s.reason}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "SettingsScreen"
|
||||||
@@ -8,7 +8,15 @@ androidx-activity-compose = "1.10.1"
|
|||||||
androidx-lifecycle = "2.9.0"
|
androidx-lifecycle = "2.9.0"
|
||||||
kotlinx-serialization = "1.11.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
kotlinx-coroutines = "1.11.0"
|
kotlinx-coroutines = "1.11.0"
|
||||||
agentik = "5"
|
agentik = "15"
|
||||||
|
journal-api = "15"
|
||||||
|
journal-inmemory = "15"
|
||||||
|
journal-ksqlite = "15"
|
||||||
|
ksqlite = "0.1.2"
|
||||||
|
asrQwen3 = "4"
|
||||||
|
micApi = "1.0.0"
|
||||||
|
work = "2.10.0"
|
||||||
|
ru-otpbank-markdown = "0.51.0"
|
||||||
android-compileSdk = "35"
|
android-compileSdk = "35"
|
||||||
android-minSdk = "26"
|
android-minSdk = "26"
|
||||||
android-targetSdk = "35"
|
android-targetSdk = "35"
|
||||||
@@ -31,6 +39,14 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
|
|||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
agentik-client = { module = "pw.binom.agentik:client", version.ref = "agentik" }
|
agentik-client = { module = "pw.binom.agentik:client", version.ref = "agentik" }
|
||||||
|
agentik-journal-api = { module = "pw.binom.agentik:journal-api", version.ref = "journal-api" }
|
||||||
|
agentik-journal-inmemory = { module = "pw.binom.agentik:journal-inmemory", version.ref = "journal-inmemory" }
|
||||||
|
agentik-journal-ksqlite = { module = "pw.binom.agentik:journal-ksqlite", version.ref = "journal-ksqlite" }
|
||||||
|
ksqlite = { module = "pw.binom.db:ksqlite", version.ref = "ksqlite" }
|
||||||
|
asr-qwen3 = { module = "pw.binom.asr:asr-qwen3", version.ref = "asrQwen3" }
|
||||||
|
mic-api = { module = "pw.binom.mic:mic-api", version.ref = "micApi" }
|
||||||
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
|
ru-otpbank-markdown = { module = "ru.otpbank.ai:markdown", version.ref = "ru-otpbank-markdown" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ dependencyResolutionManagement {
|
|||||||
// Nexus отдаёт по plain HTTP — без флага Gradle откажется.
|
// Nexus отдаёт по plain HTTP — без флага Gradle откажется.
|
||||||
isAllowInsecureProtocol = true
|
isAllowInsecureProtocol = true
|
||||||
}
|
}
|
||||||
|
// Хост для ru.otpbank.ai:markdown (парсер Markdown из дизайна BORROW.md, раздел 3).
|
||||||
|
maven {
|
||||||
|
name = "developerspace-prod-mvn-hosted"
|
||||||
|
url = uri("http://192.168.76.117/repository/developerspace-prod-mvn-hosted/")
|
||||||
|
// Nexus отдаёт по plain HTTP — без флага Gradle откажется.
|
||||||
|
isAllowInsecureProtocol = true
|
||||||
|
}
|
||||||
|
// JitPack хостит onnx-asr/sherpa-onnx и другие GitHub-проекты,
|
||||||
|
// которые тянет asr-qwen3-android транзитивно.
|
||||||
|
maven {
|
||||||
|
name = "jitpack"
|
||||||
|
url = uri("https://jitpack.io")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user