"""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)