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

1
gui/pages/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`."""

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

119
gui/pages/home.py Normal file
View file

@ -0,0 +1,119 @@
"""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

90
gui/pages/macros.py Normal file
View file

@ -0,0 +1,90 @@
"""Macros page — list macros, show steps, delete. Recording is voice-only."""
import flet as ft
from gui import services as svc
def build(page: ft.Page) -> ft.Control:
list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO)
def render():
list_view.controls.clear()
macros = svc.macros_all()
rec_on, rec_name = svc.macros_recording()
if rec_on:
list_view.controls.append(_recording_banner(rec_name))
if not macros:
list_view.controls.append(
ft.Text(
"Макросов пока нет. Запиши голосом: «Запиши макрос работа», "
"потом скажи команды, потом «Сохрани макрос».",
color="#8aa0a8",
)
)
for m in sorted(macros, key=lambda x: x["name"]):
list_view.controls.append(_macro_card(m, on_delete=lambda n=m["name"]: do_delete(n)))
page.update()
def do_delete(name: str) -> None:
if svc.macros_delete(name):
page.open(ft.SnackBar(content=ft.Text(f"Удалил «{name}»", color="white"), bgcolor="#33d3a4"))
render()
render()
return ft.Column(
[
ft.Row([
ft.Text("Макросы", size=24, weight=ft.FontWeight.W_700),
ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
list_view,
],
expand=True,
spacing=10,
)
def _recording_banner(name: str | None) -> ft.Control:
return ft.Container(
content=ft.Row([
ft.Icon(ft.Icons.FIBER_MANUAL_RECORD, color="#ff5757"),
ft.Text(
f"Идёт запись макроса «{name or ''}». "
"Произнесите команды, затем «Сохрани макрос».",
),
]),
bgcolor="#3a2020",
border_radius=8,
padding=10,
)
def _macro_card(m: dict, on_delete) -> ft.Control:
steps = m.get("steps", [])
preview = steps[:5]
extra = len(steps) - len(preview)
return ft.Container(
content=ft.Column([
ft.Row([
ft.Text(m["name"], weight=ft.FontWeight.W_600, size=14),
ft.Container(
content=ft.Text(f"{m['steps_count']} шагов", size=10),
bgcolor="#1e3a44",
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border_radius=10,
),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Column([
ft.Text(f"{s}", size=11, color="#a8b8c0") for s in preview
] + ([ft.Text(f"+ ещё {extra}", size=11, color="#5a6a72", italic=True)] if extra > 0 else []),
spacing=2),
ft.Row([
ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()),
]),
], spacing=8),
bgcolor="#15252b",
border_radius=8,
padding=12,
)

74
gui/pages/memory.py Normal file
View file

@ -0,0 +1,74 @@
"""Long-term memory page — list facts, add new ones, search, forget."""
import flet as ft
from gui import services as svc
def build(page: ft.Page) -> ft.Control:
key_input = ft.TextField(label="Ключ", expand=True)
val_input = ft.TextField(label="Значение", expand=True)
search_input = ft.TextField(label="Поиск", expand=True)
list_view = ft.Column(spacing=6, expand=True, scroll=ft.ScrollMode.AUTO)
def render(query: str = ""):
list_view.controls.clear()
facts = svc.memory_search(query, limit=100) if query.strip() else svc.memory_all()
if not facts:
list_view.controls.append(ft.Text("Нет фактов. Скажи «Запомни что моя любимая еда — суши».",
color="#8aa0a8"))
for f in sorted(facts, key=lambda x: x.get("key", "")):
list_view.controls.append(_fact_row(f, on_forget=lambda k=f["key"]: do_forget(k)))
page.update()
def do_add(_):
k = key_input.value.strip()
v = val_input.value.strip()
if not k or not v:
return
svc.memory_remember(k, v)
key_input.value = ""
val_input.value = ""
render(search_input.value or "")
def do_forget(k: str):
svc.memory_forget(k)
render(search_input.value or "")
search_input.on_change = lambda e: render(e.control.value)
render()
return ft.Column(
[
ft.Text("Долговременная память", size=24, weight=ft.FontWeight.W_700),
ft.Text(
"Ключ-значение, которые Jarvis передаёт LLM как контекст пользователя.",
color="#8aa0a8", size=12,
),
ft.Row([key_input, val_input, ft.FilledButton(text="Добавить", on_click=do_add)]),
search_input,
list_view,
],
spacing=10,
expand=True,
)
def _fact_row(f: dict, on_forget) -> ft.Control:
return ft.Container(
content=ft.Row([
ft.Column([
ft.Text(f.get("key", ""), weight=ft.FontWeight.W_600, size=13),
ft.Text(f.get("value", ""), size=12, color="#a8b8c0"),
], spacing=2, expand=True),
ft.IconButton(
icon=ft.Icons.DELETE_FOREVER,
tooltip="Забыть",
on_click=lambda e: on_forget(),
),
]),
bgcolor="#15252b",
border_radius=8,
padding=ft.padding.symmetric(horizontal=12, vertical=8),
)

97
gui/pages/plugins.py Normal file
View file

@ -0,0 +1,97 @@
"""Plugins page — list user packs, toggle enabled, open folder."""
import flet as ft
from gui import services as svc
def build(page: ft.Page) -> ft.Control:
list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO)
def render():
list_view.controls.clear()
packs = svc.plugins_all()
if not packs:
list_view.controls.append(
ft.Column([
ft.Text(
"Плагинов пока нет. Создай папку с commands.yaml в "
"%APPDATA%\\com.priler.jarvis\\plugins\\, нажми «Обновить».",
color="#8aa0a8",
),
])
)
for p in packs:
list_view.controls.append(_pack_row(p, on_toggle=lambda n=p["name"], e=p["enabled"]: do_toggle(n, e)))
page.update()
def do_toggle(name: str, was_enabled: bool):
ok, msg = svc.plugins_set_enabled(name, not was_enabled)
page.open(ft.SnackBar(
content=ft.Text(
"Перезапусти main.py, чтобы применить." if ok else f"Ошибка: {msg}",
color="white",
),
bgcolor="#33d3a4" if ok else "#d8584f",
))
render()
def do_open(_):
path = svc.plugins_open_folder()
page.open(ft.SnackBar(content=ft.Text(f"Папка: {path}", color="white"), bgcolor="#33d3a4"))
render()
return ft.Column(
[
ft.Row([
ft.Text("Плагины", size=24, weight=ft.FontWeight.W_700),
ft.Row([
ft.OutlinedButton(text="Открыть папку", icon=ft.Icons.FOLDER_OPEN, on_click=do_open),
ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()),
]),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Text(
"Папки в %APPDATA%\\com.priler.jarvis\\plugins\\, "
"каждая содержит свой commands.yaml. Изменения подхватываются после рестарта main.py.",
color="#8aa0a8", size=12,
),
list_view,
],
spacing=10,
expand=True,
)
def _pack_row(p: dict, on_toggle) -> ft.Control:
err = p.get("error")
return ft.Container(
content=ft.Column([
ft.Row([
ft.Text(p["name"], weight=ft.FontWeight.W_600, size=14),
ft.Container(
content=ft.Text(
"ошибка" if err else f"{p['command_count']} команд",
size=10, color="white",
),
bgcolor="#7a2c2c" if err else ("#1e6b5a" if p["enabled"] else "#404040"),
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border_radius=10,
),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Text(p["path"], size=10, color="#5a6a72", font_family="Cascadia Mono"),
ft.Text(err, size=11, color="#ff8888") if err else ft.Container(height=0),
ft.Row([
ft.Switch(
value=p["enabled"],
on_change=lambda e: on_toggle(),
disabled=bool(err),
),
ft.Text("Включён" if p["enabled"] else "Выключен",
size=11, color="#33d3a4" if p["enabled"] else "#8aa0a8"),
]),
], spacing=6),
bgcolor="#15252b",
border_radius=8,
padding=12,
)

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

@ -0,0 +1,80 @@
"""Scheduler page — list tasks + remove."""
import datetime
import flet as ft
from gui import services as svc
def _fmt_ts(ts) -> str:
if not ts:
return ""
try:
return datetime.datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError):
return ""
def build(page: ft.Page) -> ft.Control:
list_view = ft.Column(spacing=8, expand=True, scroll=ft.ScrollMode.AUTO)
def render():
list_view.controls.clear()
tasks = svc.scheduler_all()
if not tasks:
list_view.controls.append(
ft.Text("Запланированных задач нет. Создавай голосом: «Каждые 30 минут напомни попить воды».",
color="#8aa0a8"))
for t in sorted(tasks, key=lambda x: x.get("created_at", 0)):
list_view.controls.append(_task_card(t, on_delete=lambda tid=t["id"]: do_remove(tid)))
page.update()
def do_remove(task_id: str):
if svc.scheduler_remove(task_id):
page.open(ft.SnackBar(content=ft.Text("Задача удалена", color="white"), bgcolor="#33d3a4"))
render()
render()
return ft.Column(
[
ft.Row([
ft.Text("Расписание", size=24, weight=ft.FontWeight.W_700),
ft.FilledButton(text="Обновить", icon=ft.Icons.REFRESH, on_click=lambda e: render()),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
list_view,
],
spacing=10,
expand=True,
)
def _task_card(t: dict, on_delete) -> ft.Control:
name = t.get("name", "?")
sched_h = t.get("schedule_human") or t.get("schedule_kind") or "?"
action = t.get("action") or {}
action_text = action.get("text") or action.get("speak") or action.get("phrase") or "(нет текста)"
return ft.Container(
content=ft.Column([
ft.Row([
ft.Text(name, weight=ft.FontWeight.W_600, size=14),
ft.Container(
content=ft.Text(sched_h, size=10),
bgcolor="#1e3a44",
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border_radius=10,
),
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
ft.Text(f"Действие: {action_text}", size=11, color="#a8b8c0"),
ft.Row([
ft.Text(f"создана: {_fmt_ts(t.get('created_at'))}", size=10, color="#5a6a72"),
ft.Text(f"последний запуск: {_fmt_ts(t.get('last_fired'))}", size=10, color="#5a6a72"),
]),
ft.Row([
ft.OutlinedButton(text="Удалить", icon=ft.Icons.DELETE, on_click=lambda e: on_delete()),
]),
], spacing=6),
bgcolor="#15252b",
border_radius=8,
padding=12,
)

77
gui/pages/settings.py Normal file
View file

@ -0,0 +1,77 @@
"""Settings page — active profile + LLM backend swap."""
import flet as ft
from gui import services as svc
def build(page: ft.Page) -> ft.Control:
profiles = svc.profile_list()
active_profile = svc.profile_active()
try:
llm_b, llm_m = svc.llm_active()
except (RuntimeError, OSError, ValueError) as exc:
llm_b, llm_m = "ошибка", str(exc)
profile_dd = ft.Dropdown(
label="Активный профиль",
options=[ft.dropdown.Option(p) for p in profiles],
value=active_profile,
expand=True,
)
def apply_profile(_):
target = profile_dd.value
if not target:
return
svc.profile_set(target)
page.open(ft.SnackBar(content=ft.Text(f"Профиль: {target}", color="white"), bgcolor="#33d3a4"))
llm_dd = ft.Dropdown(
label="LLM бэкенд",
options=[ft.dropdown.Option("groq"), ft.dropdown.Option("ollama")],
value=llm_b if llm_b in ("groq", "ollama") else "groq",
expand=True,
)
def apply_llm(_):
target = llm_dd.value
if not target:
return
try:
new = svc.llm_swap(target)
page.open(ft.SnackBar(content=ft.Text(f"LLM → {new}", color="white"), bgcolor="#33d3a4"))
except (ValueError, RuntimeError, OSError) as exc:
page.open(ft.SnackBar(content=ft.Text(f"Ошибка: {exc}", color="white"), bgcolor="#d8584f"))
return ft.Column(
[
ft.Text("Настройки", size=24, weight=ft.FontWeight.W_700),
ft.Container(
content=ft.Column([
ft.Text("Профиль", size=14, weight=ft.FontWeight.W_500),
ft.Text(
"Профили ограничивают какие команды активны (например, «sleep» отрубает шумные).",
color="#8aa0a8", size=11,
),
ft.Row([profile_dd, ft.FilledButton(text="Применить", on_click=apply_profile)]),
], spacing=8),
bgcolor="#15252b",
border_radius=8,
padding=14,
),
ft.Container(
content=ft.Column([
ft.Text("LLM", size=14, weight=ft.FontWeight.W_500),
ft.Text(f"Сейчас: {llm_b} · {llm_m}", color="#8aa0a8", size=11),
ft.Row([llm_dd, ft.FilledButton(text="Переключить", on_click=apply_llm)]),
], spacing=8),
bgcolor="#15252b",
border_radius=8,
padding=14,
),
],
spacing=14,
expand=True,
scroll=ft.ScrollMode.AUTO,
)