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

View file

@ -37,6 +37,35 @@ Vosk-модель для русского языка лежит в `model_small/
python main.py
```
## GUI (Flet)
Опциональная графическая панель — те же 7 страниц, что и в Rust-форке
(Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки).
```bash
pip install flet>=0.85 # одноразово, уже в requirements.txt
python -m gui # или просто gui.bat
```
GUI не запускает ассистент сам — это панель управления. Кнопка «Запустить
J.A.R.V.I.S.» на главной запускает `main.py` отдельным процессом.
## Плагины
Голосовые команды, которые лежат **вне** репозитория. Добавляются без правки
проекта: создайте папку в
```
%APPDATA%\com.priler.jarvis\plugins\<имя_плагина>\
commands.yaml (тот же формат, что и корневой commands.yaml)
disabled (опционально — пустой файл, отключает плагин)
```
Плагины подхватываются при следующем запуске `main.py`. ID встроенных команд
выигрывают конфликты (плагин не может переопределить, например, `open_browser`).
GUI-страница «Плагины» показывает список, флажки enable/disable и кнопку
«Открыть папку».
## Конфигурация
- `config.py`:

5
gui.bat Normal file
View file

@ -0,0 +1,5 @@
@echo off
REM Launch the Flet GUI. Requires `pip install flet>=0.85`.
pushd "%~dp0"
python -m gui
popd

19
gui/__init__.py Normal file
View file

@ -0,0 +1,19 @@
"""Python Flet GUI — Russian-language desktop UI mirroring the Rust Tauri GUI.
Entry point: `python -m gui` (see `__main__.py`).
Layout:
gui/__init__.py # this docstring
gui/__main__.py # `python -m gui` entry
gui/app.py # routing, layout, theme
gui/pages/ # one file per page (home, commands, macros, ...)
gui/services.py # thin sync wrappers around store modules
Design notes:
- Each page is a function `build(page: ft.Page) -> ft.Control` returning a
composable view. This keeps state per-page lazy and lets the user navigate
without prematurely loading everything.
- The GUI does not start the assistant; it only manipulates state files and
hot-launches `main.py` in a subprocess (mirroring how the Rust GUI starts
jarvis-app.exe via tray).
"""

5
gui/__main__.py Normal file
View file

@ -0,0 +1,5 @@
"""`python -m gui` — launch the Flet GUI."""
from gui.app import run
if __name__ == "__main__":
run()

81
gui/app.py Normal file
View file

@ -0,0 +1,81 @@
"""Flet GUI router and shell.
Provides:
- Top navigation bar with the 6 pages (Главная / Команды / Макросы / Расписание / Память / Плагины / Настройки)
- Page swap on click, no full reload
- Dark theme matching the Tauri GUI vibe
Each page lives in `gui/pages/<name>.py` and exposes `build(page) -> Control`.
"""
import flet as ft
from gui.pages import home, commands, macros, scheduler, memory, plugins, settings
PAGES = [
("Главная", home.build),
("Команды", commands.build),
("Макросы", macros.build),
("Расписание", scheduler.build),
("Память", memory.build),
("Плагины", plugins.build),
("Настройки", settings.build),
]
def _make_app(page: ft.Page):
page.title = "J.A.R.V.I.S. — Python GUI"
page.theme_mode = ft.ThemeMode.DARK
page.window.width = 940
page.window.height = 720
page.padding = 0
page.bgcolor = "#0a1419"
content_holder = ft.Container(expand=True, padding=20)
def go(idx: int):
nav.selected_index = idx
title, builder = PAGES[idx]
try:
content_holder.content = builder(page)
except (RuntimeError, ValueError, OSError) as exc:
content_holder.content = ft.Text(
f"Не удалось открыть «{title}»:\n{exc}",
color="red",
)
page.update()
nav = ft.NavigationRail(
selected_index=0,
label_type=ft.NavigationRailLabelType.ALL,
min_width=80,
min_extended_width=180,
bgcolor="#0d1b21",
destinations=[
ft.NavigationRailDestination(icon=ft.Icons.HOME, label="Главная"),
ft.NavigationRailDestination(icon=ft.Icons.LIST_ALT, label="Команды"),
ft.NavigationRailDestination(icon=ft.Icons.PLAY_CIRCLE_OUTLINE, label="Макросы"),
ft.NavigationRailDestination(icon=ft.Icons.SCHEDULE, label="Расписание"),
ft.NavigationRailDestination(icon=ft.Icons.MEMORY, label="Память"),
ft.NavigationRailDestination(icon=ft.Icons.EXTENSION, label="Плагины"),
ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, label="Настройки"),
],
on_change=lambda e: go(int(e.control.selected_index)),
)
layout = ft.Row(
controls=[
nav,
ft.VerticalDivider(width=1, color="#1f3540"),
content_holder,
],
expand=True,
)
page.add(layout)
go(0)
def run():
ft.app(target=_make_app)

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

