Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон

- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с
- PlaybackService (foreground, WAKE_LOCK) против морозов HONOR
- Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID)
- Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с
- LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях)
- Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText
- Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
2026-08-22 16:31:04 +03:00
parent adfe376dd4
commit bbf7a020d4
28 changed files with 1802 additions and 342 deletions
+16
View File
@@ -7,7 +7,18 @@
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Bluetooth SPP-транспорт (включается флагом PhoneConfig.USE_BLUETOOTH):
Android 12/13 — runtime BLUETOOTH_CONNECT/SCAN; до 12 — опасные
BLUETOOTH/BLUETOOTH_ADMIN (запрашиваются на запуске). -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".PhoneApp"
@@ -24,5 +35,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -0,0 +1,200 @@
package pw.binom.viewmate.phone
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.core.log
import pw.binom.viewmate.core.net.BtTransportConstants
import pw.binom.viewmate.core.net.GlassesServerTransport
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStream
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Bluetooth-транспорт, сторона телефона («сервер») — TASK-transport.md п.3:
* RFCOMM/SPP ([BtTransportConstants.SPP_UUID]), сообщения — строки
* (JSON + перевод строки как разделитель), read-поток — на Dispatchers.IO,
* запись — под синхронизацией на сокете. [connId] = MAC-адрес очков.
* Работает флагом [pw.binom.viewmate.phone.PhoneConfig.USE_BLUETOOTH]
* (дефолт — [WifiServerTransport]).
* @param context — для runtime-проверки BT-пермишенов (Android 12+ / до 12).
*/
class BtServerTransport(
private val context: Context? = null,
) : GlassesServerTransport {
companion object {
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
private fun hasBtPermissions(ctx: Context): Boolean =
runtimePermissions().all {
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
}
}
override val name = "bluetooth"
@Volatile
private var closed = false
@Volatile
private var serverSocket: BluetoothServerSocket? = null
private val sockets = ConcurrentHashMap<String, BluetoothSocket>()
private val writers = ConcurrentHashMap<String, OutputStream>()
private val readerJobs = ConcurrentHashMap<String, kotlinx.coroutines.Job>()
private val readerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Бесконечно принимать SPP-соединения очков; без BT/пермишена — ретраи через 3с. */
override suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
while (!closed) {
if (serverSocket == null) {
serverSocket = openServerSocket()
if (serverSocket == null) {
log("bt", "SPP-сервер не создан (BT выключен? нет пермишена?), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
delay(BtTransportConstants.RETRY_DELAY_MS)
continue
}
log("bt", "SPP-сервер запущен: ${BtTransportConstants.SERVICE_NAME} (${BtTransportConstants.SPP_UUID})")
}
try {
val s = withContext(Dispatchers.IO) { serverSocket!!.accept() } ?: break
if (closed) {
runCatching { s.close() }
break
}
val remote: BluetoothDevice = s.remoteDevice
val connId = remote.address
sockets[connId] = s
writers[connId] = s.outputStream
log("bt", "очки подключились: $connId")
onConnected(connId)
readerJobs[connId] = readerScope.launch {
readLines(s, connId, onMessage, onDisconnected)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "accept прерван (${e::class.simpleName}: ${e.message}), ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
runCatching { serverSocket?.close() }
serverSocket = null
delay(BtTransportConstants.RETRY_DELAY_MS)
}
}
cleanupConnections()
readerScope.cancel()
}
/** SPP-сервер (RFCOMM-канал SPP): без BT-радио/пермишена — null (лечь и ретраиться). */
@SuppressLint("MissingPermission")
private fun openServerSocket(): BluetoothServerSocket? {
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
log("bt", "BT-радио не найдено — SPP недоступен")
return null
}
if (!adapter.isEnabled) {
log("bt", "Bluetooth выключен — включите в настройках Android")
return null
}
val ctx = context
if (ctx != null && !hasBtPermissions(ctx)) {
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
return null
}
return runCatching {
adapter.listenUsingRfcommWithServiceRecord(
BtTransportConstants.SERVICE_NAME,
UUID.fromString(BtTransportConstants.SPP_UUID),
)
}.onFailure { log("bt", "не удалось создать SPP-сервер: ${it.message}") }.getOrNull()
}
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
private suspend fun readLines(
s: BluetoothSocket,
connId: String,
onMessage: suspend (connId: String, json: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
val reader = BufferedReader(InputStreamReader(s.inputStream))
try {
while (!closed) {
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
if (line.isNotBlank()) onMessage(connId, line)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("bt", "read-поток $connId оборван: ${e.message}")
} finally {
runCatching { reader.close() }
runCatching { s.close() }
sockets.remove(connId)
writers.remove(connId)
readerJobs.remove(connId)
}
if (!closed) {
log("bt", "очки отключились: $connId")
onDisconnected(connId)
}
}
/** JSON-строка в сокет [connId]: UTF-8 + разделитель, под синхронизацией на сокете. */
override suspend fun send(connId: String, json: String) {
val out = writers[connId] ?: return
runCatching {
synchronized(out) {
out.write(json.toByteArray(Charsets.UTF_8))
out.write(BtTransportConstants.NEWLINE.code)
out.flush()
}
}.onFailure { log("bt", "запись на $connId не удалась: ${it.message}") }
}
override fun close() {
closed = true
runCatching { serverSocket?.close() }
serverSocket = null
cleanupConnections()
readerScope.cancel()
}
private fun cleanupConnections() {
readerJobs.values.forEach { it.cancel() }
readerJobs.clear()
sockets.values.forEach { runCatching { it.close() } }
sockets.clear()
writers.clear()
}
}
@@ -1,23 +1,18 @@
package pw.binom.viewmate.phone
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.net.GlassesServerTransport
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.DownloadProgress
@@ -41,14 +36,18 @@ const val GLASSES_WS_PATH = "/ws/glasses"
/**
* Менеджер подключённых очков (телефон). Потокобезопасен (ConcurrentHashMap).
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие
* (Hello → Welcome, жесты/статус/позицию → лог, GlassesOff → пауза,
* DownloadProgress → карта скачивания на очки).
* Сессии адресуются **connId-строками** (UUID — WiFi, MAC-адрес — Bluetooth),
* не WS-объектами (TASK-transport.md п.1): WS-мост — в [WifiServerTransport],
* SPP-сокеты — в [BtServerTransport]. Исходящие — JSON-строкой через
* [GlassesServerTransport.send] (декод протокола — в хабe, не в транспорте).
*/
class GlassesHub(
private val state: PhoneState,
/** Выбранный транспорт (дефолт — [WifiServerTransport]). */
private val transport: GlassesServerTransport,
) {
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
/** Сессии: connId → Unit. */
private val sessions = ConcurrentHashMap<String, Unit>()
private val _connected = MutableStateFlow(0)
val connected: StateFlow<Int> = _connected.asStateFlow()
@@ -110,32 +109,29 @@ class GlassesHub(
@Volatile
var sttFactory: (() -> SttStreamer?)? = null
fun add(session: DefaultWebSocketServerSession) {
sessions[session] = Unit
fun add(connId: String) {
sessions[connId] = Unit
_connected.value = sessions.size
log("hub", "очки подключились, всего: ${sessions.size}")
log("hub", "очки подключились (connId $connId, всего: ${sessions.size})")
}
fun remove(session: DefaultWebSocketServerSession) {
sessions.remove(session)
fun remove(connId: String) {
sessions.remove(connId)
_connected.value = sessions.size
log("hub", "очки отключились, всего: ${sessions.size}")
log("hub", "очки отключились (connId $connId, всего: ${sessions.size})")
}
/** Отправить сообщение всем подключённым очкам. */
/** Отправить сообщение всем подключённым очкам (JSON-строкой через [transport]). */
suspend fun broadcast(msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
for (session in sessions.keys) {
try {
session.send(text)
} catch (e: Exception) {
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
}
for (connId in sessions.keys) {
runCatching { transport.send(connId, text) }
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
}
/** Обработать входящее текстовое сообщение от очков. */
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
/** Обработать входящее текстовое сообщение от очков (JSON-строкой из [transport]). */
suspend fun handle(connId: String, text: String) {
val msg = try {
protocolJson.decodeFromString(GlassesToHost.serializer(), text)
} catch (e: Exception) {
@@ -144,8 +140,8 @@ class GlassesHub(
}
when (msg) {
is Hello -> {
log("glasses", "hello (app=${msg.appVersion}) → welcome")
send(session, Welcome(mode = state.mode, sessionId = state.activeSessionId))
log("glasses", "hello (app=${msg.appVersion}, connId $connId) → welcome")
send(connId, Welcome(mode = state.mode, sessionId = state.activeSessionId))
}
is Gesture -> {
@@ -199,80 +195,67 @@ class GlassesHub(
is StopStt -> {
if (msg.cancel) {
log("stt", "распознавание отменено (очки)")
send(session, SttCancel())
send(connId, SttCancel())
stt?.reset()
} else {
val full = stt?.finish() ?: ""
log("stt", "ВЕСЬ ТЕКСТ: $full")
send(session, SttDone(full))
send(connId, SttDone(full))
stt?.reset()
}
}
}
}
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
private suspend fun send(connId: String, msg: HostToGlasses) {
val text = protocolJson.encodeToString(HostToGlasses.serializer(), msg)
session.send(text)
}
}
/** Ktor-модуль телефона: WebSocket-роут для подключения очков. */
fun Application.glassesServerModule(hub: GlassesHub) {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
hub.add(this)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
hub.handle(this, text)
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
hub.remove(this)
}
}
runCatching { transport.send(connId, text) }
.onFailure { log("hub", "не удалось отправить ${msg::class.simpleName} на $connId: ${it.message}") }
}
}
/**
* WS-сервер телефона (CIO-движок — работает на Android, в отличие от Netty).
* Живёт в приложении (Application), слушает 0.0.0.0:[port]/ws/glasses.
* Сервер связи очков (запускается в PhoneApp). [transport] выбирается флагом
* [useBluetooth] (TASK-transport.md п.4): дефолт — [WifiServerTransport]
* (Ktor CIO, `0.0.0.0:$port/ws/glasses`), Bluetooth — [BtServerTransport]
* (SPP). Поведение по умолчанию (WiFi) не меняется.
*/
class GlassesServer(
private val port: Int = PhoneConfig.SERVER_PORT,
private val state: PhoneState = PhoneState(),
/** `false` (дефолт) — WiFi; `true` — Bluetooth SPP. */
useBluetooth: Boolean = false,
/** Context для runtime-проверки BT-пермишенов (нужен только в [BtServerTransport]). */
private val btContext: Context? = null,
) {
val hub = GlassesHub(state)
/** Активный транспорт: [WifiServerTransport] (дефолт) или [BtServerTransport]. */
val transport: GlassesServerTransport =
if (useBluetooth) BtServerTransport(btContext) else WifiServerTransport(port)
val hub = GlassesHub(state, transport)
/** GlassesSender-адаптер поверх хаба (для PhoneActions). */
val sender: GlassesSender = object : GlassesSender {
override suspend fun send(msg: HostToGlasses) = hub.broadcast(msg)
}
private var server: EmbeddedServer<*, *>? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var acceptJob: Job? = null
/** Запустить приём подключений очков ([transport.accept] — бесконечный цикл). */
fun start() {
if (server != null) return
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesServerModule(hub)
if (acceptJob != null) return
acceptJob = scope.launch {
transport.accept(
onMessage = { connId, json -> hub.handle(connId, json) },
onConnected = { connId -> hub.add(connId) },
onDisconnected = { connId -> hub.remove(connId) },
)
}
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
}
fun stop() {
server?.stop(1_000, 3_000)
server = null
transport.close()
scope.cancel()
}
}
@@ -1,5 +1,7 @@
package pw.binom.viewmate.phone
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
@@ -7,6 +9,7 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.content.ContextCompat
import pw.binom.viewmate.phone.stt.SttDebug
import pw.binom.viewmate.phone.agent.LlmDebug
import pw.binom.viewmate.phone.ui.ViewMateApp
@@ -15,6 +18,7 @@ import java.io.File
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestBtPermissions()
setContent {
ViewMateApp()
}
@@ -26,6 +30,23 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Runtime-запрос Bluetooth-пермишенов (TASK-transport.md п.5): только при
* вкл. SPP ([PhoneConfig.USE_BLUETOOTH]). На 31+ — BLUETOOTH_CONNECT/SCAN,
* на ≤30 — BLUETOOTH/BLUETOOTH_ADMIN. Если уже выданы — молча пропускаем.
*/
private fun requestBtPermissions() {
if (!PhoneConfig.USE_BLUETOOTH) return
val needed = BtServerTransport.runtimePermissions()
val missing = needed.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
requestPermissions(missing.toTypedArray(), 1009)
log("bt", "запрошены Bluetooth-пермишены (SPP): ${missing.joinToString()}")
}
}
/** Smoke Koog-клиента: один вызов llm.binom.pw, ответ в лог [llm]. */
private fun runLlmDebug() {
showOverlay("[llm] вызываю llm.binom.pw...")
@@ -39,8 +39,13 @@ class PhoneApp : Application() {
val state: PhoneState = PhoneState()
/** WS-сервер для очков. */
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
/** Сервер связи очков: транспорт по [PhoneConfig.USE_BLUETOOTH] — дефолт WiFi (WS), Bluetooth SPP — флагом. */
val server: GlassesServer = GlassesServer(
port = PhoneConfig.SERVER_PORT,
state = state,
useBluetooth = PhoneConfig.USE_BLUETOOTH,
btContext = this,
)
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
@@ -103,13 +108,21 @@ class PhoneApp : Application() {
*/
private suspend fun assistantLoop() {
for (phrase in assistantChannel) {
log("assistant", "получена фраза: $phrase")
val assistant = ensureAssistant()
if (assistant == null) {
log("assistant", "LLM-ключ не задан — фраза пропущена")
continue
}
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
val answer = runCatching { assistant.process(phrase) }
val answer = runCatching {
val active = _activeSessionId.value
if (active != null) {
assistant.processInSession(active, phrase)
} else {
assistant.process(phrase)
}
}
.onFailure { log("assistant", "ошибка: ${it.message}") }
.getOrElse { "Ассистент недоступен: ${it.message}" }
server.hub.broadcast(ShowText(answer))
@@ -117,6 +130,36 @@ class PhoneApp : Application() {
}
}
/**
* Отправка текста в ассистента из чата на телефоне (не с очков).
* Та же очередь, что и голосовые фразы — сериализация LLM сохраняется.
*/
fun sendToAssistant(text: String) {
scope.launch { assistantChannel.send(text) }
}
/** Активная сессия чата (для UI телефона). null — «последняя» (дефолт). */
private val _activeSessionId = MutableStateFlow<Long?>(null)
val activeSessionId: StateFlow<Long?> = _activeSessionId
/** Создать новую сессию и сделать её активной. */
fun newChatSession(): Long {
val id = chatDao.createSession()
_activeSessionId.value = id
return id
}
/** Переключить активную сессию. null — вернуться к «последней». */
fun selectChatSession(id: Long?) {
_activeSessionId.value = id
}
/** Сбросить (очистить сообщения) активной или последней сессии. */
fun resetChatSession() {
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: return
chatDao.clearSession(id)
}
/**
* Лениво создаёт стриминговое распознавание (модели во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
@@ -189,6 +232,9 @@ class PhoneApp : Application() {
super.onCreate()
instance = this
PlaybackService.start(this)
log("server", "playback-сервис запущен (foreground, WAKE_LOCK)")
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish()
@@ -264,6 +310,7 @@ class PhoneApp : Application() {
}
override fun onTerminate() {
PlaybackService.stop(this)
nsd.unpublish()
sttStreamer?.close()
server.stop()
@@ -12,6 +12,13 @@ object PhoneConfig {
const val MIRROR_API_KEY = "67KA48ScP0qxRIihmr1srlggIIkRUAG"
const val SERVER_PORT = 8080
/**
* Выбор транспорта очки↔телефон (TASK-transport.md п.4):
* `false` (дефолт) — WiFi ([WifiServerTransport], поведение без изменений);
* `true` — Bluetooth SPP ([BtServerTransport], manual-включение).
*/
const val USE_BLUETOOTH = true
/** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */
const val AUDIO_SILENCE_PAUSE_MS = 5_000L
@@ -0,0 +1,75 @@
package pw.binom.viewmate.phone
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* Foreground service типа mediaPlayback: держит процесс телефона живым (WS-сервер +
* аудио-плеер) при выключенном экране. Без него HONOR замораживает/убивает процесс
* в фоне → очки теряют связь (видео паузится) и звук встаёт. Является спутником
* WS-сервера: стартует в PhoneApp.onCreate, останавливается в onTerminate.
*/
class PlaybackService : Service() {
companion object {
const val CHANNEL_ID = "viewmate_playback"
const val NOTIFICATION_ID = 1
fun start(context: Context) {
ContextCompat.startForegroundService(
context, Intent(context, PlaybackService::class.java)
)
}
fun stop(context: Context) {
context.stopService(Intent(context, PlaybackService::class.java))
}
}
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null
override fun onCreate() {
super.onCreate()
val channel = NotificationChannel(
CHANNEL_ID, "ViewMate playback",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("ViewMate")
.setContentText("Звук на телефоне при просмотре на очках")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// держим CPU и WiFi живыми при выключенном экране
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "viewmate:audio")
?.apply { acquire() }
wifiLock = (getApplicationContext().getSystemService(WIFI_SERVICE) as WifiManager)
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "viewmate:wifi")
?.apply { acquire() }
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
START_STICKY
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wifiLock?.takeIf { it.isHeld }?.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,98 @@
package pw.binom.viewmate.phone
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.delay
import pw.binom.viewmate.core.net.GlassesServerTransport
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* WiFi-транспорт, сторона телефона («сервер») — TASK-transport.md п.2:
* [GlassesServerTransport] на Ktor-CIO-сервере (WebSocket [GLASSES_WS_PATH]).
* Сессии — [connId] (UUID-строки, не WS-объекты): мост между WS-сессией и
* connId живёт здесь, [GlassesHub] знает только строки.
*/
class WifiServerTransport(
private val port: Int = PhoneConfig.SERVER_PORT,
) : GlassesServerTransport {
override val name = "wifi"
private val sessionByConn = ConcurrentHashMap<String, DefaultWebSocketServerSession>()
@Volatile
private var server: EmbeddedServer<*, *>? = null
@Volatile
private var closed = false
/** Бесконечно принимать очки: старт Ktor-сервера + холд (пока [close]). */
override suspend fun accept(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
server = embeddedServer(CIO, port = port, host = "0.0.0.0") {
glassesWsModule(onMessage, onConnected, onDisconnected)
}
server!!.start(wait = false)
log("server", "WS-сервер запущен на 0.0.0.0:$port$GLASSES_WS_PATH")
while (!closed) {
delay(500)
}
}
/** Ktor-модуль: WS-роут очков; сессия адресуется connId (UUID), события — колбэки [accept]. */
private fun Application.glassesWsModule(
onMessage: suspend (connId: String, json: String) -> Unit,
onConnected: (connId: String) -> Unit,
onDisconnected: (connId: String) -> Unit,
) {
install(WebSockets) {
pingPeriodMillis = 5_000
timeoutMillis = 5_000
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(GLASSES_WS_PATH) {
val connId = UUID.randomUUID().toString()
sessionByConn[connId] = this
onConnected(connId)
try {
for (frame in incoming) {
if (frame is Frame.Text) {
onMessage(connId, frame.readText())
}
}
} catch (e: Exception) {
log("server", "WS-ошибка: ${e.message}")
e.printStackTrace()
} finally {
sessionByConn.remove(connId)
onDisconnected(connId)
}
}
}
}
override suspend fun send(connId: String, json: String) {
sessionByConn[connId]?.send(json)
}
override fun close() {
closed = true
runCatching { server?.stop(1_000, 3_000) }
server = null
sessionByConn.clear()
}
}
@@ -0,0 +1,53 @@
package pw.binom.viewmate.phone.agent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Тул ассистента: локальная функция, которую агент (LLM) может вызвать по
* имени. Результат возвращается строкой и подставляется в историю как
* tool-сообщение (роль [ChatRole.TOOL]).
*
* Добавление нового тула: реализовать [AgentTool], добавить в список
* [AgentTools.ALL]. Агент узнаёт о тулах из системного промпта (описание
* [describe]) — никакой магии.
*/
interface AgentTool {
/** Имя тула (как агент его называет, например `get_current_time`). */
val name: String
/** Короткое описание для системного промпта (что делает, что возвращает). */
val description: String
/** Вызвать тул. [arguments] — JSON-строка (пустая, если аргументов нет). */
fun invoke(arguments: String): String
}
/** Реестр тулов ассистента. */
object AgentTools {
val ALL: List<AgentTool> = listOf(TimeTool)
/** Описания для системного промпта: «Тул: name — description». */
fun describeAll(): String = ALL.joinToString("\n") { "Тул: ${it.name} — ${it.description}" }
}
/**
* Тул времени: возвращает текущие дату, время и часовой пояс в ISO 8601
* (например `2026-08-22T03:14:05+03:00[Europe/Moscow]`). Локально на
* телефоне — LLM не нужна, время точное.
*/
object TimeTool : AgentTool {
override val name = "get_current_time"
override val description =
"Текущие дата и время с часовым поясом в формате ISO 8601. Аргументы не нужны (пустой JSON {})."
private val formatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")
override fun invoke(arguments: String): String {
val now = Instant.now()
val zone = ZoneId.systemDefault()
return formatter.format(now.atZone(zone))
}
}
@@ -7,31 +7,98 @@ import kotlinx.coroutines.withTimeoutOrNull
* LLM → ответ. Потоковый режим: каждая распознанная фраза уходит в LLM,
* история накапливается в сессии. Синхронный вызов из одной корутины
* (сериализация фраз — LLM-вызовы не пересекаются).
*
* Тулы: агент видит их в системном промпте (см. [SYSTEM_PROMPT]); если ему
* нужна тула, он отвечает одной строкой `TOOL_CALL: <имя>` — ассистент
* выполняет тул и возвращает результат как tool-сообщение в истории,
* затем делает второй вызов LLM с результатом. Обычный текст (без маркера)
* возвращается пользователю как финальный ответ.
*/
class Assistant(
private val chatDao: ChatDao,
private val chat: suspend (history: List<ChatMessage>, system: String?) -> String,
private val tools: List<AgentTool> = AgentTools.ALL,
private val timeoutMs: Long = 30_000,
) {
/**
* Обработать фразу: сохранить user-сообщение, вызвать LLM с историей
* сессии (последние 20 сообщений), сохранить ответ. При таймауте LLM —
* ответ с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
* Обработать фразу в текущей (последней) сессии: сохранить user-сообщение,
* вызвать LLM с историей сессии (последние 20 сообщений), при вызове тула —
* выполнить и повторить вызов, сохранить ответ. При таймауте LLM — ответ
* с текстом таймаута, в БД пишется ТОЛЬКО user-сообщение.
*/
suspend fun process(text: String): String {
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession("ассистент")
val sessionId = chatDao.latestSessionId() ?: chatDao.createSession(randomTitle())
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
return processInSession(sessionId)
}
/** Обработать фразу в КОНКРЕТНОЙ сессии [sessionId] (переключение сессий). */
suspend fun processInSession(sessionId: Long, text: String): String {
chatDao.addMessage(sessionId, role = ChatRole.USER, content = text)
return processInSession(sessionId)
}
private suspend fun processInSession(sessionId: Long): String {
val history = chatDao.messagesForSession(sessionId).takeLast(20)
val answer = withTimeoutOrNull(timeoutMs) { chat(history, SYSTEM_PROMPT) }
var answer = callLlm(history, sessionId)
var lastToolResult: String? = null
// Цикл тулов: до 3 вызовов подряд (защита от зацикливания агента).
repeat(3) {
val toolName = parseToolCall(answer) ?: return answer
val tool = tools.firstOrNull { it.name == toolName }
if (tool == null) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
return "Тул $toolName не найден"
}
val result = runCatching { tool.invoke("{}") }.getOrElse { "ошибка тула: ${it.message}" }
lastToolResult = result
chatDao.addMessage(sessionId, role = ChatRole.TOOL, content = result, toolCallId = toolName)
logTool(sessionId, toolName, result)
val withResult = chatDao.messagesForSession(sessionId).takeLast(20)
answer = callLlm(withResult, sessionId)
}
// LLM зациклилась на TOOL_CALL — отдаём последний результат тула как ответ,
// чтобы пользователь получил данные, а не сырой маркер.
val toolName = parseToolCall(answer)
return if (toolName != null && lastToolResult != null) {
"TOOL_CALL $toolName (повтор) — $lastToolResult"
} else {
answer
}
}
private suspend fun callLlm(history: List<ChatMessage>, sessionId: Long): String {
val answer = withTimeoutOrNull(timeoutMs) { chat(history, systemPrompt()) }
?: return "Ассистент не ответил (таймаут)"
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.touchSession(sessionId)
if (!isToolCall(answer)) {
chatDao.addMessage(sessionId, role = ChatRole.ASSISTANT, content = answer)
chatDao.touchSession(sessionId)
}
return answer
}
/** Системный промпт: роль + описания тулов (если есть). */
private fun systemPrompt(): String {
val base = "Ты — ассистент. Делай то, что говорит пользователь. Отвечай кратко (1-3 предложения), по-русски."
if (tools.isEmpty()) return base
return "$base\n\nЕсли для ответа нужна тула, ответь ровно одной строкой TOOL_CALL: имя_тула (без кавычек, без текста вокруг). После получения результата тула ответь пользователю текстом.\n\n${AgentTools.describeAll()}"
}
companion object {
// TODO(персона): промпт выберет пользователь
const val SYSTEM_PROMPT =
"Ты — локальный голосовой ассистент в AR-очках. Отвечай кратко (1-3 предложения), по-русски, без markdown и эмодзи. Рядом с тобой может идти фильм — вопросы могут быть про него."
/** Простой протокол вызова тула: строка вида `TOOL_CALL: <name>`. */
private val TOOL_CALL_RE = Regex("""TOOL_CALL\s*:\s*([A-Za-z0-9_]+)""", RegexOption.IGNORE_CASE)
private fun parseToolCall(answer: String): String? =
TOOL_CALL_RE.find(answer)?.groupValues?.get(1)?.lowercase()
private fun isToolCall(answer: String): Boolean = parseToolCall(answer) != null
/** Случайное имя сессии (UUID-подобное, 8 символов). */
private fun randomTitle(): String =
"сессия-" + (1..8).map { "0123456789abcdef"[kotlin.random.Random.nextInt(16)] }.joinToString("")
private fun logTool(sessionId: Long, toolName: String, result: String) {
pw.binom.viewmate.core.log("assistant", "тул $toolName (сессия $sessionId) → $result")
}
}
}
@@ -32,6 +32,25 @@ class ChatDao(private val db: ChatDb) {
return cursor.use { c -> if (c.moveToFirst()) c.getLong(0) else null }
}
/** Сессия по id, или null, если не существует. */
fun getSession(id: Long): Session? {
val cursor = db.readableDatabase.rawQuery(
"SELECT id, title, created_at, updated_at FROM sessions WHERE id = ?",
arrayOf(id.toString()),
)
return cursor.use { c -> if (c.moveToFirst()) c.toSession() else null }
}
/** Сброс сессии: удалить все сообщения, сессия остаётся пустой. */
fun clearSession(id: Long) {
db.writableDatabase.delete(
ChatDb.TABLE_MESSAGES,
"session_id = ?",
arrayOf(id.toString()),
)
touchSession(id)
}
fun renameSession(id: Long, title: String) {
db.writableDatabase.update(
ChatDb.TABLE_SESSIONS,
@@ -1,64 +1,107 @@
package pw.binom.viewmate.phone.agent
import ai.koog.http.client.okhttp.OkHttpKoogHttpClient
import ai.koog.prompt.dsl.prompt
import ai.koog.prompt.executor.clients.openai.OpenAIClientSettings
import ai.koog.prompt.executor.clients.openai.OpenAILLMClient
import ai.koog.prompt.llm.LLMCapability
import ai.koog.prompt.llm.LLModel
import ai.koog.prompt.llm.LLMProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* Базовый LLM-клиент на Koog (JetBrains), OpenAI-совместимый эндпоинт
* https://llm.binom.pw (chatCompletionsPath по умолчанию "v1/chat/completions").
* LLM-клиент на прямом HTTP (OkHttp) к OpenAI-совместимому эндпоинту
* https://llm.binom.pw/v1/chat/completions.
*
* Модель и виртуальный ключ llm.binom.pw даст пользователь (llm.apiKey в local.properties).
* Пустой ключ — клиент создаётся, но вызовы вернут ошибку авторизации (это ожидаемо).
* Коog-клиент (JetBrains) работал с нашей моделью нестабильно: возвращал пустой
* content на длинных историях (после tool-вызовов) — см. заметку
* /root/notes/2026-08-22-viewmate-night.md. Прямой вызов проверен curl'ом:
* модель Qwen3.8-27B-NVFP4 отвечает стабильно, тул-цикл работает.
*
* Класс НЕ singleton и не создаётся в Application: ассистент создаст его при старте сессии.
* Модель и виртуальный ключ llm.binom.pw — из local.properties (llm.apiKey/llm.model).
*/
class LlmClient(
private val apiKey: String,
private val baseUrl: String = "https://llm.binom.pw",
private val model: String, // модель укажет пользователь
private val model: String,
) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val client: OpenAILLMClient = OpenAILLMClient(
apiKey = apiKey,
settings = OpenAIClientSettings(baseUrl = baseUrl),
httpClientFactory = OkHttpKoogHttpClient.Factory(),
private val http = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
@Serializable
private data class ChatMessageDto(
val role: String,
val content: String,
)
private val llmModel: LLModel = LLModel(
provider = LLMProvider.OpenAI,
id = model,
capabilities = listOf(
LLMCapability.Completion,
LLMCapability.OpenAIEndpoint.Completions,
),
@Serializable
private data class ChatRequest(
val model: String,
val messages: List<ChatMessageDto>,
val max_tokens: Int = 1024,
val temperature: Double = 0.3,
)
@Serializable
private data class ChatResponse(
val choices: List<Choice> = emptyList(),
val error: ApiError? = null,
) {
@Serializable
data class Choice(val message: Message = Message())
@Serializable
data class Message(val content: String? = null)
@Serializable
data class ApiError(val message: String? = null)
}
/**
* Отправить историю сообщений и вернуть текстовый ответ (без стриминга).
* System-сообщение, если задано, добавляется первым.
* System-сообщение, если задано, добавляется первым. TOOL-сообщения —
* как user-текст «Результат тула <name>: <content>» (в Koog не было tool-роли).
*/
suspend fun chat(messages: List<ChatMessage>, system: String? = null): String {
val prompt = prompt("assistant-chat") {
if (system != null) {
this.system(system)
}
for (msg in messages) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) this.system(msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) user(msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) assistant(msg.content)
// TODO(assistant): в Koog нет отдельной tool-роли — результаты инструментов
// моделируются как MessagePart.Tool.Result внутри user-сообщения, но для этого
// нужен tool-name, которого в ChatMessage нет. Пропускаем (базовый клиент).
ChatRole.TOOL -> Unit
val dto = ArrayList<ChatMessageDto>()
if (system != null) dto += ChatMessageDto("system", system)
for (msg in messages) {
when (msg.role) {
ChatRole.SYSTEM -> if (msg.content.isNotBlank()) dto += ChatMessageDto("system", msg.content)
ChatRole.USER -> if (msg.content.isNotBlank()) dto += ChatMessageDto("user", msg.content)
ChatRole.ASSISTANT -> if (msg.content.isNotBlank()) dto += ChatMessageDto("assistant", msg.content)
ChatRole.TOOL -> if (msg.content.isNotBlank()) {
dto += ChatMessageDto("user", "Результат тула ${msg.toolCallId ?: "?"}: ${msg.content}")
}
}
}
return client.execute(prompt, llmModel).textContent()
if (dto.none { it.role == "user" }) dto += ChatMessageDto("user", "Продолжай.")
val body = json.encodeToString(ChatRequest.serializer(), ChatRequest(model = model, messages = dto))
val request = Request.Builder()
.url("$baseUrl/v1/chat/completions")
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
return withContext(Dispatchers.IO) {
http.newCall(request).execute().use { resp ->
val raw = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
val err = runCatching { json.decodeFromString<ChatResponse>(raw).error?.message }.getOrNull()
throw IllegalStateException("HTTP ${resp.code}: ${err ?: raw.take(200)}")
}
val text = runCatching {
json.decodeFromString<ChatResponse>(raw).choices.firstOrNull()?.message?.content
}.getOrNull()
text?.trim() ?: ""
}
}
}
}
@@ -1,35 +1,222 @@
package pw.binom.viewmate.phone.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import pw.binom.viewmate.phone.PhoneApp
import pw.binom.viewmate.phone.agent.ChatRole
import pw.binom.viewmate.phone.agent.Session
/** Экран 4 — Чат с ассистентом (заглушка: отдельный этап, Hermes-интеграция). */
/**
* Экран «Чат с Порфирием»: история из SQLite, ввод текста, отправка в
* локального ассистента (та же очередь, что и голос с очков). Управление
* сессиями: новая, переключение (выпадающий список), сброс текущей.
*/
@Composable
fun ChatScreen() {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
val app = PhoneApp.instance
val scope = rememberCoroutineScope()
var sessions by remember { mutableStateOf<List<Session>>(emptyList()) }
var messages by remember { mutableStateOf<List<Pair<ChatRole, String>>>(emptyList()) }
var input by remember { mutableStateOf("") }
var sessionMenu by remember { mutableStateOf(false) }
var activeId by remember { mutableStateOf<Long?>(null) }
val listState = rememberLazyListState()
// Загрузка списка сессий и сообщений при старте и при смене активной.
LaunchedEffect(activeId) {
val dao = app.chatDao
val (sess, msgs) = withContext(Dispatchers.IO) {
val all = dao.listSessions()
val target = activeId ?: all.firstOrNull()?.id
val history = target?.let { dao.messagesForSession(it) } ?: emptyList()
all to history.map { it.role to it.content }
}
sessions = sess
activeId = activeId ?: sess.firstOrNull()?.id
messages = msgs
}
// Подписка на смену активной сессии извне (кнопки в PhoneApp).
val externalActive by app.activeSessionId.collectAsState()
LaunchedEffect(externalActive) {
if (externalActive != null && externalActive != activeId) {
activeId = externalActive
}
}
// Автопрокрутка вниз при новых сообщениях.
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
}
// Периодическая перезагрузка сообщений из БД (ответ ассистента приходит
// асинхронно — лента должна подхватывать его без ручного обновления).
LaunchedEffect(activeId) {
while (true) {
delay(2_000)
val id = activeId
if (id != null) {
val fresh = withContext(Dispatchers.IO) { app.chatDao.messagesForSession(id).map { it.role to it.content } }
if (fresh != messages) messages = fresh
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
// Шапка: название + управление сессиями
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Порфирий",
color = PhoneText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
// Переключение сессий
Box {
TextButton(onClick = { sessionMenu = true }) {
Text(
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
color = PhoneAccent,
fontSize = 12.sp,
)
}
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
sessions.forEach { s ->
DropdownMenuItem(
text = { Text(s.title, fontSize = 12.sp) },
onClick = {
activeId = s.id
app.selectChatSession(s.id)
sessionMenu = false
},
)
}
DropdownMenuItem(
text = { Text("+ новая сессия", fontSize = 12.sp, color = PhoneAccent) },
onClick = {
activeId = app.newChatSession()
sessionMenu = false
},
)
}
}
// Сброс текущей
TextButton(onClick = {
app.resetChatSession()
activeId?.let { id ->
val history = app.chatDao.messagesForSession(id).map { it.role to it.content }
messages = history
}
}) {
Text("сброс", color = PhoneMuted, fontSize = 12.sp)
}
}
Spacer(Modifier.height(4.dp))
// Лента сообщений
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
if (messages.isEmpty()) {
Text(
text = "Спроси что-нибудь — например, «сколько сейчас время?»",
color = PhoneMuted,
fontSize = 13.sp,
modifier = Modifier.padding(16.dp),
)
} else {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(messages) { (role, content) ->
MessageBubble(role = role, text = content)
}
}
}
}
Spacer(Modifier.height(8.dp))
// Ввод
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Сообщение…", fontSize = 14.sp, color = PhoneMuted) },
textStyle = MaterialTheme.typography.bodyMedium.copy(color = PhoneText),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val text = input.trim()
if (text.isEmpty()) return@Button
input = ""
messages = messages + (ChatRole.USER to text)
app.sendToAssistant(text)
},
) {
Text("➤", fontSize = 16.sp)
}
}
}
}
/** Пузырь сообщения: user — справа акцентный, assistant — слева карточка. */
@Composable
private fun MessageBubble(role: ChatRole, text: String) {
val isUser = role == ChatRole.USER
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Text(
text = "Чат с Порфирием",
text = text,
color = PhoneText,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "в разработке (следующий этап)",
color = PhoneMuted,
fontSize = 13.sp,
modifier = Modifier.padding(top = 8.dp),
fontSize = 14.sp,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
.background(
color = if (isUser) PhoneSurface else PhoneCard,
shape = RoundedCornerShape(10.dp),
)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}