Агент: медиа-тулсет (search/info/play) на Jellyfin + скилл media
- MEDIA_TOOLSET + MediaToolExecutor: search (поиск с лимитом), info (детали), play (проверки+запуск) - JellyfinMediaSource: обёртка JellyfinClient (search/item) с защитой userId - agentPlayMedia: проверки (очки на связи, видео скачано на очки, звук на телефоне) → watch с позицией - PhoneActions.watch(startPositionMs), JellyfinItem.ProductionYear - скилл app-phone/skills/media.md (frontmatter: name/description) - MediaToolsTest: 22 кейса — всего 142 теста зелёные
This commit is contained in:
@@ -28,7 +28,10 @@ import pw.binom.viewmate.phone.agent.AgentToolkit
|
||||
import pw.binom.viewmate.phone.agent.Assistant
|
||||
import pw.binom.viewmate.phone.agent.ChatDao
|
||||
import pw.binom.viewmate.phone.agent.ChatDb
|
||||
import pw.binom.viewmate.phone.agent.JellyfinMediaSource
|
||||
import pw.binom.viewmate.phone.agent.LlmClient
|
||||
import pw.binom.viewmate.phone.agent.MEDIA_TOOLSET
|
||||
import pw.binom.viewmate.phone.agent.MediaToolExecutor
|
||||
import pw.binom.viewmate.phone.agent.SkillRegistry
|
||||
import pw.binom.viewmate.phone.agent.SkillRepository
|
||||
import pw.binom.viewmate.phone.agent.ToolsetRegistry
|
||||
@@ -102,9 +105,11 @@ class PhoneApp : Application() {
|
||||
assistant?.let { return it }
|
||||
if (BuildConfig.LLM_API_KEY.isBlank()) return null
|
||||
val llm = LlmClient(apiKey = BuildConfig.LLM_API_KEY, model = BuildConfig.LLM_MODEL)
|
||||
val toolsets = ToolsetRegistry()
|
||||
toolsets.register(MEDIA_TOOLSET, mediaToolExecutor())
|
||||
val tk = toolkit ?: AgentToolkit(
|
||||
skills = SkillRegistry(SkillRepository(File(filesDir, "skills")).loadAll()),
|
||||
toolsets = ToolsetRegistry(),
|
||||
toolsets = toolsets,
|
||||
showText = { text -> scope.launch { server.hub.broadcast(ShowText(text)) } },
|
||||
).also { toolkit = it }
|
||||
return Assistant(
|
||||
@@ -115,6 +120,49 @@ class PhoneApp : Application() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Тулсет media: тулы работают через [JellyfinMediaSource] (каталог) и уровня-приложения [agentPlayMedia] («включить на очках»). */
|
||||
private fun mediaToolExecutor(): MediaToolExecutor = MediaToolExecutor(
|
||||
media = JellyfinMediaSource(jellyfin) { userId.value },
|
||||
player = { id, seekMs -> agentPlayMedia(id, seekMs) },
|
||||
)
|
||||
|
||||
/**
|
||||
* Тул ассистента media_play: проверки (очки на связи, видео скачано на очки,
|
||||
* звук скачан на телефон) + запуск через [PhoneActions.watch] (механизм DetailsScreen)
|
||||
* + звук на телефоне выбранной дорожкой с позиции [seekMs]. Возвращает текст для модели.
|
||||
*/
|
||||
private suspend fun agentPlayMedia(id: String, seekMs: Long): String {
|
||||
val hub = server.hub
|
||||
if (hub.connected.value == 0) return "Очки не подключены."
|
||||
val mediaDir = hub.mediaDir.value
|
||||
if (mediaDir.isNullOrBlank()) return "Очки ещё не опубликовали медиакаталог — попробуйте позже."
|
||||
if (id !in hub.downloadedItemIds.value) return "Видео не скачано на очки. Скачайте его вручную через телефон."
|
||||
if (!downloadManager.audioReady(id)) return "Звук не готов: сначала скачайте аудио на телефон."
|
||||
val acts = actions ?: return "Телефон ещё не готов."
|
||||
val audioIndex = state.currentAudioIndex
|
||||
log("agent", "play: $id, звук #$audioIndex, seek=${seekMs} мс")
|
||||
|
||||
val result = acts.watch(
|
||||
itemId = id,
|
||||
audioIndex = audioIndex,
|
||||
localVideoPath = "file://$mediaDir/$id/video.mkv",
|
||||
phoneAudioReady = true,
|
||||
startPositionMs = seekMs,
|
||||
)
|
||||
if (!result.contains("включил")) return result
|
||||
|
||||
// Звук на телефоне (с позиции, как DetailsScreen): локальный ogg или S3-стрим.
|
||||
if (audioSync.isActive) audioSync.stop()
|
||||
val audio = resolveAudioSourceDetailed(
|
||||
mirrorFiles = mirror.mirrorByItem(id)?.files,
|
||||
audioIndex = audioIndex,
|
||||
localAudioFile = { downloadManager.audioFile(id, it) },
|
||||
)
|
||||
if (audio.source == null) return "Видео включено, но источники звука отсутствуют: ${audio.reason}"
|
||||
audioSync.play(audio.source, seekMs.coerceAtLeast(0L))
|
||||
return "Включено: «${state.currentItem?.Name ?: id}»"
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственный consumer фраз: фраза → LLM (с историей из SQLite) → ShowText на очки.
|
||||
* Ошибка LLM (нет сети, 401 и т.п.) — коротким сообщением пользователю, не падаем.
|
||||
|
||||
Reference in New Issue
Block a user