159
gui/services.py Normal file
View file

@ -0,0 +1,159 @@
"""Thin wrappers around the store modules + helpers the GUI pages share.
Putting these here means each page imports `from gui import services as svc`
rather than importing internal store modules directly. If we later swap an
implementation (e.g. switching memory backend), only this file changes.
"""
import os
import sys
import subprocess
# Make sure project root is on sys.path when launched as `python -m gui`
# from inside C:\Jarvis\python\.
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
import memory_store
import profiles_store
import macros_store
import scheduler_store
import plugins_store
import llm_backend
# ---------- memory ----------
def memory_all() -> list[dict]:
return memory_store.all_facts()
def memory_remember(key: str, value: str) -> None:
memory_store.remember(key, value)
def memory_forget(key: str) -> bool:
return memory_store.forget(key)
def memory_search(query: str, limit: int = 5) -> list[dict]:
return memory_store.search(query, limit)
# ---------- profiles ----------
def profile_list() -> list[str]:
return profiles_store.list_names()
def profile_active() -> str:
return profiles_store.active_name()
def profile_set(name: str) -> dict:
return profiles_store.set_active(name)
# ---------- macros ----------
def macros_all() -> list[dict]:
out = []
for name in macros_store.list_names():
m = macros_store.get(name) or {}
out.append({
"name": name,
"steps_count": len(m.get("steps", [])),
"steps": m.get("steps", []),
"created_at": m.get("created_at", 0),
"last_run": m.get("last_run"),
})
return out
def macros_delete(name: str) -> bool:
return macros_store.delete(name)
def macros_recording() -> tuple[bool, str | None]:
return macros_store.is_recording(), macros_store.recording_name()
# ---------- scheduler ----------
def scheduler_all() -> list[dict]:
return scheduler_store.list_all()
def scheduler_remove(task_id: str) -> bool:
return scheduler_store.remove(task_id)
# ---------- plugins ----------
def plugins_all() -> list[dict]:
return plugins_store.list_packs()
def plugins_open_folder() -> str:
p = plugins_store.plugins_dir()
if os.name == "nt":
try:
subprocess.Popen(["explorer.exe", p])
except OSError:
pass
return p
def plugins_set_enabled(name: str, enabled: bool) -> tuple[bool, str]:
return plugins_store.set_enabled(name, enabled)
# ---------- LLM ----------
def llm_active() -> tuple[str, str]:
return llm_backend.current_backend(), llm_backend.current_model()
def llm_swap(target: str) -> str:
return llm_backend.swap_to(target)
# ---------- assistant launcher ----------
def assistant_run() -> bool:
"""Spawn main.py in a detached subprocess. Returns True on launch."""
try:
main_py = os.path.join(_PROJECT_ROOT, "main.py")
# CREATE_NEW_PROCESS_GROUP so it doesn't get killed with the GUI
creationflags = 0
if os.name == "nt":
creationflags = 0x00000200 # CREATE_NEW_PROCESS_GROUP
subprocess.Popen(
[sys.executable, main_py],
cwd=_PROJECT_ROOT,
creationflags=creationflags,
)
return True
except OSError as e:
print(f"[gui] failed to launch assistant: {e}")
return False
def assistant_is_running() -> bool:
"""Crude check: look for a python process running main.py from our dir.
Implementation note: we deliberately avoid pulling psutil keeps the GUI
install footprint small. The Rust fork uses sysinfo; here we just scan
`tasklist` on Windows.
"""
if os.name != "nt":
return False
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq python.exe", "/V", "/FO", "CSV"],
capture_output=True, text=True, timeout=2,
)
except (OSError, subprocess.SubprocessError):
return False
return "main.py" in out.stdout

