Feature: Digital pet

This commit is contained in:
Sami
2026-06-14 15:18:56 -04:00
parent dac4b88b94
commit d5db0d0e8e
19 changed files with 1612 additions and 968 deletions
+134
View File
@@ -0,0 +1,134 @@
(function () {
"use strict";
const PLUGIN = "digital-pet";
const apiBase = "/api/plugins/digital-pet";
function getSdk() { return window.__HERMES_PLUGIN_SDK__ || {}; }
function fetchJson(path, options) {
const sdk = getSdk();
const url = apiBase + path;
const opts = Object.assign({ headers: { "Content-Type": "application/json" } }, options || {});
const doFetch = sdk.authedFetch || fetch;
return doFetch(url, opts).then(function (res) {
if (!res.ok) throw new Error("digital pet request failed: " + res.status);
return res.json();
});
}
function DigitalPetOverlay() {
const sdk = getSdk();
const React = sdk.React || window.React;
const hooks = sdk.hooks || React;
const useEffect = hooks.useEffect;
const useRef = hooks.useRef;
const useState = hooks.useState;
const h = React.createElement;
const [state, setState] = useState(null);
const [error, setError] = useState(null);
const mounted = useRef(false);
const hoverTimer = useRef(null);
const transitionTimer = useRef(null);
function clearHoverTimer() {
if (hoverTimer.current) window.clearTimeout(hoverTimer.current);
hoverTimer.current = null;
}
function clearTransitionTimer() {
if (transitionTimer.current) window.clearTimeout(transitionTimer.current);
transitionTimer.current = null;
}
function applyRemoteState(data) {
if (!mounted.current) return null;
setError(null);
setState(data.state);
return data.state;
}
function applyRemoteError(err) {
if (!mounted.current) return undefined;
setError(err.message || String(err));
return undefined;
}
function refresh() {
return fetchJson("/state")
.then(applyRemoteState)
.catch(applyRemoteError);
}
function send(type, metadata) {
return fetchJson("/event", { method: "POST", body: JSON.stringify({ type: type, metadata: metadata || {} }) })
.then(applyRemoteState)
.catch(applyRemoteError);
}
useEffect(function () {
mounted.current = true;
return function () {
mounted.current = false;
clearHoverTimer();
clearTransitionTimer();
};
}, []);
useEffect(function () {
refresh();
const id = window.setInterval(refresh, 30000);
return function () { window.clearInterval(id); };
}, []);
useEffect(function () {
if (!state) return undefined;
clearTransitionTimer();
const completion = state.mode === "reacting" ? "reaction_complete"
: state.mode === "prompting" ? "prompt_complete"
: state.mode === "waking" ? "wake_complete"
: null;
if (!completion) return undefined;
transitionTimer.current = window.setTimeout(function () {
transitionTimer.current = null;
send(completion);
}, 2400);
return clearTransitionTimer;
}, [state && state.mode, state && state.revision]);
function onClick() {
if (!state || state.mode === "hidden") send("summon");
else if (state.mode === "sleeping") send("wake");
else send("click");
}
function startHover() {
clearHoverTimer();
if (!state || state.mode === "hidden" || state.mode === "sleeping") return;
hoverTimer.current = window.setTimeout(function () {
hoverTimer.current = null;
send("hover_hold");
}, 1000);
}
function stopHover() {
clearHoverTimer();
}
if (!state) return h("div", { className: "digital-pet-root digital-pet-loading", "aria-live": "polite" }, error || "");
if (state.mode === "hidden" || state.enabled === false) {
return h("button", { type: "button", className: "digital-pet-summon", onClick: onClick, title: "Summon station companion", "aria-label": "Summon station companion" }, "◆");
}
const mood = state.mood || "calm";
const mode = state.mode || "idle";
const face = mode === "sleeping" ? " " : mood === "pleased" ? "•‿•" : mood === "curious" ? "•?•" : mood === "tired" ? "-_-" : "•_•";
return h("div", { className: "digital-pet-root", "data-mode": mode, "data-mood": mood },
state.message ? h("div", { className: "digital-pet-bubble", role: "status", "aria-live": "polite" }, state.message) : null,
h("button", { type: "button", className: "digital-pet-avatar", onClick: onClick, onMouseEnter: startHover, onMouseLeave: stopHover, onFocus: stopHover, title: mode === "sleeping" ? "Click to wake Signal" : "Click Signal; hover to sleep", "aria-label": mode === "sleeping" ? "Wake digital pet" : "Interact with digital pet" },
h("span", { className: "digital-pet-antenna", "aria-hidden": "true" }, "⌁"),
h("span", { className: "digital-pet-face", "aria-hidden": "true" }, face),
h("span", { className: "digital-pet-name" }, state.name || "Signal")
)
);
}
function registerWhenReady() {
if (!window.__HERMES_PLUGINS__ || !window.__HERMES_PLUGIN_SDK__ || !window.__HERMES_PLUGIN_SDK__.React) {
window.setTimeout(registerWhenReady, 50);
return;
}
window.__HERMES_PLUGINS__.registerSlot(PLUGIN, "overlay", DigitalPetOverlay);
}
registerWhenReady();
})();
+49
View File
@@ -0,0 +1,49 @@
.digital-pet-root,
.digital-pet-summon {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 60;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.digital-pet-summon,
.digital-pet-avatar {
border: 1px solid rgba(135, 206, 250, 0.45);
background: radial-gradient(circle at 40% 25%, rgba(125, 249, 255, 0.24), rgba(6, 16, 28, 0.92));
color: #d8fbff;
box-shadow: 0 0 24px rgba(74, 222, 255, 0.2), inset 0 0 18px rgba(74, 222, 255, 0.08);
cursor: pointer;
}
.digital-pet-summon { width: 42px; height: 42px; border-radius: 999px; font-size: 18px; }
.digital-pet-avatar {
width: 86px; height: 86px; border-radius: 28px 28px 34px 34px;
display: grid; grid-template-rows: 16px 1fr 18px; place-items: center;
transition: transform 180ms ease, opacity 180ms ease, filter 180ms ease;
}
.digital-pet-avatar:hover,
.digital-pet-avatar:focus-visible,
.digital-pet-summon:hover,
.digital-pet-summon:focus-visible { outline: 2px solid rgba(125, 249, 255, 0.65); outline-offset: 3px; }
.digital-pet-root[data-mode="idle"] .digital-pet-avatar { animation: digital-pet-breathe 3.6s ease-in-out infinite; }
.digital-pet-root[data-mode="reacting"] .digital-pet-avatar,
.digital-pet-root[data-mode="waking"] .digital-pet-avatar { transform: translateY(-3px) scale(1.04); }
.digital-pet-root[data-mode="sleeping"] .digital-pet-avatar { opacity: 0.58; filter: saturate(0.55); animation: none; }
.digital-pet-antenna { color: #7df9ff; font-size: 18px; line-height: 1; }
.digital-pet-face { font-size: 21px; letter-spacing: 1px; }
.digital-pet-name { font-size: 10px; opacity: 0.72; }
.digital-pet-bubble {
position: absolute; right: 0; bottom: 96px; max-width: min(260px, calc(100vw - 36px));
padding: 8px 10px; border: 1px solid rgba(135, 206, 250, 0.35); border-radius: 12px;
background: rgba(3, 10, 20, 0.92); color: #e9feff; font-size: 12px; line-height: 1.35;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.32);
}
.digital-pet-loading { pointer-events: none; color: #9ee8f3; font-size: 11px; }
@keyframes digital-pet-breathe { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@media (max-width: 640px) {
.digital-pet-root, .digital-pet-summon { right: 12px; bottom: 72px; }
.digital-pet-avatar { width: 68px; height: 68px; border-radius: 22px 22px 28px 28px; }
.digital-pet-bubble { bottom: 78px; }
}
@media (prefers-reduced-motion: reduce) {
.digital-pet-avatar, .digital-pet-root[data-mode="idle"] .digital-pet-avatar { animation: none; transition: none; }
}
@@ -0,0 +1,12 @@
{
"name": "digital-pet",
"label": "Digital Pet",
"description": "Bottom-right Hermes companion overlay with summon, click, hover-sleep, wake, and hide behavior.",
"icon": "Sparkles",
"version": "0.1.0",
"tab": { "path": "/digital-pet", "hidden": true },
"slots": ["overlay"],
"entry": "dist/index.js",
"css": "dist/styles.css",
"api": "plugin_api.py"
}
@@ -0,0 +1,37 @@
"""FastAPI routes for the Hermes digital pet dashboard plugin."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter
from pydantic import BaseModel, Field
_PLUGIN_ROOT = Path(__file__).resolve().parents[1]
_STATE_PATH = _PLUGIN_ROOT / "state.py"
_SPEC = importlib.util.spec_from_file_location("hermes_digital_pet_state", _STATE_PATH)
if _SPEC is None or _SPEC.loader is None: # pragma: no cover
raise RuntimeError("Unable to load digital pet state module")
_state_mod = importlib.util.module_from_spec(_SPEC)
sys.modules.setdefault("hermes_digital_pet_state", _state_mod)
_SPEC.loader.exec_module(_state_mod)
router = APIRouter()
class PetEvent(BaseModel):
type: str = Field(..., min_length=1, max_length=64)
metadata: Dict[str, Any] = Field(default_factory=dict)
@router.get("/state")
async def get_state():
return {"ok": True, "state": _state_mod.load_state().public()}
@router.post("/event")
async def post_event(event: PetEvent):
state = _state_mod.apply_event(event.type, metadata=event.metadata, persist=True)
return {"ok": True, "state": state.public()}