Сохраняем прогресс просмотра и возобновляем с позиции
This commit is contained in:
@@ -32,6 +32,9 @@ object MercuryBridge {
|
||||
|
||||
private const val TAG = "MercuryBridge"
|
||||
|
||||
/** Пакет нашего очкового приложения (applicationId из app-glasses). */
|
||||
const val VIEWMATE_GLASSES_PKG = "pw.binom.viewmate.glasses"
|
||||
|
||||
private var mercury: RayNeoMercury? = null
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@@ -234,6 +237,16 @@ object MercuryBridge {
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить приложение на очках по пакету (протокол «app-market», [RayNeoMercury.appMarketLaunch]).
|
||||
* Очки сами открывают приложение по [pkg]; [extras] — опциональные аргументы (как Intent).
|
||||
*/
|
||||
suspend fun launchApp(pkg: String, extras: Map<String, String>? = null): Boolean {
|
||||
val ok = mercury?.appMarketLaunch(pkg, extras) ?: false
|
||||
setStatus(if (ok) "запуск приложения $pkg на очках" else "ошибка отправки (нет связи?)")
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Автоблокировка экрана через N секунд; -1 = никогда. */
|
||||
suspend fun autoLockTime(time: Int): Boolean {
|
||||
val ok = mercury?.setAutoLockTime(time) ?: false
|
||||
|
||||
@@ -167,6 +167,9 @@ class PhoneApp : Application() {
|
||||
val localLlmState: StateFlow<LocalLlmModelState> by lazy { _localLlmState.asStateFlow() }
|
||||
|
||||
private var localLlmDownloadJob: Job? = null
|
||||
/** Пока true — движок автоповтора: после ошибки повторит скачивание через 60с. */
|
||||
private var localLlmAutoRetry = false
|
||||
|
||||
private fun initialLocalLlmState(): LocalLlmModelState =
|
||||
if (localModelFile().isFile) LocalLlmModelState.Ready else LocalLlmModelState.Idle
|
||||
|
||||
@@ -186,30 +189,41 @@ class PhoneApp : Application() {
|
||||
* Скачать локальную модель Gemma 4 E2B (файл в getExternalFilesDir(null)),
|
||||
* прогресс в процентах в [localLlmState], контрольная сумма SHA-256.
|
||||
* Повторный вызов во время скачивания — no-op.
|
||||
* Авто-повтор: при ошибке (если [localLlmAutoRetry] не сброшен кнопкой «стоп»)
|
||||
* ждёт 60с и снова пытается, пока файла нет.
|
||||
*/
|
||||
fun downloadLocalLlmModel() {
|
||||
val current = localLlmDownloadJob
|
||||
if (current != null && current.isActive) return
|
||||
localLlmAutoRetry = true
|
||||
localLlmDownloadJob = scope.launch {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
while (localLlmAutoRetry && !localModelFile().isFile) {
|
||||
val target = localModelFile()
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(0)
|
||||
try {
|
||||
GemmaModelDownloader(target).download { percent ->
|
||||
_localLlmState.value = LocalLlmModelState.Downloading(percent)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
return@launch
|
||||
} catch (e: CancellationException) {
|
||||
_localLlmState.value = LocalLlmModelState.Idle
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val msg = e.message ?: e.toString()
|
||||
_localLlmState.value = LocalLlmModelState.Error(msg)
|
||||
log("llm", "не удалось скачать модель: $msg — авто-повтор через 60с")
|
||||
delay(60_000)
|
||||
}
|
||||
_localLlmState.value = LocalLlmModelState.Ready
|
||||
log("llm", "локальная модель скачана: ${target.name}, ${target.length()} байт")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_localLlmState.value = LocalLlmModelState.Error(e.message ?: "неизвестная ошибка")
|
||||
log("llm", "не удалось скачать модель: ${e.message}")
|
||||
}
|
||||
// отменено пользователем («стоп»): [localLlmAutoRetry] сброшен, [localLlmState]=Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** Остановить скачивание локальной модели (частичный файл удаляется). */
|
||||
/** Остановить скачивание локальной модели (частичный файл остаётся, автоповтор гаснет). */
|
||||
fun cancelLocalLlmDownload() {
|
||||
localLlmAutoRetry = false
|
||||
localLlmDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
@@ -313,25 +327,35 @@ class PhoneApp : Application() {
|
||||
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
||||
*/
|
||||
private val NO_LLM_ANSWER =
|
||||
"Серверный LLM не настроен (нет API-ключа в local.properties). " +
|
||||
"Переключитесь на локальный LLM (Gemma 4 E2B) — кнопкой в чате или в настройках, " +
|
||||
"либо задайте llm.apiKey и пересоберите."
|
||||
|
||||
private suspend fun assistantLoop() {
|
||||
for (phrase in assistantChannel) {
|
||||
log("assistant", "получена фраза: $phrase")
|
||||
val assistant = ensureAssistant()
|
||||
val answer: String
|
||||
if (assistant == null) {
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — фраза пропущена")
|
||||
continue
|
||||
}
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
val answer = runCatching {
|
||||
val active = _activeSessionId.value
|
||||
if (active != null) {
|
||||
assistant.processInSession(active, phrase)
|
||||
} else {
|
||||
assistant.process(phrase)
|
||||
// Серверный LLM без API-ключа: не «пропущена в никуда», а явная
|
||||
// подсказка + сохранение фразы в БД (чтобы сообщение не исчезало).
|
||||
log("assistant", "серверный LLM без ключа (LLM_API_KEY) — не настроен, подсказка пользователю")
|
||||
answer = NO_LLM_ANSWER
|
||||
persistNoLlm(phrase)
|
||||
} else {
|
||||
server.hub.broadcast(AssistantStateMsg(AssistantState.THINKING))
|
||||
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}" }
|
||||
}
|
||||
.onFailure { log("assistant", "ошибка: ${it.message}") }
|
||||
.getOrElse { "Ассистент недоступен: ${it.message}" }
|
||||
server.hub.broadcast(ShowText(answer))
|
||||
// Очки показывают историю всей сессии (свайпами); ответ уже в ней.
|
||||
server.hub.broadcast(chatHistoryMsg())
|
||||
@@ -339,6 +363,16 @@ class PhoneApp : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нет работающего ассистента (сервер без ключа): сохраняем и user-фразу, и
|
||||
* короткую подсказку, чтобы в чате/на очках не было «провала» в истории.
|
||||
*/
|
||||
private fun persistNoLlm(phrase: String) {
|
||||
val id = _activeSessionId.value ?: chatDao.latestSessionId() ?: chatDao.createSession("нет LLM")
|
||||
chatDao.addMessage(id, role = ChatRole.USER, content = phrase)
|
||||
chatDao.addMessage(id, role = ChatRole.ASSISTANT, content = NO_LLM_ANSWER)
|
||||
}
|
||||
|
||||
/**
|
||||
* История активной (или последней) сессии для очков: только роли user/assistant,
|
||||
* tool-сообщения и toolCalls очкам не нужны (ТЗ-касяк №1).
|
||||
@@ -499,6 +533,13 @@ class PhoneApp : Application() {
|
||||
|
||||
scope.launch { assistantLoop() }
|
||||
|
||||
// Автодокачка локальной LLM-модели при старте, если файла нет.
|
||||
// Каскадный автоповтор (60с) до успеха или «стоп» в настройках.
|
||||
if (!localModelFile().isFile) {
|
||||
log("llm", "локальной модели нет — запускаю авто-скачивание (Gemma 4 E2B, ${Gemma4E2B.FILE_NAME})")
|
||||
downloadLocalLlmModel()
|
||||
}
|
||||
|
||||
val lastPositionAt = AtomicLong(0)
|
||||
val lastWasPlaying = AtomicBoolean(false)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ fun ChatScreen() {
|
||||
Box {
|
||||
TextButton(onClick = { sessionMenu = true }) {
|
||||
Text(
|
||||
text = sessions.firstOrNull { it.id == activeId }?.title ?: "сессия…",
|
||||
text = sessions.firstOrNull { it.id == activeId }?.title?.takeIf { it.isNotBlank() } ?: "нет",
|
||||
color = PhoneAccent,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
@@ -165,7 +165,7 @@ fun ChatScreen() {
|
||||
DropdownMenu(expanded = sessionMenu, onDismissRequest = { sessionMenu = false }) {
|
||||
sessions.forEach { s ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(s.title, fontSize = 12.sp) },
|
||||
text = { Text(s.title.ifBlank { "нет" }, fontSize = 12.sp) },
|
||||
onClick = {
|
||||
activeId = s.id
|
||||
app.selectChatSession(s.id)
|
||||
@@ -198,12 +198,29 @@ fun ChatScreen() {
|
||||
|
||||
// Индикатор используемой модели LLM (переключается в настройках «Инфо об очках»).
|
||||
val llmChoice by app.llmChoice.collectAsState()
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B" else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "Локальный LLM · Gemma 4 E2B"
|
||||
else "Серверный LLM · llm.binom.pw",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
// Переключить бэкенд прямо в чате (без захода в настройки).
|
||||
Text(
|
||||
text = if (llmChoice == LlmModelChoice.LOCAL) "→ серверный" else "→ локальный",
|
||||
color = PhoneAccent,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
app.setLlmModelChoice(
|
||||
if (llmChoice == LlmModelChoice.LOCAL) LlmModelChoice.REMOTE
|
||||
else LlmModelChoice.LOCAL,
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Лента сообщений
|
||||
|
||||
@@ -201,53 +201,60 @@ private fun LlmModelSection() {
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
if (choice == LlmModelChoice.LOCAL) {
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Модель готова (Gemma 4 E2B, локально)",
|
||||
color = PhoneGreen,
|
||||
// Индикатор локальной модели — независимо от выбора бэкенда: пока модель
|
||||
// скачивается (в т.ч. авто) или упало, видно всегда, а не только в ветке LOCAL.
|
||||
when (val s = llmState) {
|
||||
LocalLlmModelState.Idle -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Локальная модель: не скачана (${Gemma4E2B.SIZE_HUMAN})",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
LlmSmallAction("скачать") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
} else {
|
||||
|
||||
is LocalLlmModelState.Downloading -> Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProgressBar(
|
||||
percent = s.percent,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
LlmSmallAction("стоп") { app.cancelLocalLlmDownload() }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Скачивание локальной модели… ${s.percent}%",
|
||||
color = PhoneMuted,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LocalLlmModelState.Ready -> Text(
|
||||
text = "Локальная модель готова (Gemma 4 E2B)",
|
||||
color = PhoneGreen,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
|
||||
is LocalLlmModelState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Ошибка скачивания: ${s.message}",
|
||||
color = PhoneRed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
LlmSmallAction("повторить") { app.downloadLocalLlmModel() }
|
||||
}
|
||||
}
|
||||
|
||||
// Подпись серверного бэкенда — когда выбран сервер и локальная модель не в пути/ошибке.
|
||||
if (
|
||||
choice == LlmModelChoice.REMOTE &&
|
||||
llmState !is LocalLlmModelState.Downloading &&
|
||||
llmState !is LocalLlmModelState.Error
|
||||
) {
|
||||
Text(text = "Модель: сервер llm.binom.pw", color = PhoneMuted, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||
import pw.binom.viewmate.phone.PhoneApp
|
||||
import pw.binom.viewmate.phone.agent.LocalLlmModelState
|
||||
import pw.binom.viewmate.phone.glassesLivePercent
|
||||
|
||||
/** Экран 3 — Статус очков + пульт. */
|
||||
@@ -61,6 +62,7 @@ fun GlassesScreen(
|
||||
val playing by app.state.playingFlow.collectAsState()
|
||||
val mode by app.state.modeFlow.collectAsState()
|
||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||
val llmState by app.localLlmState.collectAsState()
|
||||
val durationMs = currentItem?.RunTimeTicks?.div(10_000)
|
||||
|
||||
var showDownloads by remember { mutableStateOf(false) }
|
||||
@@ -222,6 +224,18 @@ fun GlassesScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// авто-скачивание локальной LLM-модели (Gemma 4 E2B)
|
||||
val llmModelState = llmState
|
||||
if (llmModelState is LocalLlmModelState.Downloading) {
|
||||
Text(
|
||||
text = "скачивание LLM-модели: ${llmModelState.percent}%",
|
||||
color = PhoneYellow,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
||||
@@ -197,6 +197,19 @@ fun MercuryPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Button(
|
||||
onClick = { scope.launch { MercuryBridge.launchApp(MercuryBridge.VIEWMATE_GLASSES_PKG) } },
|
||||
enabled = state.connected,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PhoneBlue,
|
||||
contentColor = PhoneBlueText,
|
||||
),
|
||||
) {
|
||||
Text("Запустить приложение", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Яркость ----
|
||||
Text(
|
||||
text = "Яркость: $brightness%",
|
||||
|
||||
Reference in New Issue
Block a user