13
main.py
View file

@ -19,6 +19,7 @@ import webrtcvad
import yaml
import extensions # new-command handlers ported from rust fork
import plugins_store # user plugin packs under APP_CONFIG_DIR/plugins/
from comtypes import CLSCTX_ALL
from fuzzywuzzy import fuzz
from pvrecorder import PvRecorder
@ -38,6 +39,18 @@ VA_CMD_LIST = yaml.safe_load(
open('commands.yaml', 'rt', encoding='utf8'),
)
# Merge in any user-installed plugin packs from APP_CONFIG_DIR/plugins/.
# Built-in ids take precedence (plugins cannot override stock commands).
_plugin_cmds = plugins_store.discover()
_added = 0
for _cmd_id, _entry in _plugin_cmds.items():
if _cmd_id in VA_CMD_LIST:
continue
VA_CMD_LIST[_cmd_id] = _entry
_added += 1
if _added > 0:
print(f"[plugins] loaded {_added} extra command(s) from APP_CONFIG_DIR/plugins/")
system_message = {"role": "system", "content": (
"Ты — J.A.R.V.I.S. (Just A Rather Very Intelligent System), ИИ-ассистент Тони Старка из киновселенной Marvel "
"(до событий Age of Ultron — ты НЕ Vision и НЕ FRIDAY). "

154
plugins_store.py Normal file
View file

@ -0,0 +1,154 @@
r"""User plugin loader — mirror of the Rust plugin system.
Plugins live in `<APP_CONFIG_DIR>/plugins/<name>/commands.yaml` and follow the
exact same YAML schema as the bundled `commands.yaml`. The user can drop new
voice command packs in there without touching the project tree.
Layout (per pack):
<APP_CONFIG_DIR>/plugins/
<pack_name>/
commands.yaml (required top-level dict, same schema)
disabled (optional empty file pack is skipped)
... (any helper files referenced by handlers)
Discovery is tolerant: a malformed yaml logs a warning and the pack is skipped;
it never breaks the rest of the loader.
`APP_CONFIG_DIR` resolves to `%APPDATA%\com.priler.jarvis\plugins\` on Windows,
matching the Rust fork so a single plugins folder can serve both installs.
The id-collision policy is "first wins, plugin loses": a plugin cannot override
a built-in command id. This protects against drive-by replacement of e.g.
`open_browser` by a malicious pack.
"""
import os
import yaml
from typing import Dict, List, Tuple
PLUGINS_DIR_NAME = "plugins"
DISABLED_FLAG = "disabled"
def app_config_dir() -> str:
"""Returns the per-user config dir matching the Rust fork's APP_CONFIG_DIR.
On Windows this is `%APPDATA%\\com.priler.jarvis\\`. We don't depend on
platform-dirs here std env vars are enough.
"""
appdata = os.environ.get("APPDATA")
if appdata:
return os.path.join(appdata, "com.priler.jarvis")
# fallback for non-Windows or unusual environments
return os.path.join(os.path.expanduser("~"), ".config", "com.priler.jarvis")
def plugins_dir() -> str:
"""Returns `<APP_CONFIG_DIR>/plugins/`, creating it lazily."""
d = os.path.join(app_config_dir(), PLUGINS_DIR_NAME)
try:
os.makedirs(d, exist_ok=True)
except OSError:
pass
return d
def discover() -> Dict[str, dict]:
"""Walks `plugins_dir()` and returns a merged dict of command_id -> entry.
Caller is expected to merge this into the main VA_CMD_LIST.
"""
return discover_in(plugins_dir())
def discover_in(directory: str) -> Dict[str, dict]:
"""Like `discover` but takes an explicit directory — used by tests."""
merged: Dict[str, dict] = {}
if not os.path.isdir(directory):
return merged
for name in sorted(os.listdir(directory)):
pack_path = os.path.join(directory, name)
if not os.path.isdir(pack_path):
continue
if os.path.isfile(os.path.join(pack_path, DISABLED_FLAG)):
continue
yaml_path = os.path.join(pack_path, "commands.yaml")
if not os.path.isfile(yaml_path):
continue
try:
with open(yaml_path, "rt", encoding="utf8") as f:
data = yaml.safe_load(f) or {}
except (OSError, yaml.YAMLError) as e:
print(f"[plugins] failed to load {yaml_path}: {e}")
continue
if not isinstance(data, dict):
print(f"[plugins] {yaml_path}: top-level must be a dict; skipping")
continue
for cmd_id, entry in data.items():
if not isinstance(cmd_id, str) or not isinstance(entry, dict):
continue
if cmd_id in merged:
print(f"[plugins] duplicate id '{cmd_id}' across packs; first wins")
continue
merged[cmd_id] = entry
return merged
def list_packs() -> List[dict]:
"""Light listing for the (eventual) Python GUI: name + enabled + count."""
return list_packs_in(plugins_dir())
def list_packs_in(directory: str) -> List[dict]:
out: List[dict] = []
if not os.path.isdir(directory):
return out
for name in sorted(os.listdir(directory)):
pack_path = os.path.join(directory, name)
if not os.path.isdir(pack_path):
continue
yaml_path = os.path.join(pack_path, "commands.yaml")
if not os.path.isfile(yaml_path):
continue
enabled = not os.path.isfile(os.path.join(pack_path, DISABLED_FLAG))
count = 0
error = None
try:
with open(yaml_path, "rt", encoding="utf8") as f:
data = yaml.safe_load(f) or {}
if isinstance(data, dict):
count = sum(1 for v in data.values() if isinstance(v, dict))
else:
error = "top-level must be a dict"
except (OSError, yaml.YAMLError) as e:
error = str(e)
out.append(
{
"name": name,
"path": pack_path,
"enabled": enabled,
"command_count": count,
"error": error,
}
)
return out
def set_enabled(name: str, enabled: bool) -> Tuple[bool, str]:
"""Toggle the `disabled` flag for a pack. Returns (ok, message)."""
pack = os.path.join(plugins_dir(), name)
if not os.path.isdir(pack):
return False, f"plugin '{name}' not found"
flag = os.path.join(pack, DISABLED_FLAG)
try:
if enabled and os.path.isfile(flag):
os.remove(flag)
elif not enabled and not os.path.isfile(flag):
with open(flag, "w", encoding="utf8") as f:
f.write("")
return True, "ok"
except OSError as e:
return False, str(e)

View file

@ -38,3 +38,8 @@ noisereduce>=3.0
ruamel.yaml
pywebview
pyautogui
# Flet — main jarvis-gui (mirrors the Rust Tauri GUI's 5 pages).
# Optional: install only if you want the desktop GUI; the assistant runs
# fine in console mode without it.
flet>=0.85

49
tests/test_gui_smoke.py Normal file
View file

@ -0,0 +1,49 @@
"""Smoke tests for the Flet GUI module — verifies that imports work and that
each page builder is callable. We do NOT launch a real Flet window; we mock
the `flet.Page` object enough that the builders execute without errors.
If Flet isn't installed, these tests skip cleanly so the suite still passes
on console-only installs.
"""
import pytest
flet = pytest.importorskip("flet")
def test_gui_app_module_imports():
from gui import app # pylint: disable=import-outside-toplevel
# The module must expose the canonical 7 pages.
titles = [t for t, _ in app.PAGES]
assert "Главная" in titles
assert "Команды" in titles
assert "Макросы" in titles
assert "Расписание" in titles
assert "Память" in titles
assert "Плагины" in titles
assert "Настройки" in titles
def test_services_module_imports_cleanly():
# services pulls in memory_store, profiles_store, macros_store, scheduler_store
# and llm_backend. None of those should fail to import in a fresh process.
from gui import services # pylint: disable=import-outside-toplevel
# Just verify the public surface exists.
for fn in (
"memory_all", "memory_remember", "memory_forget", "memory_search",
"profile_list", "profile_active", "profile_set",
"macros_all", "macros_delete", "macros_recording",
"scheduler_all", "scheduler_remove",
"plugins_all", "plugins_open_folder", "plugins_set_enabled",
"llm_active", "llm_swap",
"assistant_run", "assistant_is_running",
):
assert callable(getattr(services, fn)), f"missing or non-callable: {fn}"
def test_each_page_module_exposes_build_callable():
from gui.pages import home, commands, macros, scheduler, memory, plugins, settings
for mod in (home, commands, macros, scheduler, memory, plugins, settings):
assert callable(getattr(mod, "build", None)), \
f"{mod.__name__}.build missing"

View file

@ -0,0 +1,96 @@
"""Tests for `plugins_store.py` — mirrors the Rust `plugins.rs` tests."""
import os
import pytest
import plugins_store
def write(path, content):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wt", encoding="utf8") as f:
f.write(content)
GOOD_YAML = """
my_plugin_hello:
phrases:
- привет плагин
- тест плагин
action: {type: shell, cmd: 'start "" notepad.exe'}
"""
BAD_YAML = "this is :: not valid yaml ::\n ::: -"
def test_discover_empty_dir_returns_empty(tmp_path):
assert plugins_store.discover_in(str(tmp_path)) == {}
def test_discover_loads_yaml(tmp_path):
write(str(tmp_path / "greeter" / "commands.yaml"), GOOD_YAML)
out = plugins_store.discover_in(str(tmp_path))
assert "my_plugin_hello" in out
assert out["my_plugin_hello"]["action"]["type"] == "shell"
def test_disabled_flag_skips_pack(tmp_path):
write(str(tmp_path / "muted" / "commands.yaml"), GOOD_YAML)
write(str(tmp_path / "muted" / plugins_store.DISABLED_FLAG), "")
out = plugins_store.discover_in(str(tmp_path))
assert out == {}
def test_malformed_yaml_does_not_break_other_packs(tmp_path):
write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML)
write(str(tmp_path / "good" / "commands.yaml"), GOOD_YAML)
out = plugins_store.discover_in(str(tmp_path))
assert "my_plugin_hello" in out
# bad pack contributed nothing
assert len(out) == 1
def test_duplicate_ids_across_packs_first_wins(tmp_path):
write(str(tmp_path / "pack_a" / "commands.yaml"), GOOD_YAML)
write(
str(tmp_path / "pack_b" / "commands.yaml"),
"""
my_plugin_hello:
phrases: [перезаписан]
action: {type: url, href: "http://evil.com"}
""",
)
out = plugins_store.discover_in(str(tmp_path))
# alphabetical order means pack_a wins
assert out["my_plugin_hello"]["action"]["type"] == "shell"
def test_non_dirs_ignored(tmp_path):
write(str(tmp_path / "README.md"), "# not a pack")
out = plugins_store.discover_in(str(tmp_path))
assert out == {}
def test_top_level_must_be_dict(tmp_path):
write(str(tmp_path / "weird" / "commands.yaml"), "- just\n- a\n- list\n")
out = plugins_store.discover_in(str(tmp_path))
assert out == {}
def test_list_packs_in_reports_both_states(tmp_path):
write(str(tmp_path / "alpha" / "commands.yaml"), GOOD_YAML)
write(str(tmp_path / "bravo" / "commands.yaml"), GOOD_YAML)
write(str(tmp_path / "bravo" / plugins_store.DISABLED_FLAG), "")
listing = plugins_store.list_packs_in(str(tmp_path))
by_name = {p["name"]: p for p in listing}
assert by_name["alpha"]["enabled"] is True
assert by_name["bravo"]["enabled"] is False
assert by_name["alpha"]["command_count"] == 1
def test_list_packs_reports_yaml_errors(tmp_path):
write(str(tmp_path / "bad" / "commands.yaml"), BAD_YAML)
listing = plugins_store.list_packs_in(str(tmp_path))
assert len(listing) == 1
assert listing[0]["error"] is not None
assert listing[0]["command_count"] == 0