Files
LLM-infa/web/views/main_view.py

870 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Main page of the LLM-infa Flet web app: вкладки «Главная» и «Настройки»."""
from __future__ import annotations
import threading
import time
import flet as ft
from web.api_client import ApiClient, ApiError
import web.designer as d
_JOB_STATUS = {
"queued": ("в очереди", None),
"running": ("выполняется", None),
"done": ("готово", "ok"),
"error": ("ошибка", "danger"),
"cancelled": ("отменена", None),
"interrupted": ("прервана", "danger"),
}
class MainView:
def __init__(self, page: ft.Page, client: ApiClient):
self.page = page
self.client = client
self.models: list[dict] = []
self.selected_model: str = ""
self.sections: list[dict] = []
self.current_section_id: int | None = None # None = все видео
self._polling = False
# ── Settings tab fields ────────────────────────────────────────────
self.base_url = d.text_field("Base URL", hint="https://openrouter.ai/api/v1", expand=True)
self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
self.model_input = d.text_field(
"Модель",
hint="Нажмите «Загрузить модели» и выберите из списка",
on_change=self._on_model_search,
expand=True,
)
self._model_list_col = ft.Column(spacing=2, scroll=ft.ScrollMode.AUTO)
self._model_list_wrap = ft.Container(
content=self._model_list_col,
height=260,
bgcolor=d.SURFACE_3,
border=ft.border.all(1, d.BORDER),
border_radius=8,
padding=4,
visible=False,
)
self.llm_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.llm_status_chip = ft.Container(
content=ft.Text("LLM не настроена", size=12, color=d.MUTED),
padding=ft.padding.symmetric(horizontal=10, vertical=4),
border=ft.border.all(1, d.BORDER),
border_radius=99,
)
self.btn_load = d.primary_button("Загрузить модели", self._load_models)
self.btn_test = d.secondary_button("Проверить связь", self._test_conn)
self.vosk_path = d.text_field(
"Vosk-модель (fallback, когда нет субтитров)",
value="models/vosk-model-small-ru-0.22",
expand=True,
)
self.n_sentences = ft.TextField(
label="Предложений в выжимке",
value="15",
input_filter=ft.NumbersOnlyInputFilter(),
bgcolor=d.SURFACE_3,
color=d.TEXT,
border_color=d.BORDER,
focused_border_color=d.ACCENT,
label_style=ft.TextStyle(color=d.MUTED, size=12),
text_size=14,
width=220,
)
self.keep_video_sw = ft.Switch(
label="Сохранять видео на диске (иначе качается только аудио)",
value=True,
active_color=d.ACCENT,
label_style=ft.TextStyle(color=d.TEXT, size=13),
)
self.settings_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_save = d.primary_button("💾 Сохранить настройки", self._save_settings)
# ── Pipeline (main tab) ────────────────────────────────────────────
self.yt_url = d.text_field(
"Ссылка на YouTube: видео или плейлист",
hint="https://youtu.be/… или https://www.youtube.com/playlist?list=…",
expand=True,
)
self.pipeline_section_dd = self._dropdown("Раздел для новых видео", width=280)
self.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_run = d.primary_button("В очередь", self._run_pipeline)
# ── Jobs ───────────────────────────────────────────────────────────
self._jobs_col = ft.Column(spacing=6)
# ── Library ────────────────────────────────────────────────────────
self.search_field = d.text_field(
"Поиск по названию / URL", on_change=None, expand=True
)
self.search_field.on_submit = lambda _: self._refresh_library()
self.method_dd = self._dropdown("Метод", width=190)
self.method_dd.options = [
ft.dropdown.Option("", "все"),
ft.dropdown.Option("subtitles", "субтитры"),
ft.dropdown.Option("vosk", "vosk"),
]
self.method_dd.value = ""
self.method_dd.on_change = lambda _: self._refresh_library()
self._sections_col = ft.Column(spacing=2)
self._library_col = ft.Column(spacing=8, expand=True)
self.lib_msg = ft.Text("", color=d.MUTED, size=12)
# ─────────────────────── small factories ─────────────────────────────
def _dropdown(self, label: str, width: int | None = None) -> ft.Dropdown:
return ft.Dropdown(
label=label,
options=[],
width=width,
bgcolor=d.SURFACE_3,
border_color=d.BORDER,
focused_border_color=d.ACCENT,
label_style=ft.TextStyle(color=d.MUTED, size=12),
text_size=13,
color=d.TEXT,
)
# ─────────────────────── build ───────────────────────────────────────
def build(self) -> ft.Control:
main_tab = ft.Container(
content=ft.Column(
[
self._build_pipeline_card(),
self._build_jobs_card(),
self._build_library_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
)
settings_tab = ft.Container(
content=ft.Column(
[
self._build_llm_card(),
self._build_defaults_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
)
tabs = ft.Tabs(
selected_index=0,
animation_duration=200,
label_color=d.ACCENT_2,
unselected_label_color=d.MUTED,
indicator_color=d.ACCENT,
divider_color=d.BORDER,
tabs=[
ft.Tab(text="Главная", content=main_tab),
ft.Tab(text="Настройки", content=settings_tab),
],
expand=True,
)
return ft.Column(
controls=[self._build_topbar(), tabs],
expand=True,
spacing=0,
)
def _build_topbar(self) -> ft.Container:
return ft.Container(
content=ft.Row(
[
ft.Row(
[
ft.Container(
width=14,
height=14,
border_radius=4,
gradient=ft.LinearGradient(
begin=ft.alignment.top_left,
end=ft.alignment.bottom_right,
colors=[d.ACCENT, "#5b8def"],
),
),
ft.Text("LLM-infa", color=d.TEXT, size=17, weight=ft.FontWeight.W_600),
],
spacing=10,
),
ft.Container(expand=True),
self.llm_status_chip,
],
),
padding=ft.padding.symmetric(horizontal=24, vertical=14),
bgcolor=ft.Colors.with_opacity(0.6, d.SURFACE),
border=ft.border.only(bottom=ft.BorderSide(1, d.BORDER)),
)
# ── Settings tab cards ────────────────────────────────────────────────
def _build_llm_card(self) -> ft.Container:
return d.card(
d.section_title("Подключение к LLM"),
d.hint(
"Любой OpenAI-совместимый провайдер (OpenRouter, OpenAI, локальная Ollama…). "
"Ключ и модель хранятся на сервере в БД и переживают перезапуск."
),
ft.Row([self.base_url, self.api_key], spacing=12),
ft.Row([self.btn_load, self.btn_test, self.llm_msg], spacing=10),
ft.Divider(color=d.BORDER, height=1),
self.model_input,
self._model_list_wrap,
)
def _build_defaults_card(self) -> ft.Container:
return d.card(
d.section_title("Параметры обработки"),
d.hint("Применяются ко всем новым задачам."),
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
self.keep_video_sw,
ft.Divider(color=d.BORDER, height=1),
ft.Row([self.btn_save, self.settings_msg], spacing=10),
)
# ── Main tab cards ────────────────────────────────────────────────────
def _build_pipeline_card(self) -> ft.Container:
return d.card(
d.section_title("Обработать видео или плейлист"),
d.hint(
"Задача уходит в фон: качаем, расшифровываем (субтитры или Vosk), "
"сокращаем через LLM, пишем в БД. Плейлисты и лекции на 45 часов — ок."
),
self.yt_url,
ft.Row(
[self.btn_run, self.pipeline_section_dd, self.pipeline_msg],
spacing=12,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
)
def _build_jobs_card(self) -> ft.Container:
return d.card(
ft.Row(
[
d.section_title("Задачи"),
ft.Container(expand=True),
d.secondary_button("Обновить", lambda _: self._start_jobs_poll()),
]
),
self._jobs_col,
)
def _build_library_card(self) -> ft.Container:
left = ft.Container(
width=260,
content=ft.Column(
[
ft.Row(
[
ft.Text("Разделы", color=d.TEXT, size=14, weight=ft.FontWeight.W_600),
ft.Container(expand=True),
ft.IconButton(
icon=ft.Icons.ADD,
icon_color=d.ACCENT_2,
icon_size=18,
tooltip="Добавить раздел",
on_click=self._add_section_dialog,
),
]
),
self._sections_col,
],
spacing=6,
tight=True,
),
)
right = ft.Column(
[
ft.Row([self.search_field, self.method_dd], spacing=10),
self.lib_msg,
self._library_col,
],
spacing=10,
expand=True,
tight=True,
)
return d.card(
ft.Row(
[
d.section_title("Библиотека"),
ft.Container(expand=True),
d.secondary_button("Обновить", lambda _: self._refresh_library()),
]
),
ft.Row(
[left, ft.Container(width=1, bgcolor=d.BORDER), right],
spacing=16,
vertical_alignment=ft.CrossAxisAlignment.START,
),
)
# ─────────────────── settings load/save ──────────────────────────────
def apply_settings(self, s: dict) -> None:
"""Заполняет поля значениями с сервера (вызывается при старте сессии)."""
self.base_url.value = s.get("llm_base_url", "")
self.api_key.value = s.get("llm_api_key", "")
self.selected_model = s.get("llm_model", "")
self.model_input.value = self.selected_model
self.n_sentences.value = str(s.get("summary_max_sentences", 15))
self.keep_video_sw.value = bool(s.get("keep_video", True))
self.vosk_path.value = s.get("vosk_model_path", "models/vosk-model-small-ru-0.22")
self._set_status(ok=bool(self.base_url.value and self.selected_model))
def _save_settings(self, _):
model = self.selected_model or (self.model_input.value or "").strip()
payload = {
"llm_base_url": self.base_url.value.strip(),
"llm_api_key": self.api_key.value.strip(),
"llm_model": model,
"summary_max_sentences": int(self.n_sentences.value or 15),
"keep_video": bool(self.keep_video_sw.value),
"vosk_model_path": self.vosk_path.value.strip(),
}
try:
self.client.save_settings(payload)
self.settings_msg.value = "Сохранено ✓ (хранится в БД, переживёт перезапуск)"
self.settings_msg.color = d.OK
self._set_status(ok=bool(payload["llm_base_url"] and model))
except ApiError as e:
self.settings_msg.value = str(e)
self.settings_msg.color = d.DANGER
self.page.update()
# ─────────────────── model combobox ──────────────────────────────────
def _on_model_search(self, e):
q = (self.model_input.value or "").strip().lower()
filtered = [m for m in self.models if q in m["id"].lower()] if q else self.models
self._render_model_list(filtered)
def _render_model_list(self, items: list[dict]):
self._model_list_col.controls = [
ft.Container(
content=ft.Row(
[
ft.Text(m["id"], color=d.TEXT, size=13, expand=True, no_wrap=True),
ft.Text(m.get("owned_by", ""), color=d.MUTED, size=11),
],
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
),
ink=True,
bgcolor=d.SURFACE_2,
padding=ft.padding.symmetric(horizontal=10, vertical=8),
border_radius=6,
on_click=lambda e, mid=m["id"]: self._select_model(mid),
)
for m in items
]
if not items:
self._model_list_col.controls = [
ft.Text("Ничего не найдено", color=d.MUTED, size=13)
]
self._model_list_wrap.visible = bool(self.models)
self.page.update()
def _select_model(self, mid: str):
self.selected_model = mid
self.model_input.value = mid
self._model_list_wrap.visible = False
self.llm_msg.value = "Не забудьте нажать «Сохранить настройки»"
self.llm_msg.color = d.MUTED
self.page.update()
# ─────────────────── LLM actions ─────────────────────────────────────
def _load_models(self, _):
base_url = self.base_url.value.strip()
if not base_url:
self._set_llm_msg("Укажите Base URL", error=True)
return
self._set_llm_msg("Загружаю…")
self.btn_load.disabled = True
self.page.update()
def _task():
try:
self.models = self.client.llm_models(base_url, self.api_key.value.strip())
self._render_model_list(self.models)
self._set_llm_msg(f"Загружено: {len(self.models)} моделей", ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
finally:
self.btn_load.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
def _test_conn(self, _):
model = self.selected_model or (self.model_input.value or "").strip()
if not model:
self._set_llm_msg("Сначала выберите модель", error=True)
return
self._set_llm_msg("Проверяю…")
self.btn_test.disabled = True
self.page.update()
def _task():
try:
r = self.client.llm_test(
self.base_url.value.strip(),
self.api_key.value.strip(),
model,
)
preview = (r.get("reply") or "")[:80]
self._set_llm_msg(f"OK · {preview}", ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
finally:
self.btn_test.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
# ─────────────────── pipeline → jobs ──────────────────────────────────
def _run_pipeline(self, _):
url = self.yt_url.value.strip()
if not url:
self._set_pipeline_msg("Укажите ссылку на видео или плейлист", error=True)
return
payload: dict = {"url": url}
if self.pipeline_section_dd.value:
payload["section_id"] = int(self.pipeline_section_dd.value)
try:
self.client.create_job(payload)
self.yt_url.value = ""
self._set_pipeline_msg("Задача поставлена в очередь ✓", ok=True)
except ApiError as e:
self._set_pipeline_msg(str(e), error=True)
self.page.update()
self._start_jobs_poll()
# ─────────────────── jobs ─────────────────────────────────────────────
def _start_jobs_poll(self):
if self._polling:
return
self._polling = True
threading.Thread(target=self._poll_loop, daemon=True).start()
def _poll_loop(self):
prev: dict = {}
fails = 0
try:
while True:
try:
jobs = self.client.list_jobs()
self._render_jobs(jobs)
state = {j["id"]: (j["done_items"], j["status"]) for j in jobs}
if state != prev:
prev = state
self._refresh_library() # там же page.update()
else:
self.page.update()
fails = 0
if not any(j["status"] in ("queued", "running") for j in jobs):
break
except Exception:
# обрыв сети/перезапуск api — не глушим опрос, пробуем ещё
fails += 1
if fails >= 10:
break
time.sleep(3)
finally:
self._polling = False
def _render_jobs(self, jobs: list[dict]):
if not jobs:
self._jobs_col.controls = [
ft.Text("Задач ещё не было", color=d.MUTED, size=13)
]
return
self._jobs_col.controls = [self._job_row(j) for j in jobs]
def _job_row(self, j: dict) -> ft.Container:
label, tone = _JOB_STATUS.get(j["status"], (j["status"], None))
color = d.OK if tone == "ok" else (d.DANGER if tone == "danger" else d.MUTED)
active = j["status"] in ("queued", "running")
chip = ft.Container(
content=ft.Text(label, size=11, color=color),
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border=ft.border.all(1, color),
border_radius=99,
)
head = [
chip,
ft.Text(
(j["title"] or j["url"])[:90],
color=d.TEXT,
size=13,
weight=ft.FontWeight.W_600,
expand=True,
no_wrap=True,
),
]
if j["kind"] == "playlist":
head.append(ft.Text("плейлист", color=d.ACCENT_2, size=11))
if j["total_items"]:
head.append(
ft.Text(f"{j['done_items']}/{j['total_items']}", color=d.MUTED, size=12)
)
if active:
head.append(
ft.IconButton(
icon=ft.Icons.STOP_CIRCLE_OUTLINED,
icon_color=d.DANGER,
icon_size=17,
tooltip="Отменить (после текущего элемента)",
on_click=lambda e, jid=j["id"]: self._cancel_job(jid),
)
)
else:
head.append(
ft.IconButton(
icon=ft.Icons.CLOSE,
icon_color=d.MUTED,
icon_size=15,
tooltip="Убрать из списка",
on_click=lambda e, jid=j["id"]: self._delete_job(jid),
)
)
rows: list[ft.Control] = [ft.Row(head, spacing=8)]
if active:
total = j["total_items"] or 0
value = (j["done_items"] / total) if total else None
rows.append(
ft.ProgressBar(value=value, color=d.ACCENT, bgcolor=d.SURFACE_3, height=4)
)
if j.get("live"):
rows.append(ft.Text(j["live"][:140], color=d.ACCENT_2, size=11))
if j.get("error"):
rows.append(
ft.Text(j["error"][:300], color=d.DANGER, size=11, no_wrap=False)
)
if j["failed_items"]:
rows.append(
ft.Text(f"с ошибкой: {j['failed_items']}", color=d.DANGER, size=11)
)
return ft.Container(
content=ft.Column(rows, spacing=5, tight=True),
padding=10,
bgcolor=d.SURFACE_2,
border=ft.border.all(1, d.BORDER),
border_radius=10,
)
def _cancel_job(self, job_id: str):
try:
self.client.cancel_job(job_id)
except ApiError as e:
self.lib_msg.value = str(e)
self._start_jobs_poll()
def _delete_job(self, job_id: str):
try:
self.client.delete_job(job_id)
except ApiError as e:
self.lib_msg.value = str(e)
self._start_jobs_poll()
# ─────────────────── sections ─────────────────────────────────────────
def _section_options(self, *, none_label: str) -> list[ft.dropdown.Option]:
"""Options списка разделов с отступами по глубине дерева."""
opts = [ft.dropdown.Option("", none_label)]
children: dict[int | None, list[dict]] = {}
for s in self.sections:
children.setdefault(s["parent_id"], []).append(s)
def walk(parent_id: int | None, depth: int):
for s in children.get(parent_id, []):
opts.append(ft.dropdown.Option(str(s["id"]), " " * depth + s["name"]))
walk(s["id"], depth + 1)
walk(None, 0)
return opts
def _render_sections(self):
children: dict[int | None, list[dict]] = {}
for s in self.sections:
children.setdefault(s["parent_id"], []).append(s)
def item(sid: int | None, name: str, count: int | None, depth: int, deletable: bool):
selected = self.current_section_id == sid
row = [
ft.Text(name, color=d.ACCENT_2 if selected else d.TEXT, size=13, expand=True, no_wrap=True),
]
if count is not None:
row.append(ft.Text(str(count), color=d.MUTED, size=11))
if deletable:
row.append(
ft.IconButton(
icon=ft.Icons.DELETE_OUTLINE,
icon_color=d.MUTED,
icon_size=15,
tooltip="Удалить раздел (видео останутся)",
on_click=lambda e, s=sid: self._delete_section(s),
)
)
return ft.Container(
content=ft.Row(row, spacing=4),
padding=ft.padding.only(left=10 + depth * 16, right=2, top=2, bottom=2),
bgcolor=d.SURFACE_2 if selected else None,
border_radius=6,
ink=True,
on_click=lambda e, s=sid: self._select_section(s),
)
controls = [item(None, "Все видео", None, 0, deletable=False)]
def walk(parent_id: int | None, depth: int):
for s in children.get(parent_id, []):
controls.append(item(s["id"], s["name"], s["video_count"], depth, deletable=True))
walk(s["id"], depth + 1)
walk(None, 0)
if not self.sections:
controls.append(ft.Text("Разделов пока нет — добавьте «+»", color=d.MUTED, size=12))
self._sections_col.controls = controls
def _select_section(self, section_id: int | None):
self.current_section_id = section_id
self._refresh_library()
def _delete_section(self, section_id: int):
try:
self.client.delete_section(section_id)
if self.current_section_id == section_id:
self.current_section_id = None
except ApiError as e:
self.lib_msg.value = str(e)
self._refresh_library()
def _add_section_dialog(self, _):
name_field = d.text_field("Название раздела", expand=True)
parent_dd = self._dropdown("Родительский раздел", width=None)
parent_dd.options = self._section_options(none_label="— корневой —")
parent_dd.value = str(self.current_section_id or "")
def _create(_):
name = (name_field.value or "").strip()
if not name:
return
try:
parent = int(parent_dd.value) if parent_dd.value else None
self.client.create_section(name, parent)
except ApiError as e:
self.lib_msg.value = str(e)
self.page.close(dlg)
self._refresh_library()
dlg = ft.AlertDialog(
modal=True,
bgcolor=d.SURFACE,
title=ft.Text("Новый раздел", color=d.TEXT, size=16),
content=ft.Container(
width=420,
content=ft.Column([name_field, parent_dd], spacing=12, tight=True),
),
actions=[
ft.TextButton("Отмена", on_click=lambda e: self.page.close(dlg)),
d.primary_button("Создать", _create),
],
)
self.page.open(dlg)
# ─────────────────── library ─────────────────────────────────────────
def _refresh_library(self):
self.lib_msg.value = ""
try:
self.sections = self.client.list_sections()
items = self.client.list_videos(
q=(self.search_field.value or "").strip() or None,
section_id=self.current_section_id,
method=self.method_dd.value or None,
)
except ApiError as e:
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
self.page.update()
return
self._render_sections()
self.pipeline_section_dd.options = self._section_options(none_label="— без раздела —")
if not items:
self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)]
else:
self._library_col.controls = [self._lib_card(v) for v in items]
self.page.update()
def _lib_card(self, v: dict) -> ft.Container:
created = v.get("created_at", "")[:19].replace("T", " ")
section_dd = self._dropdown("Раздел", width=230)
section_dd.options = self._section_options(none_label="— без раздела —")
section_dd.value = str(v.get("section_id") or "")
section_dd.on_change = lambda e, u=v["uuid"]: self._assign_section(u, e.control.value)
return ft.Container(
content=ft.Column(
[
ft.Row(
[
ft.Text(
v.get("title") or v.get("uuid", ""),
color=d.TEXT,
size=14,
weight=ft.FontWeight.W_600,
expand=True,
no_wrap=True,
),
ft.Text(created, color=d.MUTED, size=11),
ft.IconButton(
icon=ft.Icons.DELETE_OUTLINE,
icon_color=d.DANGER,
icon_size=17,
tooltip="Удалить видео и файлы",
on_click=lambda e, vid=v: self._confirm_delete_video(vid),
),
]
),
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
self._kv("Метод", v.get("transcription_method", "")),
self._kv("Видео", v.get("video_path") or "не сохранено"),
ft.Row(
[
d.secondary_button("Выжимка", lambda e, vid=v: self._show_text(vid, "summary")),
d.secondary_button("Полный текст", lambda e, vid=v: self._show_text(vid, "full")),
ft.Container(expand=True),
section_dd,
],
spacing=8,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
],
spacing=5,
tight=True,
),
padding=12,
bgcolor=d.SURFACE_2,
border=ft.border.all(1, d.BORDER),
border_radius=10,
)
def _assign_section(self, uuid: str, value: str):
try:
self.client.set_video_section(uuid, int(value) if value else None)
except ApiError as e:
self.lib_msg.value = str(e)
self._refresh_library()
def _confirm_delete_video(self, v: dict):
def _do(_):
try:
self.client.delete_video(v["uuid"])
except ApiError as e:
self.lib_msg.value = str(e)
self.page.close(dlg)
self._refresh_library()
dlg = ft.AlertDialog(
modal=True,
bgcolor=d.SURFACE,
title=ft.Text("Удалить видео?", color=d.TEXT, size=16),
content=ft.Text(
f"«{v.get('title') or v['uuid']}»\n"
"Будут удалены запись в БД и файлы (видео, тексты).",
color=d.MUTED,
size=13,
),
actions=[
ft.TextButton("Отмена", on_click=lambda e: self.page.close(dlg)),
ft.TextButton(
"Удалить",
style=ft.ButtonStyle(color=d.DANGER),
on_click=_do,
),
],
)
self.page.open(dlg)
def _show_text(self, v: dict, kind: str):
try:
data = self.client.video_text(v["uuid"], kind)
except ApiError as e:
self.lib_msg.value = str(e)
self.page.update()
return
title = ("Выжимка — " if kind == "summary" else "Полный текст — ") + (
v.get("title") or v["uuid"]
)
dlg = ft.AlertDialog(
bgcolor=d.SURFACE,
title=ft.Text(title, color=d.TEXT, size=15, no_wrap=False),
content=ft.Container(
width=780,
height=440,
content=ft.Column(
[ft.Text(data.get("text", ""), color=d.TEXT, size=13, selectable=True)],
scroll=ft.ScrollMode.AUTO,
),
bgcolor=d.SURFACE_3,
border=ft.border.all(1, d.BORDER),
border_radius=8,
padding=12,
),
actions=[ft.TextButton("Закрыть", on_click=lambda e: self.page.close(dlg))],
)
self.page.open(dlg)
# ─────────────────── helpers ──────────────────────────────────────────
def _kv(self, key: str, value: str) -> ft.Row:
return ft.Row(
[
ft.Text(key, color=d.MUTED, size=12, width=120),
ft.Text(value, color=d.TEXT, size=12, selectable=True, expand=True, no_wrap=False),
],
)
def _set_llm_msg(self, text: str, *, ok: bool = False, error: bool = False):
self.llm_msg.value = text
self.llm_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
def _set_pipeline_msg(self, text: str, *, ok: bool = False, error: bool = False):
self.pipeline_msg.value = text
self.pipeline_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
def _set_status(self, *, ok: bool):
chip = self.llm_status_chip
chip.content.value = "LLM настроена" if ok else "LLM не настроена"
chip.content.color = d.OK if ok else d.MUTED
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else d.BORDER)