J.A.R.V.I.S-py/gui/pages/home.py

120 lines
3.9 KiB
Python
Raw Normal View History

feat: Wave 3 — plugin loader + Flet GUI (parity with Rust fork) Plugin loader (mirrors Rust #29): - plugins_store.py walks %APPDATA%\com.priler.jarvis\plugins\<name>\ for commands.yaml fragments and merges them into VA_CMD_LIST at startup. Same schema as the project root commands.yaml. Per-pack `disabled` flag file skips a pack without deletion. Built-in ids win conflicts so a malicious pack can't silently override open_browser. 9 unit tests (parity with the Rust tests). - main.py: plugin merge runs after the root yaml load; logs the number of extra commands picked up. Flet GUI (#27 — first parity with the Rust Tauri GUI): - New gui/ package: __main__.py launches via `python -m gui` (or gui.bat). app.py routes between 7 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки) via NavigationRail; dark theme matching the Tauri look. - gui/services.py centralises every store-module call the pages share — single place to swap implementations later. - One file per page in gui/pages/, all expose `build(page)` and use the same card layout idiom. Pages mirror the Tauri ones feature- for-feature: home (status + counters + launcher), commands (filterable list of all VA_CMD_LIST entries), macros (list + delete + recording banner), scheduler (list + remove), memory (add/forget/search), plugins (toggle/open folder), settings (profile + LLM hot-swap). - requirements.txt: flet>=0.85 (optional; assistant runs without). - 3 smoke tests verify imports + build callables; skip cleanly when Flet isn't installed. README: documents both the GUI and the plugin layout in user-facing terms. Tests: 42 → 54 (+9 plugins +3 GUI smoke).
2026-05-16 13:27:04 +03:00
"""Home page — quick launcher + active backends + counters."""
import flet as ft
from gui import services as svc
def _stat_card(title: str, value: str, color: str = "#33d3a4") -> ft.Container:
return ft.Container(
content=ft.Column(
[
ft.Text(title, size=11, color="#8aa0a8"),
ft.Text(value, size=22, weight=ft.FontWeight.W_600, color=color),
],
spacing=2,
),
bgcolor="#15252b",
border_radius=10,
padding=14,
expand=True,
)
def build(page: ft.Page) -> ft.Control:
running = svc.assistant_is_running()
try:
llm_b, llm_m = svc.llm_active()
except (RuntimeError, OSError, ValueError) as exc:
llm_b, llm_m = "", str(exc)
counters = ft.Row(
controls=[
_stat_card("Команды", str(_safe_count(svc, "commands"))),
_stat_card("Макросы", str(len(svc.macros_all()))),
_stat_card("Расписание", str(len(svc.scheduler_all()))),
_stat_card("Память (фактов)", str(len(svc.memory_all()))),
_stat_card("Плагины", str(len(svc.plugins_all()))),
],
spacing=10,
)
status_chip = ft.Container(
content=ft.Text(
"АССИСТЕНТ РАБОТАЕТ" if running else "АССИСТЕНТ ОСТАНОВЛЕН",
size=12,
weight=ft.FontWeight.W_700,
color="#13191c",
),
bgcolor="#33d3a4" if running else "#d8d8d8",
padding=ft.padding.symmetric(horizontal=12, vertical=6),
border_radius=20,
)
start_btn = ft.FilledButton(
text="Запустить J.A.R.V.I.S.",
icon=ft.Icons.PLAY_ARROW,
on_click=lambda e: _launch(page, e),
disabled=running,
)
backends_card = ft.Container(
content=ft.Column([
ft.Text("Активный LLM", size=11, color="#8aa0a8"),
ft.Text(f"{llm_b} · {llm_m}", size=14, weight=ft.FontWeight.W_500),
], spacing=2),
bgcolor="#15252b",
border_radius=10,
padding=14,
)
return ft.Column(
controls=[
ft.Row([
ft.Text("J.A.R.V.I.S.", size=28, weight=ft.FontWeight.W_700),
status_chip,
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Text(
"Голосовой ассистент — Python-форк. Эта панель управляет состоянием, "
"запускает main.py и переключает движки.",
color="#8aa0a8",
size=12,
),
ft.Divider(color="#1f3540"),
backends_card,
counters,
ft.Divider(color="#1f3540"),
start_btn,
],
spacing=14,
expand=True,
scroll=ft.ScrollMode.AUTO,
)
def _launch(page: ft.Page, _e) -> None:
ok = svc.assistant_run()
page.open(
ft.SnackBar(
content=ft.Text(
"Запускаю main.py" if ok else "Не удалось запустить main.py",
color="white",
),
bgcolor="#33d3a4" if ok else "#d8584f",
)
)
def _safe_count(svc_module, kind: str) -> int:
"""Counts come from disk; if a store fails we display 0 rather than crash."""
try:
if kind == "commands":
# Don't import the full VA_CMD_LIST machinery — read yaml directly.
import os, yaml # pylint: disable=import-outside-toplevel
here = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
with open(os.path.join(here, "commands.yaml"), "rt", encoding="utf8") as f:
return len(yaml.safe_load(f) or {})
except (OSError, ValueError):
return 0
return 0