Фоновые задачи: плейлисты, длинные лекции, вкладка настроек
- таблица jobs + очередь с воркером: POST /jobs сразу отвечает, элементы
плейлиста обрабатываются последовательно, есть отмена и живой прогресс
(скачивание %, минуты распознанного Vosk-аудио)
- expand_url: плейлист раскрывается в список видео
- keep_video=false теперь качает только аудио (для 4-5ч лекций)
- настройки (LLM-ключ, модель, длина выжимки, хранение видео, путь Vosk)
хранятся в Postgres: переживают перезапуск и перезагрузку страницы
- длина выжимки прокидывается в LLM-промпт ({n} предложений)
- UI: вкладки «Главная»/«Настройки», карточка задач с автообновлением
каждые 3 сек, прогресс-бар, отмена, библиотека обновляется по мере
готовности элементов
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""Main page of the LLM-infa Flet web app."""
|
||||
"""Main page of the LLM-infa Flet web app: вкладки «Главная» и «Настройки»."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import flet as ft
|
||||
|
||||
@@ -9,6 +10,16 @@ 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
|
||||
@@ -20,14 +31,15 @@ class MainView:
|
||||
self.sections: list[dict] = []
|
||||
self.current_section_id: int | None = None # None = все видео
|
||||
|
||||
# ── LLM config fields ──────────────────────────────────────────────
|
||||
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)
|
||||
|
||||
# Searchable model combobox
|
||||
self.model_input = d.text_field(
|
||||
"Модель",
|
||||
hint="Сначала нажмите «Загрузить модели»",
|
||||
hint="Нажмите «Загрузить модели» и выберите из списка",
|
||||
on_change=self._on_model_search,
|
||||
expand=True,
|
||||
)
|
||||
@@ -44,7 +56,7 @@ class MainView:
|
||||
|
||||
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),
|
||||
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,
|
||||
@@ -52,12 +64,6 @@ class MainView:
|
||||
self.btn_load = d.primary_button("Загрузить модели", self._load_models)
|
||||
self.btn_test = d.secondary_button("Проверить связь", self._test_conn)
|
||||
|
||||
# ── Pipeline fields ────────────────────────────────────────────────
|
||||
self.yt_url = d.text_field(
|
||||
"Ссылка на YouTube",
|
||||
hint="https://youtu.be/…",
|
||||
expand=True,
|
||||
)
|
||||
self.vosk_path = d.text_field(
|
||||
"Vosk-модель (fallback, когда нет субтитров)",
|
||||
value="models/vosk-model-small-ru-0.22",
|
||||
@@ -76,19 +82,28 @@ class MainView:
|
||||
width=220,
|
||||
)
|
||||
self.keep_video_sw = ft.Switch(
|
||||
label="Сохранять видео на диске",
|
||||
label="Сохранять видео на диске (иначе качается только аудио)",
|
||||
value=True,
|
||||
active_color=d.ACCENT,
|
||||
label_style=ft.TextStyle(color=d.TEXT, size=13),
|
||||
)
|
||||
self.pipeline_section_dd = self._dropdown("Раздел для нового видео", width=280)
|
||||
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)
|
||||
self.btn_run = d.primary_button("▶ В очередь", self._run_pipeline)
|
||||
|
||||
# Result block (initially hidden)
|
||||
self._result_col = ft.Column(spacing=6, visible=False)
|
||||
# ── Jobs ───────────────────────────────────────────────────────────
|
||||
self._jobs_col = ft.Column(spacing=6)
|
||||
|
||||
# ── Library controls ───────────────────────────────────────────────
|
||||
# ── Library ────────────────────────────────────────────────────────
|
||||
self.search_field = d.text_field(
|
||||
"Поиск по названию / URL", on_change=None, expand=True
|
||||
)
|
||||
@@ -124,24 +139,47 @@ class MainView:
|
||||
# ─────────────────────── build ───────────────────────────────────────
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
return ft.Column(
|
||||
controls=[
|
||||
self._build_topbar(),
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self._build_llm_card(),
|
||||
self._build_pipeline_card(),
|
||||
self._build_library_card(),
|
||||
],
|
||||
spacing=20,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||
expand=True,
|
||||
),
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -174,13 +212,14 @@ class MainView:
|
||||
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-совместимый провайдер. "
|
||||
"Нейронка внутри Docker не разворачивается — "
|
||||
"указываем URL внешнего сервиса."
|
||||
"Любой 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),
|
||||
@@ -189,22 +228,43 @@ class MainView:
|
||||
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.section_title("Обработать видео или плейлист"),
|
||||
d.hint(
|
||||
"Pipeline: yt-dlp → субтитры (или Vosk fallback) "
|
||||
"→ выжимка через выбранную LLM → запись в БД."
|
||||
"Задача уходит в фон: качаем, расшифровываем (субтитры или Vosk), "
|
||||
"сокращаем через LLM, пишем в БД. Плейлисты и лекции на 4–5 часов — ок."
|
||||
),
|
||||
self.yt_url,
|
||||
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
|
||||
ft.Row(
|
||||
[self.pipeline_section_dd, self.keep_video_sw],
|
||||
spacing=18,
|
||||
[self.btn_run, self.pipeline_section_dd, self.pipeline_msg],
|
||||
spacing=12,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
|
||||
self._result_col,
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -256,6 +316,39 @@ class MainView:
|
||||
),
|
||||
)
|
||||
|
||||
# ─────────────────── 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):
|
||||
@@ -292,6 +385,8 @@ class MainView:
|
||||
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 ─────────────────────────────────────
|
||||
@@ -311,10 +406,8 @@ class MainView:
|
||||
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)
|
||||
self._set_status(ok=True)
|
||||
except ApiError as e:
|
||||
self._set_llm_msg(str(e), error=True)
|
||||
self._set_status(ok=False)
|
||||
finally:
|
||||
self.btn_load.disabled = False
|
||||
self.page.update()
|
||||
@@ -322,7 +415,8 @@ class MainView:
|
||||
threading.Thread(target=_task, daemon=True).start()
|
||||
|
||||
def _test_conn(self, _):
|
||||
if not self.selected_model:
|
||||
model = self.selected_model or (self.model_input.value or "").strip()
|
||||
if not model:
|
||||
self._set_llm_msg("Сначала выберите модель", error=True)
|
||||
return
|
||||
|
||||
@@ -335,87 +429,164 @@ class MainView:
|
||||
r = self.client.llm_test(
|
||||
self.base_url.value.strip(),
|
||||
self.api_key.value.strip(),
|
||||
self.selected_model,
|
||||
model,
|
||||
)
|
||||
preview = (r.get("reply") or "")[:80]
|
||||
self._set_llm_msg(f"OK · {preview}", ok=True)
|
||||
self._set_status(ok=True)
|
||||
except ApiError as e:
|
||||
self._set_llm_msg(str(e), error=True)
|
||||
self._set_status(ok=False)
|
||||
finally:
|
||||
self.btn_test.disabled = False
|
||||
self.page.update()
|
||||
|
||||
threading.Thread(target=_task, daemon=True).start()
|
||||
|
||||
# ─────────────────── pipeline ─────────────────────────────────────────
|
||||
# ─────────────────── pipeline → jobs ──────────────────────────────────
|
||||
|
||||
def _run_pipeline(self, _):
|
||||
url = self.yt_url.value.strip()
|
||||
if not url:
|
||||
self._set_pipeline_msg("Укажите ссылку на видео", error=True)
|
||||
self._set_pipeline_msg("Укажите ссылку на видео или плейлист", error=True)
|
||||
return
|
||||
|
||||
payload: dict = {
|
||||
"url": url,
|
||||
"model_path": self.vosk_path.value.strip() or None,
|
||||
"summary_max_sentences": int(self.n_sentences.value or 15),
|
||||
"keep_video": bool(self.keep_video_sw.value),
|
||||
}
|
||||
payload: dict = {"url": url}
|
||||
if self.pipeline_section_dd.value:
|
||||
payload["section_id"] = int(self.pipeline_section_dd.value)
|
||||
if self.base_url.value.strip() and self.selected_model:
|
||||
payload["llm"] = {
|
||||
"base_url": self.base_url.value.strip(),
|
||||
"api_key": self.api_key.value.strip(),
|
||||
"model": self.selected_model,
|
||||
}
|
||||
|
||||
self._set_pipeline_msg("Обработка видео… (может занять несколько минут)")
|
||||
self._result_col.visible = False
|
||||
self.btn_run.disabled = True
|
||||
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()
|
||||
|
||||
def _task():
|
||||
try:
|
||||
r = self.client.pipeline_process(payload)
|
||||
self._render_result(r)
|
||||
self._set_pipeline_msg("Готово ✓", ok=True)
|
||||
self._refresh_library()
|
||||
except ApiError as e:
|
||||
self._set_pipeline_msg(str(e), error=True)
|
||||
finally:
|
||||
self.btn_run.disabled = False
|
||||
self.page.update()
|
||||
# ─────────────────── jobs ─────────────────────────────────────────────
|
||||
|
||||
threading.Thread(target=_task, daemon=True).start()
|
||||
def _start_jobs_poll(self):
|
||||
if self._polling:
|
||||
return
|
||||
self._polling = True
|
||||
threading.Thread(target=self._poll_loop, daemon=True).start()
|
||||
|
||||
def _render_result(self, r: dict):
|
||||
self._result_col.controls = [
|
||||
ft.Divider(color=d.BORDER, height=1),
|
||||
ft.Text(r.get("title") or r.get("uuid", ""), color=d.ACCENT_2, size=15, weight=ft.FontWeight.W_600),
|
||||
*[
|
||||
self._kv(k, v)
|
||||
for k, v in [
|
||||
("UUID", r.get("uuid", "")),
|
||||
("Источник", r.get("source_url", "")),
|
||||
("Метод", r.get("transcription_method", "")),
|
||||
("Видео", r.get("video_path") or "не сохранено"),
|
||||
("Полный текст", r.get("text_full_path", "")),
|
||||
("Выжимка", r.get("text_summary_path", "")),
|
||||
]
|
||||
],
|
||||
ft.Text("Превью выжимки:", color=d.MUTED, size=12),
|
||||
ft.Container(
|
||||
content=ft.Text(r.get("summary_preview", ""), color=d.TEXT, size=13, selectable=True, no_wrap=False),
|
||||
padding=12,
|
||||
bgcolor=d.SURFACE_3,
|
||||
border=ft.border.all(1, d.BORDER),
|
||||
border_radius=8,
|
||||
def _poll_loop(self):
|
||||
prev: dict = {}
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
jobs = self.client.list_jobs()
|
||||
except (ApiError, Exception):
|
||||
break
|
||||
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()
|
||||
if not any(j["status"] in ("queued", "running") for j in jobs):
|
||||
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,
|
||||
),
|
||||
]
|
||||
self._result_col.visible = 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 ─────────────────────────────────────────
|
||||
|
||||
@@ -688,6 +859,6 @@ class MainView:
|
||||
|
||||
def _set_status(self, *, ok: bool):
|
||||
chip = self.llm_status_chip
|
||||
chip.content.value = "LLM подключена" if ok else "Ошибка подключения"
|
||||
chip.content.color = d.OK if ok else d.DANGER
|
||||
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else "rgba(227,90,114,.4)")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user