v3 M1c (host): provider selector — control panel + live-switch plumbing
Panel: new Providers tab (data-driven from GET /api/providers — labels/accents from the daemon registry, enable/active/creds from config). z.ai base_url + key, api_key masked and preserved on save like the HA token. Daemon: request_provider_switch() lets the panel push an active-provider change into the live BLE session (same _set_active_provider path as the watch) so it takes effect now, not on the next 60s poll; _active_session exposes the session to the panel thread. On-watch cycle groundwork: provnext -> _cycle_provider (daemon owns the enabled set/order), and pnm/pi/pc pushed in the payload so the watch's Provider screen can show the name + "1/3". server.py: ConfigIn gains providers/active_provider/display_order; secrets masked in _masked; active_provider change notifies the injected _command_sink. tray wires set_command_sink(request_provider_switch). Tests: test_server_providers (masking + sink), test_provider_switch (cycle + offline persist). 106 passed, 2 Linux-only failures (baseline). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -431,6 +431,13 @@ class Session:
|
||||
if isinstance(pid, str) and loop is not None:
|
||||
loop.call_soon_threadsafe(self._set_active_provider, pid)
|
||||
return
|
||||
# v3: watch's Provider screen "switch" tap ({"cmd":"provnext"}) — the watch
|
||||
# doesn't know the enabled set/order, so the daemon cycles to the next one.
|
||||
if payload.get("cmd") == "provnext":
|
||||
loop = self._loop
|
||||
if loop is not None:
|
||||
loop.call_soon_threadsafe(self._cycle_provider)
|
||||
return
|
||||
# Phase 7 M2: a watch button press carries only its index — map it to the
|
||||
# configured action/entity/value here (the watch stays dumb).
|
||||
if payload.get("cmd") == "btn":
|
||||
@@ -469,6 +476,21 @@ class Session:
|
||||
except Exception as e:
|
||||
log(f"Provider switch failed: {e!r}")
|
||||
|
||||
def _cycle_provider(self) -> None:
|
||||
"""Advance to the next enabled provider (on-watch 'switch' tap). Runs on
|
||||
the loop thread. With a single enabled provider this is a no-op poll."""
|
||||
try:
|
||||
try:
|
||||
from daemon.config import active_provider_id
|
||||
except ImportError:
|
||||
from config import active_provider_id
|
||||
ids = _enabled_ids()
|
||||
cur = active_provider_id()
|
||||
i = ids.index(cur) if cur in ids else -1
|
||||
self._set_active_provider(ids[(i + 1) % len(ids)])
|
||||
except Exception as e:
|
||||
log(f"Provider cycle failed: {e!r}")
|
||||
|
||||
def _handle_battery(self, payload: dict) -> None:
|
||||
pct = payload.get("bat")
|
||||
charging = bool(payload.get("chg", 0))
|
||||
@@ -826,6 +848,53 @@ def _active_provider():
|
||||
return pid, get_provider(pid)
|
||||
|
||||
|
||||
def _enabled_ids() -> list:
|
||||
"""Enabled provider ids in on-watch cycle order (never empty — the watch
|
||||
always has at least Anthropic to cycle/show). Used for both the on-watch
|
||||
'switch' cycle and the pi/pc badge sent to the Provider screen."""
|
||||
try:
|
||||
try:
|
||||
from daemon.config import enabled_providers
|
||||
except ImportError:
|
||||
from config import enabled_providers
|
||||
return enabled_providers() or ["anthropic"]
|
||||
except Exception:
|
||||
return ["anthropic"]
|
||||
|
||||
|
||||
# The live Session, exposed module-wide so the control-panel HTTP thread can push
|
||||
# an active-provider switch into the BLE loop (set on connect, cleared on exit).
|
||||
_active_session = None
|
||||
|
||||
|
||||
def request_provider_switch(pid: str) -> None:
|
||||
"""Control-panel hook: switch the watch's displayed provider immediately,
|
||||
reusing the exact on-watch path (persist config + refresh-poll + BLE push).
|
||||
Called from the panel's HTTP thread, so dispatch onto the BLE loop. If no
|
||||
watch is connected, persist directly so the next connect uses it. Never raises."""
|
||||
if not isinstance(pid, str):
|
||||
return
|
||||
sess = _active_session
|
||||
loop = getattr(sess, "_loop", None) if sess is not None else None
|
||||
if sess is not None and loop is not None:
|
||||
loop.call_soon_threadsafe(sess._set_active_provider, pid)
|
||||
return
|
||||
# No live session/loop yet: persist directly. (The panel PUT already saved the
|
||||
# config, so this is usually a no-op — but it keeps the hook correct on its own.)
|
||||
try:
|
||||
try:
|
||||
from daemon.config import load_config, save_config, PROVIDER_IDS
|
||||
except ImportError:
|
||||
from config import load_config, save_config, PROVIDER_IDS
|
||||
if pid in PROVIDER_IDS:
|
||||
cfg = load_config()
|
||||
if cfg.get("active_provider") != pid:
|
||||
cfg["active_provider"] = pid
|
||||
save_config(cfg)
|
||||
except Exception as e:
|
||||
log(f"Provider switch (offline) failed: {e!r}")
|
||||
|
||||
|
||||
async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> bool:
|
||||
"""Connect to device and poll until disconnected or stopped.
|
||||
|
||||
@@ -876,6 +945,8 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
|
||||
log("Connected")
|
||||
session = Session(client, tray_state)
|
||||
global _active_session
|
||||
_active_session = session # let the control panel reach this session for live switches
|
||||
await session.setup_refresh_subscription()
|
||||
await session.setup_command_subscription()
|
||||
|
||||
@@ -915,10 +986,16 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
auth_problem = status.auth_problem
|
||||
cached = status.to_payload()
|
||||
cached["pv"] = active_pv # which brand theme the watch wears
|
||||
cached["pnm"] = str(provider.label)[:16] # name for the Provider screen
|
||||
try: # brand accent (0xRRGGBB) for the theme
|
||||
cached["ac"] = int(provider.accent, 16)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# 1-based position + count among enabled providers, so the watch's
|
||||
# Provider screen shows "1/3" and hides the switch hint when alone.
|
||||
_ids = _enabled_ids()
|
||||
cached["pc"] = len(_ids)
|
||||
cached["pi"] = (_ids.index(active_pv) + 1) if active_pv in _ids else 1
|
||||
last_claude_poll = now
|
||||
|
||||
# Now Playing (Phase 5) — best-effort Windows media session, read every
|
||||
@@ -984,6 +1061,7 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
await _wait_first(session.refresh_requested, stop_event,
|
||||
timeout=NOWPLAYING_INTERVAL)
|
||||
finally:
|
||||
_active_session = None # no live session for the panel to push into
|
||||
# Clean GATT disconnect on the way out — this is what tells the peripheral
|
||||
# the link is gone. WinRT can surface a raw OSError (not BleakError) here,
|
||||
# so swallow both; the link tears down regardless once we exit.
|
||||
|
||||
@@ -40,6 +40,11 @@ def _masked(cfg: dict) -> dict:
|
||||
c = copy.deepcopy(cfg)
|
||||
if c.get("ha", {}).get("token"):
|
||||
c["ha"]["token"] = MASK
|
||||
# Provider secrets (e.g. z.ai api_key) get the same treatment as the HA token:
|
||||
# never leave this process. The UI echoes the mask back to keep the stored one.
|
||||
for pconf in (c.get("providers") or {}).values():
|
||||
if isinstance(pconf, dict) and pconf.get("api_key"):
|
||||
pconf["api_key"] = MASK
|
||||
return c
|
||||
|
||||
|
||||
@@ -55,6 +60,9 @@ class ConfigIn(BaseModel):
|
||||
ha: dict | None = None
|
||||
buttons: list | None = None
|
||||
settings: dict | None = None
|
||||
providers: dict | None = None # v3: per-provider {enabled, base_url, api_key}
|
||||
active_provider: str | None = None # which provider the watch displays
|
||||
display_order: list | None = None # on-watch cycle order
|
||||
|
||||
|
||||
@app.put("/api/config")
|
||||
@@ -71,10 +79,61 @@ def put_config(incoming: ConfigIn) -> dict:
|
||||
cfg["settings"].update(data["settings"])
|
||||
if "buttons" in data:
|
||||
cfg["buttons"] = data["buttons"]
|
||||
# v3 providers — merge per known id; a masked api_key echoed back keeps the
|
||||
# stored secret (same discipline as the HA token).
|
||||
if isinstance(data.get("providers"), dict):
|
||||
for pid, pconf in data["providers"].items():
|
||||
if pid not in cfg["providers"] or not isinstance(pconf, dict):
|
||||
continue
|
||||
pconf = dict(pconf)
|
||||
if pconf.get("api_key") == MASK:
|
||||
pconf["api_key"] = cfg["providers"][pid].get("api_key", "")
|
||||
cfg["providers"][pid].update(pconf)
|
||||
if isinstance(data.get("display_order"), list):
|
||||
cfg["display_order"] = data["display_order"]
|
||||
switched = None
|
||||
if isinstance(data.get("active_provider"), str):
|
||||
if data["active_provider"] != cfg.get("active_provider"):
|
||||
switched = data["active_provider"]
|
||||
cfg["active_provider"] = data["active_provider"]
|
||||
cfgmod.save_config(cfg)
|
||||
# Push the switch to a connected watch now (same path as the on-watch button)
|
||||
# instead of waiting for the ~60s poll to notice the config change. Best-effort.
|
||||
if switched is not None and _command_sink is not None:
|
||||
try:
|
||||
_command_sink(switched)
|
||||
except Exception:
|
||||
pass
|
||||
return _masked(cfg)
|
||||
|
||||
|
||||
@app.get("/api/providers")
|
||||
def get_providers() -> dict:
|
||||
"""Provider list for the panel's Providers tab: brand label + accent come from
|
||||
the daemon registry, enabled/creds state from config. Data-driven so adding a
|
||||
provider (a daemon class + a config default) needs zero web-UI edits. Secrets
|
||||
are never sent — only whether a key is stored (has_key)."""
|
||||
cfg = cfgmod.load_config()
|
||||
try:
|
||||
from daemon.providers import get_provider
|
||||
except ImportError:
|
||||
from providers import get_provider
|
||||
out = []
|
||||
for pid in cfgmod.PROVIDER_IDS:
|
||||
p = get_provider(pid)
|
||||
pconf = cfg["providers"].get(pid, {})
|
||||
out.append({
|
||||
"id": pid,
|
||||
"label": p.label,
|
||||
"accent": p.accent,
|
||||
"enabled": bool(pconf.get("enabled")),
|
||||
"needs_key": "api_key" in pconf, # z.ai-style base_url + key creds
|
||||
"base_url": pconf.get("base_url", ""),
|
||||
"has_key": bool(pconf.get("api_key")),
|
||||
})
|
||||
return {"providers": out, "active": cfg["active_provider"], "order": cfg["display_order"]}
|
||||
|
||||
|
||||
def _resolve_token(token: str) -> str:
|
||||
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
|
||||
|
||||
@@ -123,6 +182,7 @@ async def ha_entities() -> dict:
|
||||
|
||||
|
||||
_status_provider = None # set by the tray to expose live BLE/daemon state
|
||||
_command_sink = None # set by the tray: called with a provider id to live-switch the watch
|
||||
|
||||
|
||||
def set_status_provider(fn) -> None:
|
||||
@@ -132,6 +192,14 @@ def set_status_provider(fn) -> None:
|
||||
_status_provider = fn
|
||||
|
||||
|
||||
def set_command_sink(fn) -> None:
|
||||
"""The tray injects a callable(provider_id) that pushes an active-provider
|
||||
switch to the live BLE session. Optional — a panel edit still persists to
|
||||
config (and the daemon picks it up on its next poll) if this is unset."""
|
||||
global _command_sink
|
||||
_command_sink = fn
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def get_status() -> dict:
|
||||
if _status_provider is not None:
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""v3 M1c — provider switching (on-watch cycle + control-panel offline hook).
|
||||
|
||||
The daemon owns the enabled set/order, so the watch's "switch" tap just asks it
|
||||
to advance (provnext -> _cycle_provider) and the panel's active_provider edit is
|
||||
mirrored into the live loop (request_provider_switch). Both funnel through the
|
||||
same _set_active_provider path exercised here without a real BLE loop.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from daemon import config
|
||||
|
||||
|
||||
def _cfg(tmp_path, monkeypatch, data: dict):
|
||||
p = tmp_path / "config.json"
|
||||
p.write_text(json.dumps(data), encoding="utf-8")
|
||||
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
return p
|
||||
|
||||
|
||||
def test_enabled_ids_defaults_to_anthropic(tmp_path, monkeypatch):
|
||||
from daemon import claude_usage_daemon_windows as d
|
||||
_cfg(tmp_path, monkeypatch, {"version": 2})
|
||||
assert d._enabled_ids() == ["anthropic"]
|
||||
|
||||
|
||||
def test_cycle_advances_and_wraps(tmp_path, monkeypatch):
|
||||
from daemon import claude_usage_daemon_windows as d
|
||||
_cfg(tmp_path, monkeypatch, {
|
||||
"version": 2,
|
||||
"display_order": ["anthropic", "openai", "zai"],
|
||||
"providers": {"anthropic": {"enabled": True},
|
||||
"openai": {"enabled": True},
|
||||
"zai": {"enabled": True}},
|
||||
"active_provider": "anthropic"})
|
||||
sess = d.Session(MagicMock())
|
||||
sess._cycle_provider()
|
||||
assert config.active_provider_id() == "openai"
|
||||
sess._cycle_provider()
|
||||
assert config.active_provider_id() == "zai"
|
||||
sess._cycle_provider() # wraps back to the start
|
||||
assert config.active_provider_id() == "anthropic"
|
||||
|
||||
|
||||
def test_cycle_skips_disabled(tmp_path, monkeypatch):
|
||||
from daemon import claude_usage_daemon_windows as d
|
||||
_cfg(tmp_path, monkeypatch, {
|
||||
"version": 2,
|
||||
"display_order": ["anthropic", "openai", "zai"],
|
||||
"providers": {"anthropic": {"enabled": True},
|
||||
"openai": {"enabled": False},
|
||||
"zai": {"enabled": True}},
|
||||
"active_provider": "anthropic"})
|
||||
sess = d.Session(MagicMock())
|
||||
sess._cycle_provider()
|
||||
assert config.active_provider_id() == "zai" # openai skipped (disabled)
|
||||
|
||||
|
||||
def test_cycle_single_enabled_is_noop(tmp_path, monkeypatch):
|
||||
from daemon import claude_usage_daemon_windows as d
|
||||
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
|
||||
sess = d.Session(MagicMock())
|
||||
sess._cycle_provider()
|
||||
assert config.active_provider_id() == "anthropic"
|
||||
|
||||
|
||||
def test_request_switch_offline_persists(tmp_path, monkeypatch):
|
||||
from daemon import claude_usage_daemon_windows as d
|
||||
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
|
||||
monkeypatch.setattr(d, "_active_session", None)
|
||||
d.request_provider_switch("zai")
|
||||
assert config.active_provider_id() == "zai"
|
||||
|
||||
|
||||
def test_request_switch_ignores_bad_id(tmp_path, monkeypatch):
|
||||
from daemon import claude_usage_daemon_windows as d
|
||||
_cfg(tmp_path, monkeypatch, {"version": 2, "active_provider": "anthropic"})
|
||||
monkeypatch.setattr(d, "_active_session", None)
|
||||
d.request_provider_switch("bogus")
|
||||
assert config.active_provider_id() == "anthropic"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""v3 M1c — control-panel provider API: secret masking + live-switch sink.
|
||||
|
||||
Provider api_keys (z.ai) get the same never-leave-the-process treatment as the
|
||||
HA token, and flipping active_provider notifies the injected command sink so a
|
||||
connected watch switches without waiting for the ~60s poll.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from daemon import config, server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
cfg = {
|
||||
"version": 2,
|
||||
"providers": {
|
||||
"anthropic": {"enabled": True},
|
||||
"openai": {"enabled": False},
|
||||
"zai": {"enabled": False, "base_url": "https://api.z.ai", "api_key": "secret-key"},
|
||||
},
|
||||
"active_provider": "anthropic",
|
||||
"display_order": ["anthropic", "openai", "zai"],
|
||||
}
|
||||
p = tmp_path / "config.json"
|
||||
p.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
monkeypatch.setenv("CLAWDMETER_CONFIG", str(p))
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
monkeypatch.setattr(server, "_command_sink", None)
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
def test_zai_api_key_masked_in_get(client):
|
||||
r = client.get("/api/config").json()
|
||||
assert r["providers"]["zai"]["api_key"] == server.MASK
|
||||
assert r["providers"]["zai"]["base_url"] == "https://api.z.ai" # non-secret shown
|
||||
|
||||
|
||||
def test_masked_api_key_preserved_on_put(client):
|
||||
# Echo the mask back unchanged => keep the stored secret; other fields apply.
|
||||
client.put("/api/config",
|
||||
json={"providers": {"zai": {"api_key": server.MASK, "enabled": True}}})
|
||||
saved = config.load_config()["providers"]["zai"]
|
||||
assert saved["api_key"] == "secret-key"
|
||||
assert saved["enabled"] is True
|
||||
|
||||
|
||||
def test_real_api_key_stored_on_put(client):
|
||||
client.put("/api/config", json={"providers": {"zai": {"api_key": "new-key"}}})
|
||||
assert config.load_config()["providers"]["zai"]["api_key"] == "new-key"
|
||||
|
||||
|
||||
def test_active_provider_switch_calls_sink(client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
|
||||
client.put("/api/config", json={"active_provider": "openai"})
|
||||
assert calls == ["openai"]
|
||||
assert config.active_provider_id() == "openai"
|
||||
|
||||
|
||||
def test_no_switch_when_active_unchanged(client, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(server, "_command_sink", lambda pid: calls.append(pid))
|
||||
client.put("/api/config", json={"active_provider": "anthropic"}) # already active
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_unknown_provider_id_ignored_on_put(client):
|
||||
client.put("/api/config", json={"providers": {"bogus": {"enabled": True}}})
|
||||
assert "bogus" not in config.load_config()["providers"]
|
||||
@@ -226,7 +226,8 @@ def main() -> None:
|
||||
from pystray import Menu, MenuItem
|
||||
|
||||
import daemon.autostart_windows as autostart
|
||||
from daemon.claude_usage_daemon_windows import main as daemon_main, log as daemon_log
|
||||
from daemon.claude_usage_daemon_windows import (
|
||||
main as daemon_main, log as daemon_log, request_provider_switch)
|
||||
from daemon.icon_assets import load_logo_rgba, build_state_icons
|
||||
|
||||
# Build per-state icons once at startup; swap icon.icon per tick (never recomposite).
|
||||
@@ -277,6 +278,7 @@ def main() -> None:
|
||||
from daemon.config import DEFAULT_PORT
|
||||
panel_port = DEFAULT_PORT
|
||||
panel_server.set_status_provider(lambda: _status_dict(ts))
|
||||
panel_server.set_command_sink(request_provider_switch)
|
||||
panel_server.serve_in_thread(panel_port)
|
||||
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -51,6 +51,10 @@
|
||||
.stat .k{font-size:12px;color:var(--dim)}
|
||||
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
|
||||
.muted{color:var(--dim);font-size:12.5px}
|
||||
.swatch{width:13px;height:13px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.12)}
|
||||
label.inline{display:inline-flex;align-items:center;gap:7px;margin:0;color:var(--text);font-size:13px;cursor:pointer}
|
||||
label.inline input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
|
||||
label.inline.off{color:var(--dim);cursor:default}
|
||||
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
#toast.show{opacity:1}
|
||||
</style>
|
||||
@@ -60,6 +64,7 @@
|
||||
<nav class="nav">
|
||||
<div class="brand"><span class="dot"></span>Clawdmeter</div>
|
||||
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
|
||||
<button class="tabbtn" data-tab="providers"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>Providers</button>
|
||||
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
|
||||
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
|
||||
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
|
||||
@@ -79,6 +84,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROVIDERS -->
|
||||
<section class="tab" data-tab="providers">
|
||||
<h1>Providers</h1>
|
||||
<div class="sub">Which usage source the watch shows — and its brand colour</div>
|
||||
<div id="prov-list"></div>
|
||||
<div class="actions"><button class="primary" id="prov-save">Save</button></div>
|
||||
</section>
|
||||
|
||||
<!-- HOME ASSISTANT -->
|
||||
<section class="tab" data-tab="ha">
|
||||
<h1>Home Assistant</h1>
|
||||
@@ -146,6 +159,7 @@ document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
|
||||
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
|
||||
const id=b.dataset.tab;
|
||||
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
|
||||
if(id==="providers") loadProviders().catch(e=>toast("Load failed: "+e.message));
|
||||
});
|
||||
|
||||
// ---- load ----
|
||||
@@ -251,6 +265,64 @@ $("#set-save").onclick = async ()=>{
|
||||
}catch(e){ toast("Error: "+e.message); }
|
||||
};
|
||||
|
||||
// ---- Providers ----
|
||||
let providers = []; // [{id,label,accent,enabled,needs_key,base_url,has_key,...}]
|
||||
let activeProv = "anthropic";
|
||||
async function loadProviders(){
|
||||
const r = await api("/api/providers");
|
||||
providers = r.providers; activeProv = r.active;
|
||||
// Seed the editable secret from has_key: a stored key shows (and re-saves) as the
|
||||
// mask, so leaving it untouched preserves it; typing over it sends the new key.
|
||||
providers.forEach(p=>{ if(p.needs_key) p.api_key = p.has_key ? MASK : ""; });
|
||||
renderProviders();
|
||||
}
|
||||
function esc(s){return (s||"").replace(/"/g,""");}
|
||||
function renderProviders(){
|
||||
const list=$("#prov-list"); list.innerHTML="";
|
||||
providers.forEach(p=>{
|
||||
const card=document.createElement("div"); card.className="card";
|
||||
const keyBlock = p.needs_key ? `
|
||||
<div data-keys="${p.id}" style="${p.enabled?"":"display:none"};margin-top:12px">
|
||||
<label>Base URL</label>
|
||||
<input type="text" data-pf="base_url" data-pid="${p.id}" value="${esc(p.base_url)}" placeholder="https://api.z.ai/api/anthropic" autocomplete="off">
|
||||
<label>API key</label>
|
||||
<input type="password" data-pf="api_key" data-pid="${p.id}" value="${esc(p.api_key)}" placeholder="Paste key" autocomplete="off">
|
||||
</div>` : "";
|
||||
const actCls = p.enabled?"inline":"inline off";
|
||||
card.innerHTML=`
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<div class="row" style="gap:10px"><span class="swatch" style="background:#${p.accent}"></span><b style="font-weight:500">${p.label}</b></div>
|
||||
<div class="row" style="gap:18px">
|
||||
<label class="inline"><input type="checkbox" data-pen="${p.id}" ${p.enabled?"checked":""}> Enabled</label>
|
||||
<label class="${actCls}"><input type="radio" name="activeprov" data-pact="${p.id}" ${activeProv===p.id?"checked":""} ${p.enabled?"":"disabled"}> Show on watch</label>
|
||||
</div>
|
||||
</div>${keyBlock}`;
|
||||
list.appendChild(card);
|
||||
});
|
||||
list.querySelectorAll("[data-pen]").forEach(el=>el.onchange=()=>{
|
||||
const p=providers.find(x=>x.id===el.dataset.pen); p.enabled=el.checked;
|
||||
if(!el.checked && activeProv===p.id){ // disabling the shown one → hand off
|
||||
const alt=providers.find(x=>x.enabled); activeProv = alt?alt.id:"anthropic";
|
||||
}
|
||||
if(el.checked && !providers.some(x=>x.enabled&&x.id===activeProv)) activeProv=p.id;
|
||||
renderProviders();
|
||||
});
|
||||
list.querySelectorAll("[data-pact]").forEach(el=>el.onchange=()=>{activeProv=el.dataset.pact;});
|
||||
list.querySelectorAll("[data-pf]").forEach(el=>el.oninput=()=>{
|
||||
providers.find(x=>x.id===el.dataset.pid)[el.dataset.pf]=el.value;
|
||||
});
|
||||
}
|
||||
$("#prov-save").onclick = async ()=>{
|
||||
const payload={providers:{}, active_provider:activeProv};
|
||||
providers.forEach(p=>{
|
||||
const pc={enabled:!!p.enabled};
|
||||
if(p.needs_key){ pc.base_url=(p.base_url||"").trim(); pc.api_key=p.api_key||""; }
|
||||
payload.providers[p.id]=pc;
|
||||
});
|
||||
try{ await api("/api/config","PUT",payload); toast("Providers saved"); await loadProviders(); }
|
||||
catch(e){ toast("Error: "+e.message); }
|
||||
};
|
||||
|
||||
// ---- Status poll ----
|
||||
async function pollStatus(){
|
||||
try{
|
||||
|
||||
Reference in New Issue
Block a user