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).
This commit is contained in:
Bossiara13 2026-05-16 13:27:04 +03:00
parent 2bef1d5ea7
commit 59a1258dcc
19 changed files with 1233 additions and 0 deletions

80
gui/pages/commands.py Normal file
View file

@ -0,0 +1,80 @@
"""Commands page — read-only browser of `commands.yaml` + plugin packs."""
import os
import flet as ft
import yaml
_HERE = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _load_commands() -> dict[str, dict]:
try:
with open(os.path.join(_HERE, "commands.yaml"), "rt", encoding="utf8") as f:
return yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError):
return {}
def build(page: ft.Page) -> ft.Control: # pylint: disable=unused-argument
cmds = _load_commands()
search = ft.TextField(label="Фильтр", value="", autofocus=False, expand=True)
list_view = ft.ListView(expand=True, spacing=6, padding=0)
def render(query: str = ""):
q = (query or "").strip().lower()
list_view.controls.clear()
shown = 0
for cmd_id, entry in sorted(cmds.items()):
phrases = entry.get("phrases") or []
action = entry.get("action") or {}
joined = " ".join(phrases).lower()
if q and q not in cmd_id.lower() and q not in joined:
continue
shown += 1
list_view.controls.append(
ft.Container(
content=ft.Column([
ft.Row([
ft.Text(cmd_id, weight=ft.FontWeight.W_600, size=13),
ft.Container(
content=ft.Text(action.get("type", "?"), size=10),
bgcolor="#1e3a44",
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border_radius=10,
),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Text(
" · ".join(phrases[:6]) + ("" if len(phrases) > 6 else ""),
size=11,
color="#8aa0a8",
),
], spacing=4),
bgcolor="#15252b",
border_radius=8,
padding=10,
)
)
# The header refers to the closure-captured `header` Text via the
# outer-scope binding declared after this function.
header.value = f"Команд показано: {shown} из {len(cmds)}"
page.update()
search.on_change = lambda e: render(e.control.value)
header = ft.Text(f"Всего команд: {len(cmds)}", size=12, color="#8aa0a8")
render()
return ft.Column(
controls=[
ft.Row([
ft.Text("Команды", size=24, weight=ft.FontWeight.W_700),
header,
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
search,
ft.Container(content=list_view, expand=True),
],
spacing=10,
expand=True,
)