Ассистент Порфирий: тула времени, сессии, чат-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:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user