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