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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue