diff --git a/README.md b/README.md index 12abbd5..417165b 100644 --- a/README.md +++ b/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`: diff --git a/gui.bat b/gui.bat new file mode 100644 index 0000000..6c8f127 --- /dev/null +++ b/gui.bat @@ -0,0 +1,5 @@ +@echo off +REM Launch the Flet GUI. Requires `pip install flet>=0.85`. +pushd "%~dp0" +python -m gui +popd diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..9c17412 --- /dev/null +++ b/gui/__init__.py @@ -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). +""" diff --git a/gui/__main__.py b/gui/__main__.py new file mode 100644 index 0000000..ff07ddf --- /dev/null +++ b/gui/__main__.py @@ -0,0 +1,5 @@ +"""`python -m gui` — launch the Flet GUI.""" +from gui.app import run + +if __name__ == "__main__": + run() diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..afe2f4b --- /dev/null +++ b/gui/app.py @@ -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/.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) diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py new file mode 100644 index 0000000..2302ae1 --- /dev/null +++ b/gui/pages/__init__.py @@ -0,0 +1 @@ +"""Page modules: each exposes `build(page: flet.Page) -> flet.Control`.""" diff --git a/gui/pages/commands.py b/gui/pages/commands.py new file mode 100644 index 0000000..b8a622f --- /dev/null +++ b/gui/pages/commands.py @@ -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, + ) diff --git a/gui/pages/home.py b/gui/pages/home.py new file mode 100644 index 0000000..a59d59c --- /dev/null +++ b/gui/pages/home.py @@ -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 diff --git a/gui/pages/macros.py b/gui/pages/macros.py new file mode 100644 index 0000000..f89267f --- /dev/null +++ b/gui/pages/macros.py @@ -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, + ) diff --git a/gui/pages/memory.py b/gui/pages/memory.py new file mode 100644 index 0000000..fb8f549 --- /dev/null +++ b/gui/pages/memory.py @@ -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), + ) diff --git a/gui/pages/plugins.py b/gui/pages/plugins.py new file mode 100644 index 0000000..233020c --- /dev/null +++ b/gui/pages/plugins.py @@ -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, + ) diff --git a/gui/pages/scheduler.py b/gui/pages/scheduler.py new file mode 100644 index 0000000..d46f05f --- /dev/null +++ b/gui/pages/scheduler.py @@ -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, + ) diff --git a/gui/pages/settings.py b/gui/pages/settings.py new file mode 100644 index 0000000..b3db0bd --- /dev/null +++ b/gui/pages/settings.py @@ -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, + ) diff --git a/gui/services.py b/gui/services.py new file mode 100644 index 0000000..7d76728 --- /dev/null +++ b/gui/services.py @@ -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 diff --git a/main.py b/main.py index e03a185..ffc2020 100644 --- a/main.py +++ b/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). " diff --git a/plugins_store.py b/plugins_store.py new file mode 100644 index 0000000..6a34ad3 --- /dev/null +++ b/plugins_store.py @@ -0,0 +1,154 @@ +r"""User plugin loader — mirror of the Rust plugin system. + +Plugins live in `/plugins//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): + + /plugins/ + / + 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 `/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) diff --git a/requirements.txt b/requirements.txt index 780ab1e..070fb42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_gui_smoke.py b/tests/test_gui_smoke.py new file mode 100644 index 0000000..909420c --- /dev/null +++ b/tests/test_gui_smoke.py @@ -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" diff --git a/tests/test_plugins_store.py b/tests/test_plugins_store.py new file mode 100644 index 0000000..03495f5 --- /dev/null +++ b/tests/test_plugins_store.py @@ -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