From 7d6865f83dd0c06355f73c60506bbaa621438fee Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 07:41:41 +0800 Subject: [PATCH 1/5] feat: register Fitbit workbench panel --- dashboard.py | 32 +++- package.json | 4 +- plugin.py | 6 + scripts/preview_dashboard.py | 4 +- tests/test_dashboard.py | 22 +++ tests/test_dashboard_panel.mjs | 128 ------------- tests/test_web_module.mjs | 250 ++++++++++++++++++++++++++ dashboard_panel.css => web_module.css | 7 +- dashboard_panel.js => web_module.js | 164 ++++++++++++----- 9 files changed, 438 insertions(+), 179 deletions(-) delete mode 100644 tests/test_dashboard_panel.mjs create mode 100644 tests/test_web_module.mjs rename dashboard_panel.css => web_module.css (99%) rename dashboard_panel.js => web_module.js (76%) diff --git a/dashboard.py b/dashboard.py index aa85837..d3596b4 100644 --- a/dashboard.py +++ b/dashboard.py @@ -2,10 +2,10 @@ from collections.abc import Mapping, Sequence from typing import Any +from urllib.parse import urlsplit import requests from fastapi import FastAPI, HTTPException -from fastapi.responses import RedirectResponse from agent.plugin_composition import DashboardContext @@ -32,8 +32,8 @@ def refresh() -> dict[str, str]: return {"status": "refreshing"} @app.get("/api/dashboard/fitbit/auth/start") - def auth_start() -> RedirectResponse: - return RedirectResponse(f"{_MONITOR_URL}/auth/start") + def auth_start() -> dict[str, str]: + return {"url": _monitor_authorization_url()} def _monitor_json(path: str) -> Mapping[str, object]: @@ -56,6 +56,32 @@ def _monitor_payload(path: str) -> object: return payload +def _monitor_authorization_url() -> str: + """Read the monitor-generated Fitbit authorization redirect as a DTO.""" + + try: + response = requests.get( + f"{_MONITOR_URL}/auth/start", + timeout=8, + allow_redirects=False, + ) + response.raise_for_status() + except requests.RequestException as error: + raise HTTPException(status_code=502, detail="Fitbit monitor 不可用: /auth/start") from error + + authorization_url = response.headers.get("location") + parsed = urlsplit(authorization_url or "") + if ( + not response.is_redirect + or not isinstance(authorization_url, str) + or parsed.scheme != "https" + or parsed.netloc != "www.fitbit.com" + or parsed.path != "/oauth2/authorize" + ): + raise HTTPException(status_code=502, detail="Fitbit monitor 返回无效授权地址") + return authorization_url + + def _project_dashboard_snapshot(payload: Mapping[str, object]) -> dict[str, object]: """Validate and project the monitor-owned compact snapshot.""" diff --git a/package.json b/package.json index 8dffd61..5bb3045 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "test:mobile": "node --test tests/test_mobile_panel.mjs", - "test:dashboard": "node --test tests/test_dashboard_panel.mjs", - "test": "node --test tests/test_mobile_panel.mjs tests/test_dashboard_panel.mjs" + "test:dashboard": "node --test tests/test_web_module.mjs", + "test": "node --test tests/test_mobile_panel.mjs tests/test_web_module.mjs" }, "devDependencies": { "linkedom": "^0.18.13" diff --git a/plugin.py b/plugin.py index 6f37ec3..30b85e3 100644 --- a/plugin.py +++ b/plugin.py @@ -51,6 +51,12 @@ class FitbitConfig(BaseModel): UI_SLOTS, ) dashboard_module = "dashboard.py" +web_module = "web_module.js" +web_requires = ("workbench.panels.v1",) +web_provides = () +web_contract_digests = { + "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527", +} async def apply(ctx: Context, config: FitbitConfig) -> None: diff --git a/scripts/preview_dashboard.py b/scripts/preview_dashboard.py index c789921..2cc6454 100644 --- a/scripts/preview_dashboard.py +++ b/scripts/preview_dashboard.py @@ -34,8 +34,8 @@ def run_preview(agent_root: Path, plugin_root: Path, host: str, port: int) -> No plugin_root = plugin_root.resolve(strict=True) if not (agent_root / "bootstrap" / "dashboard_api.py").is_file(): raise FileNotFoundError(f"Akashic Agent Dashboard 不存在: {agent_root}") - if not (plugin_root / "dashboard_panel.js").is_file(): - raise FileNotFoundError(f"Fitbit Dashboard 面板不存在: {plugin_root}") + if not (plugin_root / "web_module.js").is_file(): + raise FileNotFoundError(f"Fitbit Workbench 面板不存在: {plugin_root}") # 2. Project the plugin into an isolated HOME and reuse the real Dashboard host. with tempfile.TemporaryDirectory(prefix="fitbit-dashboard-preview-") as temp: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index ad51565..182b97c 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -125,6 +125,28 @@ def monitor_json(path: str): assert calls == ["/api/dashboard/snapshot"] +def test_monitor_authorization_redirect_becomes_a_plugin_owned_dto( + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorization_url = "https://www.fitbit.com/oauth2/authorize?client_id=fitbit" + calls: list[tuple[str, int, bool]] = [] + + class Response: + is_redirect = True + headers = {"location": authorization_url} + + def raise_for_status(self) -> None: + return None + + def get(url: str, *, timeout: int, allow_redirects: bool) -> Response: + calls.append((url, timeout, allow_redirects)) + return Response() + + monkeypatch.setattr(dashboard.requests, "get", get) + assert dashboard._monitor_authorization_url() == authorization_url + assert calls == [("http://127.0.0.1:18765/auth/start", 8, False)] + + def test_compact_snapshot_boundary_rejects_missing_prediction_events() -> None: with pytest.raises(HTTPException, match="prediction_events 必须是数组"): dashboard._project_dashboard_snapshot( diff --git a/tests/test_dashboard_panel.mjs b/tests/test_dashboard_panel.mjs deleted file mode 100644 index a53c014..0000000 --- a/tests/test_dashboard_panel.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { parseHTML } from "linkedom"; - - -const overview = { - last_updated: "13:27:01", - stale: false, - current: { - heart_rate: 72, - spo2: 97.4, - steps: 4823, - sleep_state: "awake", - sleep_reason: "高概率清醒", - sleep_prob: 0.18, - }, - freshness: { data_lag_min: 3, spo2_lag_min: 12 }, - signals: { prob_source: "ml", hr_avg: 71.5 }, - sleep_24h: [ - { range: "23:00-07:00", state: "sleeping" }, - { range: "07:00-13:27", state: "awake" }, - ], - prediction_events: [ - { - time: "2026-08-01 13:27:00", - source: "ml", - sleep_probability: 0.18, - final_state: "awake", - reason: "Viterbi 判定清醒", - changed: false, - }, - { - time: "2026-08-01 12:27:00", - source: "ml", - sleep_probability: 0.75, - final_state: "sleeping", - reason: "Viterbi 判定睡眠", - changed: true, - }, - { - time: "2026-08-01 11:27:00", - source: "ml", - sleep_probability: 0.12, - final_state: "awake", - reason: "Viterbi 判定清醒", - changed: false, - }, - ], - heart_rate_series: [ - { time: "13:25:00", value: 69 }, - { time: "13:26:00", value: 72 }, - ], - steps_series: [], -}; - - -test("Dashboard panel renders the current monitor snapshot without a table", async () => { - const { window } = parseHTML("
"); - const registered = []; - window.api = async () => overview; - window.setInterval = setInterval; - window.clearInterval = clearInterval; - window.setTimeout = setTimeout; - window.AkashicDashboard = { registerPlugin: (plugin) => registered.push(plugin) }; - globalThis.window = window; - globalThis.document = window.document; - - await import(`../dashboard_panel.js?test=${Date.now()}`); - assert.equal(registered.length, 1); - assert.equal(registered[0].layout, "workbench"); - assert.equal(registered[0].getCount(), 1); - - const host = window.document.querySelector("#host"); - registered[0].renderMain(host, {}); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal(host.querySelector("[data-fitbit-state]").textContent, "清醒"); - assert.equal(host.querySelector("[data-fitbit-heart]").textContent, "72"); - assert.equal(host.querySelector("[data-fitbit-oxygen]").textContent, "97.4"); - assert.equal(host.querySelector("[data-fitbit-steps]").textContent, "4,823"); - assert.equal(host.querySelectorAll("table").length, 0); - assert.equal(host.querySelectorAll("[data-fitbit-timeline] > span").length, 2); - assert.equal(host.querySelectorAll("[data-fitbit-prediction-events] > li").length, 3); - assert.match(host.querySelector("[data-fitbit-prediction-events]").textContent, /ML 模型 · 睡眠概率 18%/); - assert.match(host.querySelector("[data-fitbit-prediction-events]").textContent, /最终状态清醒/); - assert.match(host.querySelector("[data-fitbit-prediction-line]").getAttribute("d"), /^M.*L.*L/); - assert.equal(host.querySelectorAll("[data-fitbit-prediction-markers] > i").length, 2); - assert.equal(host.querySelector("[data-fitbit-prediction-start]").textContent, "08-01 11:27"); - assert.equal(host.querySelector("[data-fitbit-prediction-end]").textContent, "现在 · 13:27"); - assert.equal(host.querySelector("[data-fitbit-prediction-window]").textContent, "最近 24 小时 · 3 次判断"); - assert.equal(host.querySelector(".fitbit-dashboard__eyebrow"), null); - assert.match(host.querySelector(".fitbit-dashboard__subtitle").textContent, /^展示 Fitbit 观测/); - assert.match(host.querySelector("[data-fitbit-heart-path]").getAttribute("d"), /^M/); - - host.__fitbitDashboardDispose(); -}); - - -test("Dashboard panel coalesces overlapping initial and focus loads", async () => { - const { window } = parseHTML("
"); - const registered = []; - let requests = 0; - let resolveRequest; - window.api = () => { - requests += 1; - return new Promise((resolve) => { - resolveRequest = resolve; - }); - }; - window.setInterval = setInterval; - window.clearInterval = clearInterval; - window.setTimeout = setTimeout; - window.AkashicDashboard = { registerPlugin: (plugin) => registered.push(plugin) }; - globalThis.window = window; - globalThis.document = window.document; - - await import(`../dashboard_panel.js?coalesce=${Date.now()}`); - const host = window.document.querySelector("#host"); - registered[0].renderMain(host, {}); - window.dispatchEvent(new window.Event("focus")); - window.dispatchEvent(new window.Event("focus")); - - assert.equal(requests, 1); - resolveRequest(overview); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(host.querySelector("[data-fitbit-state]").textContent, "清醒"); - host.__fitbitDashboardDispose(); -}); diff --git a/tests/test_web_module.mjs b/tests/test_web_module.mjs new file mode 100644 index 0000000..7d62a59 --- /dev/null +++ b/tests/test_web_module.mjs @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setImmediate } from "node:timers/promises"; +import { parseHTML } from "linkedom"; + +import { activate } from "../web_module.js"; + + +const overview = { + last_updated: "13:27:01", + stale: false, + current: { + heart_rate: 72, + spo2: 97.4, + steps: 4823, + sleep_state: "awake", + sleep_reason: "高概率清醒", + sleep_prob: 0.18, + }, + freshness: { data_lag_min: 3, spo2_lag_min: 12 }, + signals: { prob_source: "ml", hr_avg: 71.5 }, + sleep_24h: [ + { range: "23:00-07:00", state: "sleeping" }, + { range: "07:00-13:27", state: "awake" }, + ], + prediction_events: [ + { + time: "2026-08-01 13:27:00", + source: "ml", + sleep_probability: 0.18, + final_state: "awake", + reason: "Viterbi 判定清醒", + changed: false, + }, + { + time: "2026-08-01 12:27:00", + source: "ml", + sleep_probability: 0.75, + final_state: "sleeping", + reason: "Viterbi 判定睡眠", + changed: true, + }, + { + time: "2026-08-01 11:27:00", + source: "ml", + sleep_probability: 0.12, + final_state: "awake", + reason: "Viterbi 判定清醒", + changed: false, + }, + ], + heart_rate_series: [ + { time: "13:25:00", value: 69 }, + { time: "13:26:00", value: 72 }, + ], + steps_series: [], +}; + + +function response(payload, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => payload }; +} + + +async function settle() { + await setImmediate(); +} + +function installPanel(http) { + const { window } = parseHTML("
"); + window.setInterval = setInterval; + window.clearInterval = clearInterval; + window.setTimeout = setTimeout; + window.clearTimeout = clearTimeout; + globalThis.window = window; + globalThis.document = window.document; + let panel; + let released = false; + const release = activate({ + ui: { + inject(contract, callback) { + assert.equal(contract, "workbench.panels.v1"); + return callback({ + register(definition) { + panel = definition; + return () => { released = true; }; + }, + }); + }, + }, + http: { request: http }, + }); + return { window, host: window.document.querySelector("#host"), panel, release, wasReleased: () => released }; +} + + +test("Workbench panel renders the current monitor snapshot through ctx.http", async () => { + const fixture = installPanel(async (path) => { + assert.equal(path, "/api/dashboard/fitbit/overview"); + return response(overview); + }); + + assert.equal(fixture.panel.id, "fitbit"); + const dispose = fixture.panel.render(fixture.host); + await settle(); + + assert.equal(fixture.host.querySelector("[data-fitbit-state]").textContent, "清醒"); + assert.equal(fixture.host.querySelector("[data-fitbit-heart]").textContent, "72"); + assert.equal(fixture.host.querySelector("[data-fitbit-oxygen]").textContent, "97.4"); + assert.equal(fixture.host.querySelector("[data-fitbit-steps]").textContent, "4,823"); + assert.equal(fixture.host.querySelectorAll("table").length, 0); + assert.equal(fixture.host.querySelectorAll("[data-fitbit-timeline] > span").length, 2); + assert.equal(fixture.host.querySelectorAll("[data-fitbit-prediction-events] > li").length, 3); + assert.match(fixture.host.querySelector("[data-fitbit-prediction-line]").getAttribute("d"), /^M.*L.*L/); + assert.equal(fixture.host.querySelectorAll("[data-fitbit-prediction-markers] > i").length, 2); + + dispose(); + fixture.release(); + assert.equal(fixture.host.childNodes.length, 0); + assert.equal(fixture.wasReleased(), true); +}); + + +test("Workbench panel coalesces focus loads and disposes its request and interval", () => { + let requests = 0; + let signal; + const fixture = installPanel((_path, init) => { + requests += 1; + signal = init.signal; + return new Promise(() => {}); + }); + let cleared = null; + fixture.window.setInterval = () => 17; + fixture.window.clearInterval = (timer) => { cleared = timer; }; + + const dispose = fixture.panel.render(fixture.host); + fixture.window.dispatchEvent(new fixture.window.Event("focus")); + fixture.window.dispatchEvent(new fixture.window.Event("focus")); + + assert.equal(requests, 1); + dispose(); + fixture.window.dispatchEvent(new fixture.window.Event("focus")); + assert.equal(requests, 1); + assert.equal(cleared, 17); + assert.equal(signal.aborted, true); +}); + + +test("Workbench panel clears its delayed refresh callback on dispose", async () => { + const fixture = installPanel(async () => response(overview)); + const timeouts = []; + let cleared = null; + fixture.window.setTimeout = (callback, delay) => { + timeouts.push({ callback, delay }); + return 23; + }; + fixture.window.clearTimeout = (timer) => { cleared = timer; }; + + const dispose = fixture.panel.render(fixture.host); + await settle(); + fixture.host.querySelector("[data-fitbit-refresh]").click(); + await settle(); + + assert.equal(timeouts.length, 1); + assert.equal(timeouts[0].delay, 900); + dispose(); + assert.equal(cleared, 23); +}); + + +test("Workbench refresh starts a new overview read after a slow initial load", async () => { + const overviewRequests = []; + let refreshCalls = 0; + const fixture = installPanel((path, init) => { + if (path.endsWith("/refresh")) { + refreshCalls += 1; + return response({ status: "refreshing" }, 202); + } + return new Promise((resolve) => overviewRequests.push({ resolve, signal: init.signal })); + }); + let delayedRefresh; + fixture.window.setTimeout = (callback) => { delayedRefresh = callback; return 23; }; + + const dispose = fixture.panel.render(fixture.host); + fixture.host.querySelector("[data-fitbit-refresh]").click(); + await settle(); + delayedRefresh(); + + assert.equal(refreshCalls, 1); + assert.equal(overviewRequests.length, 2); + assert.equal(overviewRequests[0].signal.aborted, true); + overviewRequests[1].resolve(response(overview)); + await settle(); + assert.equal(fixture.host.querySelector("[data-fitbit-state]").textContent, "清醒"); + dispose(); +}); + + +test("Workbench panel requests a plugin-owned authorization URL before opening it", async () => { + const calls = []; + const fixture = installPanel(async (path) => { + calls.push(path); + return response(path.endsWith("/auth/start") + ? { url: "https://www.fitbit.com/oauth2/authorize?client_id=test" } + : overview); + }); + const opened = []; + const authorizationWindow = { + close() {}, + location: { replace(url) { opened.push(["replace", url]); } }, + }; + fixture.window.open = (...args) => { + opened.push(args); + return authorizationWindow; + }; + + const dispose = fixture.panel.render(fixture.host); + await settle(); + fixture.host.querySelector("[data-fitbit-auth]").click(); + await settle(); + + assert.deepEqual(calls, ["/api/dashboard/fitbit/overview", "/api/dashboard/fitbit/auth/start"]); + assert.equal(opened.length, 2); + assert.equal(opened[0][0], "about:blank"); + assert.equal(opened[0][1], "_blank"); + assert.equal(opened[1][0], "replace"); + assert.equal(opened[1][1], "https://www.fitbit.com/oauth2/authorize?client_id=test"); + assert.equal(authorizationWindow.opener, null); + assert.equal(fixture.host.querySelector("a[href]"), null); + dispose(); +}); + + +test("Workbench disposer closes an authorization popup before navigation commits", async () => { + const fixture = installPanel((path) => path.endsWith("/auth/start") + ? new Promise(() => {}) + : Promise.resolve(response(overview))); + let closed = false; + fixture.window.open = () => ({ + close() { closed = true; }, + location: { replace() {} }, + }); + + const dispose = fixture.panel.render(fixture.host); + await settle(); + fixture.host.querySelector("[data-fitbit-auth]").click(); + dispose(); + + assert.equal(closed, true); +}); diff --git a/dashboard_panel.css b/web_module.css similarity index 99% rename from dashboard_panel.css rename to web_module.css index 1c23145..7b0686b 100644 --- a/dashboard_panel.css +++ b/web_module.css @@ -1,3 +1,4 @@ +/* Workbench panel styles are scoped to the Fitbit root. */ .fitbit-dashboard { --fitbit-primary: oklch(0.74 0.1 270); --fitbit-on-primary: oklch(0.18 0.025 270); @@ -134,8 +135,10 @@ background: var(--fitbit-primary-container); } -.fitbit-dashboard__button:hover { - filter: brightness(1.06); +@media (hover: hover) { + .fitbit-dashboard__button:hover { + filter: brightness(1.06); + } } .fitbit-dashboard__button:active { diff --git a/dashboard_panel.js b/web_module.js similarity index 76% rename from dashboard_panel.js rename to web_module.js index 4b98dda..a37029a 100644 --- a/dashboard_panel.js +++ b/web_module.js @@ -1,4 +1,13 @@ -const api = window.api; +export function activate(ctx) { + return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({ + id: "fitbit", + label: "Fitbit 健康", + order: 40, + render(host) { + return renderFitbitDashboard(host, ctx); + }, + })); +} function number(value, digits = 0) { if (value === null || value === undefined) return "—"; @@ -214,8 +223,7 @@ function showError(root, error) { errorNode.querySelector("span").textContent = error instanceof Error ? error.message : "Fitbit 数据读取失败"; } -function renderFitbitDashboard(container) { - container.__fitbitDashboardDispose?.(); +function renderFitbitDashboard(container, ctx) { container.innerHTML = `
@@ -225,7 +233,7 @@ function renderFitbitDashboard(container) {
正在连接 - 连接 Fitbit +
@@ -318,70 +326,142 @@ function renderFitbitDashboard(container) { let disposed = false; let timer; + let refreshTimer = null; let inFlight = null; + let loadRequest = null; + let pendingAuthorizationWindow = null; let lastLoadedAt = 0; + const requests = new Set(); const refreshIntervalMs = 60_000; - const load = () => { - if (inFlight) return inFlight; - inFlight = api("/api/dashboard/fitbit/overview") + const refreshButton = container.querySelector("[data-fitbit-refresh]"); + const retryButton = container.querySelector("[data-fitbit-retry]"); + const authButton = container.querySelector("[data-fitbit-auth]"); + const request = (path, init = {}, controller = new AbortController()) => { + requests.add(controller); + return requestJson(ctx, path, init, controller) + .finally(() => requests.delete(controller)); + }; + const load = (force = false) => { + if (inFlight && !force) return inFlight; + if (force) loadRequest?.abort(); + const controller = new AbortController(); + loadRequest = controller; + const pending = request("/api/dashboard/fitbit/overview", {}, controller) .then((payload) => { - if (!disposed) { + if (!disposed && loadRequest === controller && !controller.signal.aborted) { renderOverview(container, payload); lastLoadedAt = Date.now(); } }) .catch((error) => { - if (!disposed) showError(container, error); + if (!disposed && loadRequest === controller && !controller.signal.aborted) { + showError(container, error); + } }) .finally(() => { - inFlight = null; + if (loadRequest === controller) { + loadRequest = null; + inFlight = null; + } }); - return inFlight; + inFlight = pending; + return pending; }; const refresh = async () => { - const button = container.querySelector("[data-fitbit-refresh]"); - button.disabled = true; - button.textContent = "刷新中"; + refreshButton.disabled = true; + refreshButton.textContent = "刷新中"; + try { + await request("/api/dashboard/fitbit/refresh", { method: "POST" }); + if (!disposed) { + refreshTimer = window.setTimeout(() => { + refreshTimer = null; + void load(true); + }, 900); + } + } catch (error) { + if (!disposed) showError(container, error); + } finally { + if (!disposed) { + refreshButton.disabled = false; + refreshButton.textContent = "刷新"; + } + } + }; + const startAuthorization = async () => { + if (typeof window.open !== "function") { + showError(container, new Error("此浏览器无法打开 Fitbit 授权窗口")); + return; + } + const authorizationWindow = window.open("about:blank", "_blank"); + if (authorizationWindow === null) { + showError(container, new Error("浏览器阻止了 Fitbit 授权窗口")); + return; + } + pendingAuthorizationWindow = authorizationWindow; + authorizationWindow.opener = null; + authButton.disabled = true; + authButton.textContent = "正在打开"; try { - await api("/api/dashboard/fitbit/refresh", { method: "POST" }); - window.setTimeout(() => void load(), 900); + const payload = await request("/api/dashboard/fitbit/auth/start"); + if (disposed) { + authorizationWindow.close(); + return; + } + if (!payload || typeof payload.url !== "string") { + throw new Error("Fitbit 授权地址无效"); + } + authorizationWindow.location.replace(payload.url); + // Navigation commits the popup to the user-owned OAuth flow. + if (pendingAuthorizationWindow === authorizationWindow) pendingAuthorizationWindow = null; } catch (error) { - showError(container, error); + authorizationWindow.close(); + if (pendingAuthorizationWindow === authorizationWindow) pendingAuthorizationWindow = null; + if (!disposed) showError(container, error); } finally { - button.disabled = false; - button.textContent = "刷新"; + if (!disposed) { + authButton.disabled = false; + authButton.textContent = "连接 Fitbit"; + } } }; - container.querySelector("[data-fitbit-refresh]").addEventListener("click", refresh); - container.querySelector("[data-fitbit-retry]").addEventListener("click", load); const onFocus = () => { if (Date.now() - lastLoadedAt >= refreshIntervalMs) void load(); }; + refreshButton.addEventListener("click", refresh); + retryButton.addEventListener("click", load); + authButton.addEventListener("click", startAuthorization); window.addEventListener("focus", onFocus); - timer = window.setInterval(load, refreshIntervalMs); - container.__fitbitDashboardDispose = () => { + timer = window.setInterval(() => void load(), refreshIntervalMs); + void load(); + return () => { disposed = true; window.clearInterval(timer); + if (refreshTimer !== null) window.clearTimeout(refreshTimer); window.removeEventListener("focus", onFocus); + refreshButton.removeEventListener("click", refresh); + retryButton.removeEventListener("click", load); + authButton.removeEventListener("click", startAuthorization); + for (const controller of requests) controller.abort(); + requests.clear(); + pendingAuthorizationWindow?.close(); + pendingAuthorizationWindow = null; + container.replaceChildren(); }; - void load(); } -window.AkashicDashboard.registerPlugin({ - id: "fitbit_health", - label: "Fitbit 健康", - viewLabel: "Fitbit 健康", - layout: "workbench", - pageSize: 1, - rowKey: "id", - columns: [{ key: "id", label: "Fitbit", flex: true }], - getCount() { - return 1; - }, - async fetchPage() { - return { items: [], total: 0 }; - }, - renderMain(container) { - renderFitbitDashboard(container); - }, -}); +async function requestJson(client, path, init, controller) { + const response = await client.http.request(path, { ...init, signal: controller.signal }); + let payload = null; + try { + payload = await response.json(); + } catch { + // HTTP status remains the authoritative failure when the body is unavailable. + } + if (!response.ok) { + const detail = payload && typeof payload === "object" + ? payload.detail || payload.message + : null; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return payload; +} From 9cac7d5743812d6480f0058dc4c698b1d31f3a9f Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 08:35:19 +0800 Subject: [PATCH 2/5] ci: validate Web UI composition --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 4b7e121..bc0d6a9 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 39cbdcefc155aaf6c41deafd7754a37e6126c23c + ref: 14221ff778d660ab0e9c9249a8a8ff772861dbe9 path: .akashic-core - uses: actions/setup-python@v5 with: From 5e0e2e8c4baca51fd2ad7a7cbccd5f057723e910 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 08:41:49 +0800 Subject: [PATCH 3/5] ci: follow final Core head --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index bc0d6a9..7c52de9 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 14221ff778d660ab0e9c9249a8a8ff772861dbe9 + ref: a4e87422a2448c4b148b03777da349b51c77ed16 path: .akashic-core - uses: actions/setup-python@v5 with: From 39fef79f8d49878b637fd034995ab9438421e18c Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 08:47:29 +0800 Subject: [PATCH 4/5] ci: validate final Core release --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 7c52de9..3804322 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: a4e87422a2448c4b148b03777da349b51c77ed16 + ref: f72b9e2ce2031133462e68aeaa0379fa883daeb2 path: .akashic-core - uses: actions/setup-python@v5 with: From bddd8aab49521eecd5940d8f30ddbff45b055c85 Mon Sep 17 00:00:00 2001 From: huashen <2494946808@qq.com> Date: Sun, 30 Aug 2026 09:00:07 +0800 Subject: [PATCH 5/5] ci: validate deployed Core release --- .github/workflows/plugin-api-v3.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 3804322..1659e83 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: f72b9e2ce2031133462e68aeaa0379fa883daeb2 + ref: 69a9616f6f48f19dc109a6b3eef8fc1000825829 path: .akashic-core - uses: actions/setup-python@v5 with: