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:
parent
2bef1d5ea7
commit
59a1258dcc
19 changed files with 1233 additions and 0 deletions
29
README.md
29
README.md
|
|
@ -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
5
gui.bat
Normal 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
19
gui/__init__.py
Normal 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
5
gui/__main__.py
Normal 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
81
gui/app.py
Normal 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
1
gui/pages/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`."""
|
||||
80
gui/pages/commands.py
Normal file
80
gui/pages/commands.py
Normal 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
119
gui/pages/home.py
Normal 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
90
gui/pages/macros.py
Normal 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
74
gui/pages/memory.py
Normal 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
97
gui/pages/plugins.py
Normal 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
80
gui/pages/scheduler.py
Normal 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
77
gui/pages/settings.py
Normal 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
159
gui/services.py
Normal 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
13
main.py
|
|
@ -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
154
plugins_store.py
Normal 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)
|
||||
|
|
@ -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
49
tests/test_gui_smoke.py
Normal 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"
|
||||
96
tests/test_plugins_store.py
Normal file
96
tests/test_plugins_store.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue