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

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