870 lines
35 KiB
Python
870 lines
35 KiB
Python
"""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, пишем в БД. Плейлисты и лекции на 4–5 часов — ок."
|
||
),
|
||
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)
|