feat: paginate model picker dropdown in Discord (25 per page with Next/Prev)
This commit is contained in:
parent
524cbabd89
commit
1d0aa514d8
17 changed files with 1400 additions and 7 deletions
|
|
@ -4799,6 +4799,7 @@ if DISCORD_AVAILABLE:
|
|||
self.allowed_role_ids = allowed_role_ids or set()
|
||||
self.resolved = False
|
||||
self._selected_provider: str = ""
|
||||
self._model_page: int = 0
|
||||
|
||||
self._build_provider_select()
|
||||
|
||||
|
|
@ -4849,8 +4850,14 @@ if DISCORD_AVAILABLE:
|
|||
return
|
||||
|
||||
models = provider.get("models", [])
|
||||
page = self._model_page
|
||||
per_page = 25
|
||||
total = len(models)
|
||||
page_models = models[page * per_page : (page + 1) * per_page]
|
||||
max_page = max(0, (total - 1) // per_page)
|
||||
|
||||
options = []
|
||||
for model_id in models[:25]:
|
||||
for model_id in page_models:
|
||||
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
|
|
@ -4861,14 +4868,32 @@ if DISCORD_AVAILABLE:
|
|||
if not options:
|
||||
return
|
||||
|
||||
page_label = f" (page {page + 1}/{max_page + 1})" if max_page > 0 else ""
|
||||
select = discord.ui.Select(
|
||||
placeholder=f"Choose a model from {provider.get('name', provider_slug)}...",
|
||||
placeholder=f"Choose a model from {provider.get('name', provider_slug)}{page_label}...",
|
||||
options=options,
|
||||
custom_id="model_model_select",
|
||||
)
|
||||
select.callback = self._on_model_selected
|
||||
self.add_item(select)
|
||||
|
||||
# Navigation row
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
prev_btn = discord.ui.Button(
|
||||
label="◀ Prev", style=discord.ButtonStyle.grey, custom_id="model_prev_page"
|
||||
)
|
||||
prev_btn.callback = self._on_prev_page
|
||||
nav_buttons.append(prev_btn)
|
||||
if page < max_page:
|
||||
next_btn = discord.ui.Button(
|
||||
label="Next ▶", style=discord.ButtonStyle.grey, custom_id="model_next_page"
|
||||
)
|
||||
next_btn.callback = self._on_next_page
|
||||
nav_buttons.append(next_btn)
|
||||
for btn in nav_buttons:
|
||||
self.add_item(btn)
|
||||
|
||||
back_btn = discord.ui.Button(
|
||||
label="◀ Back", style=discord.ButtonStyle.grey, custom_id="model_back"
|
||||
)
|
||||
|
|
@ -4881,6 +4906,52 @@ if DISCORD_AVAILABLE:
|
|||
cancel_btn.callback = self._on_cancel
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
async def _on_prev_page(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
self._model_page = max(0, self._model_page - 1)
|
||||
self._build_model_select(self._selected_provider)
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == self._selected_provider), None
|
||||
)
|
||||
pname = provider.get("name", self._selected_provider) if provider else self._selected_provider
|
||||
total = len(provider.get("models", [])) if provider else 0
|
||||
page_info = f" (page {self._model_page + 1}/{max(1, (total - 1) // 25 + 1)})" if total > 25 else ""
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description=f"Provider: **{pname}**{page_info}\nSelect a model:",
|
||||
color=discord.Color.blue(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_next_page(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized~", ephemeral=True
|
||||
)
|
||||
return
|
||||
self._model_page += 1
|
||||
self._build_model_select(self._selected_provider)
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == self._selected_provider), None
|
||||
)
|
||||
pname = provider.get("name", self._selected_provider) if provider else self._selected_provider
|
||||
total = len(provider.get("models", [])) if provider else 0
|
||||
page_info = f" (page {self._model_page + 1}/{max(1, (total - 1) // 25 + 1)})" if total > 25 else ""
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description=f"Provider: **{pname}**{page_info}\nSelect a model:",
|
||||
color=discord.Color.blue(),
|
||||
),
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_provider_selected(self, interaction: discord.Interaction):
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
|
|
@ -4890,6 +4961,7 @@ if DISCORD_AVAILABLE:
|
|||
|
||||
provider_slug = interaction.data["values"][0]
|
||||
self._selected_provider = provider_slug
|
||||
self._model_page = 0 # reset to first page when switching providers
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == provider_slug), None
|
||||
)
|
||||
|
|
@ -4897,14 +4969,14 @@ if DISCORD_AVAILABLE:
|
|||
|
||||
self._build_model_select(provider_slug)
|
||||
|
||||
total = provider.get("total_models", 0) if provider else 0
|
||||
shown = min(len(provider.get("models", [])), 25) if provider else 0
|
||||
extra = f"\n*{total - shown} more available — type `/model <name>` directly*" if total > shown else ""
|
||||
total = len(provider.get("models", [])) if provider else 0
|
||||
page_info = f" (page 1/{max(1, (total - 1) // 25 + 1)})" if total > 25 else ""
|
||||
tip = "Type `/model <name>` for direct search." if total > 25 else ""
|
||||
|
||||
await interaction.response.edit_message(
|
||||
embed=discord.Embed(
|
||||
title="⚙ Model Configuration",
|
||||
description=f"Provider: **{pname}**\nSelect a model:{extra}",
|
||||
description=f"Provider: **{pname}**{page_info}\nSelect a model:\n{tip}",
|
||||
color=discord.Color.blue(),
|
||||
),
|
||||
view=self,
|
||||
|
|
|
|||
85
plugins/mascot/README.md
Normal file
85
plugins/mascot/README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# Mascot Plugin
|
||||
|
||||
Animated agent mascot with real-time state tracking for the Hermes dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time state updates**: WebSocket connection streams state changes instantly
|
||||
- **Automatic fallback**: Polls REST API if WebSocket fails
|
||||
- **5 animation states**: idle, thinking, working, waiting_input, error
|
||||
- **Tab + sidebar widget**: Full view and compact sidebar slot
|
||||
- **SVG fallback**: Placeholder works without sprite files
|
||||
|
||||
## Installation
|
||||
|
||||
The plugin is bundled with Hermes Agent. Enable it in your dashboard config.
|
||||
|
||||
## Usage
|
||||
|
||||
The mascot automatically displays the agent's current state:
|
||||
|
||||
- **idle**: Agent is waiting for input
|
||||
- **thinking**: Agent is processing a message
|
||||
- **working**: Agent is executing a task
|
||||
- **waiting_input**: Agent needs user confirmation
|
||||
- **error**: Agent encountered an error
|
||||
|
||||
### Manual State Control
|
||||
|
||||
Visit the Mascot tab in the dashboard to manually set states for testing.
|
||||
|
||||
## Sprite Files
|
||||
|
||||
Place PNG/GIF sprites in `plugins/mascot/static/sprites/`:
|
||||
|
||||
```
|
||||
hermes_idle.png
|
||||
hermes_thinking.png
|
||||
hermes_working.png
|
||||
hermes_waiting_input.png
|
||||
hermes_error.png
|
||||
```
|
||||
|
||||
If sprite files are missing, an SVG placeholder is displayed automatically.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/plugins/mascot/state` | GET | Current mascot state |
|
||||
| `/api/plugins/mascot/state` | POST | Update state (status, task, mood) |
|
||||
| `/api/plugins/mascot/reset` | POST | Reset to idle state |
|
||||
| `/api/plugins/mascot/events` | WS | WebSocket stream of state changes |
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
Server sends:
|
||||
```json
|
||||
{"type": "state", "state": {"status": "thinking", "task": "...", "mood": null, "last_update": 1234567890.123}}
|
||||
```
|
||||
|
||||
Client connects with session token:
|
||||
```
|
||||
ws://localhost:9119/api/plugins/mascot/events?token=<session_token>
|
||||
```
|
||||
|
||||
## Agent Loop Integration
|
||||
|
||||
To emit mascot state changes from your code:
|
||||
|
||||
```python
|
||||
from plugins.mascot.mascot_state import get_manager
|
||||
|
||||
manager = get_manager()
|
||||
|
||||
# Set status
|
||||
manager.set_state(status="thinking", task="Processing message")
|
||||
|
||||
# Working on something
|
||||
manager.set_state(status="working", task="Running tests")
|
||||
|
||||
# Done
|
||||
manager.reset() # Returns to idle
|
||||
```
|
||||
|
||||
Subscribers (like the WebSocket broadcaster) receive notifications automatically.
|
||||
1
plugins/mascot/__init__.py
Normal file
1
plugins/mascot/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
name = "mascot"
|
||||
2
plugins/mascot/dashboard/__init__.py
Normal file
2
plugins/mascot/dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Mascot dashboard plugin
|
||||
from .plugin_api import router # noqa: F401
|
||||
447
plugins/mascot/dashboard/dist/index.js
vendored
Normal file
447
plugins/mascot/dashboard/dist/index.js
vendored
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
/**
|
||||
* Hermes Mascot — Dashboard Plugin
|
||||
*
|
||||
* Animated agent mascot with real-time state tracking.
|
||||
* Connects to backend WebSocket for live state updates.
|
||||
* Falls back to polling if WebSocket fails.
|
||||
*
|
||||
* Backend API uses agent_state and task_description.
|
||||
* Frontend normalizes to status/task for internal use.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
if (!SDK || !window.__HERMES_PLUGINS__) return;
|
||||
|
||||
const { React } = SDK;
|
||||
const h = React.createElement;
|
||||
const { Card, CardContent, Badge } = SDK.components;
|
||||
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
|
||||
|
||||
// Valid state values matching backend AgentState enum
|
||||
const VALID_STATES = ["idle", "thinking", "working", "waiting_input", "error"];
|
||||
const STATE_LABELS = {
|
||||
idle: "Idle",
|
||||
thinking: "Thinking...",
|
||||
working: "Working",
|
||||
waiting_input: "Waiting for input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
const API = "/api/plugins/mascot";
|
||||
|
||||
// Convert backend response to internal state
|
||||
function normalizeState(backendState) {
|
||||
return {
|
||||
status: backendState.agent_state || "idle",
|
||||
task: backendState.task_description || null,
|
||||
mood: backendState.mood || null,
|
||||
session_id: backendState.session_id || null,
|
||||
last_update: backendState.timestamp ? new Date(backendState.timestamp).getTime() / 1000 : Date.now() / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert internal state to backend request
|
||||
function toBackendState(internalState) {
|
||||
return {
|
||||
agent_state: internalState.status,
|
||||
task_description: internalState.task,
|
||||
mood: internalState.mood,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Placeholder (development/fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MascotPlaceholder({ state, size = 96 }) {
|
||||
const colors = {
|
||||
idle: "#4ade80",
|
||||
thinking: "#60a5fa",
|
||||
working: "#fbbf24",
|
||||
waiting_input: "#a78bfa",
|
||||
error: "#f87171",
|
||||
};
|
||||
const color = colors[state] || colors.idle;
|
||||
|
||||
const animateProps = state === "thinking" || state === "working"
|
||||
? { className: "mascot-pulse" }
|
||||
: {};
|
||||
|
||||
return h("svg", {
|
||||
viewBox: "0 0 64 64",
|
||||
width: size,
|
||||
height: size,
|
||||
className: "mascot-svg",
|
||||
...animateProps,
|
||||
},
|
||||
// Background circle
|
||||
h("circle", {
|
||||
cx: 32, cy: 32, r: 28,
|
||||
fill: color,
|
||||
opacity: 0.2,
|
||||
}),
|
||||
// State indicator ring
|
||||
h("circle", {
|
||||
cx: 32, cy: 32, r: 26,
|
||||
fill: "none",
|
||||
stroke: color,
|
||||
strokeWidth: 2,
|
||||
}),
|
||||
// Face outline
|
||||
h("circle", {
|
||||
cx: 32, cy: 30, r: 14,
|
||||
fill: color,
|
||||
opacity: 0.8,
|
||||
}),
|
||||
// Eyes based on state
|
||||
state === "idle" && h(React.Fragment, null,
|
||||
h("circle", { cx: 26, cy: 28, r: 2, fill: "#fff" }),
|
||||
h("circle", { cx: 38, cy: 28, r: 2, fill: "#fff" })
|
||||
),
|
||||
state === "thinking" && h(React.Fragment, null,
|
||||
h("circle", { cx: 26, cy: 28, r: 2, fill: "#fff" }),
|
||||
h("circle", { cx: 38, cy: 28, r: 2, fill: "#fff" }),
|
||||
h("text", { x: 44, y: 30, fontSize: 12, fill: "#fff" }, "?")
|
||||
),
|
||||
state === "working" && h(React.Fragment, null,
|
||||
h("ellipse", { cx: 26, cy: 28, rx: 2, ry: 1, fill: "#fff" }),
|
||||
h("ellipse", { cx: 38, cy: 28, rx: 2, ry: 1, fill: "#fff" })
|
||||
),
|
||||
state === "waiting_input" && h(React.Fragment, null,
|
||||
h("circle", { cx: 26, cy: 28, r: 3, fill: "#fff" }),
|
||||
h("circle", { cx: 38, cy: 28, r: 3, fill: "#fff" })
|
||||
),
|
||||
state === "error" && h(React.Fragment, null,
|
||||
h("text", { x: 24, y: 32, fontSize: 12, fill: "#fff" }, "x"),
|
||||
h("text", { x: 36, y: 32, fontSize: 12, fill: "#fff" }, "x")
|
||||
),
|
||||
// Task arrow shape below face
|
||||
h("path", {
|
||||
d: state === "working"
|
||||
? "M32 46 L26 56 L32 52 L38 56 Z"
|
||||
: "M32 46 L28 54 L36 54 Z",
|
||||
fill: color,
|
||||
stroke: color,
|
||||
strokeWidth: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sprite Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MascotSprite({ state, size = 96 }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const src = `/plugins/mascot/sprites/hermes_${state}.png`;
|
||||
|
||||
useEffect(function () {
|
||||
setLoaded(false);
|
||||
setError(false);
|
||||
}, [state]);
|
||||
|
||||
if (error) {
|
||||
return h(MascotPlaceholder, { state, size });
|
||||
}
|
||||
|
||||
return h("img", {
|
||||
src: src,
|
||||
alt: `Mascot: ${STATE_LABELS[state]}`,
|
||||
width: size,
|
||||
height: size,
|
||||
className: loaded ? "mascot-sprite loaded" : "mascot-sprite loading",
|
||||
onLoad: function () { setLoaded(true); setError(false); },
|
||||
onError: function () { setError(true); },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useMascotState Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useMascotState() {
|
||||
const [state, setState] = useState({
|
||||
status: "idle",
|
||||
task: null,
|
||||
mood: null,
|
||||
session_id: null,
|
||||
last_update: Date.now() / 1000,
|
||||
});
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const wsRef = useRef(null);
|
||||
const backoffRef = useRef(1000);
|
||||
const closedRef = useRef(false);
|
||||
const pollRef = useRef(null);
|
||||
|
||||
// Fetch current state via HTTP
|
||||
const fetchState = useCallback(function () {
|
||||
return fetch(`${API}/state`)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
setState(normalizeState(data));
|
||||
setError(null);
|
||||
return data;
|
||||
})
|
||||
.catch(function (e) {
|
||||
setError(String(e.message || e));
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// WebSocket connection
|
||||
useEffect(function () {
|
||||
closedRef.current = false;
|
||||
|
||||
function openWs() {
|
||||
if (closedRef.current) return;
|
||||
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${window.location.host}${API}/events?token=${encodeURIComponent(token)}`;
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (e) {
|
||||
// WebSocket failed, fall back to polling
|
||||
setError("WebSocket unavailable, using polling");
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = function () {
|
||||
backoffRef.current = 1000;
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
// Clear polling fallback if WS connects
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = function (ev) {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
// Backend sends {"event": "state_change", "state": {...}}
|
||||
// or {"type": "state", "state": {...}} (fallback format)
|
||||
const stateData = msg.state || msg;
|
||||
if (stateData && stateData.agent_state) {
|
||||
setState(normalizeState(stateData));
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function (ev) {
|
||||
setConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
if (closedRef.current) return;
|
||||
|
||||
// Reconnect with exponential backoff
|
||||
const delay = backoffRef.current;
|
||||
backoffRef.current = Math.min(30000, backoffRef.current * 2);
|
||||
setTimeout(openWs, delay);
|
||||
|
||||
if (ev.code === 1008) {
|
||||
// Auth error - don't retry
|
||||
setError("WebSocket auth failed — reload page");
|
||||
return;
|
||||
}
|
||||
|
||||
// Start polling while WS is down
|
||||
if (!pollRef.current) {
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function () {
|
||||
setError("WebSocket error");
|
||||
};
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollRef.current) return;
|
||||
fetchState(); // Immediate fetch
|
||||
pollRef.current = setInterval(fetchState, 3000); // Poll every 3s
|
||||
}
|
||||
|
||||
// Initial connection
|
||||
openWs();
|
||||
|
||||
return function () {
|
||||
closedRef.current = true;
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [fetchState]);
|
||||
|
||||
// Manual state update (for testing/manual control)
|
||||
const updateState = useCallback(function (newState) {
|
||||
const body = new URLSearchParams();
|
||||
if (newState.status) body.set("agent_state", newState.status);
|
||||
if (newState.task !== undefined) body.set("task_description", newState.task);
|
||||
if (newState.mood !== undefined) body.set("mood", newState.mood);
|
||||
|
||||
return fetch(`${API}/state`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.agent_state) {
|
||||
return normalizeState(data);
|
||||
}
|
||||
throw new Error(data.detail || "Update failed");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: state,
|
||||
connected: connected,
|
||||
error: error,
|
||||
updateState: updateState,
|
||||
fetchState: fetchState,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MascotWidget (sidebar slot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MascotWidget() {
|
||||
const { state, connected, error } = useMascotState();
|
||||
const currentStatus = VALID_STATES.includes(state.status) ? state.status : "idle";
|
||||
|
||||
return h("div", { className: "mascot-widget" },
|
||||
h("div", { className: "mascot-avatar" },
|
||||
h(MascotSprite, { state: currentStatus, size: 64 }),
|
||||
!connected && h("div", { className: "mascot-connection-indicator disconnected" }),
|
||||
error && h("div", { className: "mascot-error-badge", title: error }, "!")
|
||||
),
|
||||
h("div", { className: "mascot-info" },
|
||||
h("div", { className: "mascot-status" },
|
||||
h(Badge, { variant: currentStatus === "error" ? "destructive" : "secondary" },
|
||||
STATE_LABELS[currentStatus]
|
||||
)
|
||||
),
|
||||
state.task && h("div", { className: "mascot-task" }, state.task.slice(0, 50))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MascotPage (full tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MascotPage() {
|
||||
const { state, connected, error, updateState } = useMascotState();
|
||||
const [selectedStatus, setSelectedStatus] = useState(state.status);
|
||||
|
||||
useEffect(function () {
|
||||
setSelectedStatus(state.status);
|
||||
}, [state.status]);
|
||||
|
||||
function handleStatusChange(status) {
|
||||
setSelectedStatus(status);
|
||||
updateState({ status: status }).catch(function () {});
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
fetch(`${API}/reset`, { method: "POST" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
const currentStatus = VALID_STATES.includes(state.status) ? state.status : "idle";
|
||||
|
||||
return h("div", { className: "mascot-page" },
|
||||
h("div", { className: "mascot-hero" },
|
||||
h(MascotSprite, { state: currentStatus, size: 192 }),
|
||||
h("div", { className: "mascot-state-info" },
|
||||
h("h2", null, STATE_LABELS[currentStatus]),
|
||||
state.task && h("p", { className: "mascot-task-display" }, state.task),
|
||||
h("p", { className: "mascot-connection-status" },
|
||||
connected
|
||||
? "Connected (WebSocket)"
|
||||
: error
|
||||
? "Disconnected (" + error + ")"
|
||||
: "Connecting...",
|
||||
h("span", {
|
||||
className: connected ? "status-dot connected" : "status-dot disconnected",
|
||||
})
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
h(Card, null,
|
||||
h(CardContent, { className: "mascot-controls" },
|
||||
h("h3", null, "State Controls"),
|
||||
h("div", { className: "mascot-status-grid" },
|
||||
VALID_STATES.map(function (s) {
|
||||
return h("button", {
|
||||
key: s,
|
||||
className: currentStatus === s ? "mascot-status-btn active" : "mascot-status-btn",
|
||||
onClick: function () { handleStatusChange(s); },
|
||||
},
|
||||
h("div", { className: "mascot-status-preview" },
|
||||
h(MascotPlaceholder, { state: s, size: 32 })
|
||||
),
|
||||
h("span", null, STATE_LABELS[s])
|
||||
);
|
||||
})
|
||||
),
|
||||
h("div", { className: "mascot-actions" },
|
||||
h("button", {
|
||||
className: "mascot-reset-btn",
|
||||
onClick: handleReset,
|
||||
}, "Reset to Idle")
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
h(Card, null,
|
||||
h(CardContent, { className: "mascot-debug" },
|
||||
h("h3", null, "Debug Info"),
|
||||
h("pre", { className: "mascot-state-json" },
|
||||
JSON.stringify(state, null, 2)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MascotTab() {
|
||||
return h(MascotPage);
|
||||
}
|
||||
|
||||
// Register tab
|
||||
window.__HERMES_PLUGINS__.register("mascot", MascotTab);
|
||||
|
||||
// Register sidebar widget
|
||||
if (window.__HERMES_PLUGINS__.registerSlot) {
|
||||
window.__HERMES_PLUGINS__.registerSlot("sidebar:bottom", MascotWidget);
|
||||
}
|
||||
|
||||
})();
|
||||
227
plugins/mascot/dashboard/dist/style.css
vendored
Normal file
227
plugins/mascot/dashboard/dist/style.css
vendored
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/* Mascot Plugin Styles */
|
||||
|
||||
/* Connection status indicator */
|
||||
.mascot-connection-indicator {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
border: 2px solid var(--muted);
|
||||
}
|
||||
|
||||
.mascot-connection-indicator.disconnected {
|
||||
background: #f87171;
|
||||
animation: pulse-error 1s infinite;
|
||||
}
|
||||
|
||||
.mascot-error-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #f87171;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Widget (sidebar) */
|
||||
.mascot-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.mascot-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mascot-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mascot-status {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mascot-task {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* SVG placeholder animation */
|
||||
.mascot-svg.mascot-pulse {
|
||||
animation: mascot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes mascot-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* Sprite loading state */
|
||||
.mascot-sprite.loading {
|
||||
opacity: 0.3;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.mascot-sprite.loaded {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
transition: opacity 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
/* Page */
|
||||
.mascot-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mascot-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
background: linear-gradient(135deg, var(--card) 0%, var(--muted) 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mascot-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mascot-state-info {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mascot-task-display {
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mascot-connection-status {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f87171;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.mascot-controls {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mascot-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.mascot-status-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.mascot-status-btn:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.mascot-status-btn.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mascot-status-preview {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mascot-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mascot-reset-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mascot-reset-btn:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Debug */
|
||||
.mascot-debug {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mascot-state-json {
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
background: var(--muted);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-error {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.mascot-status-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
15
plugins/mascot/dashboard/manifest.json
Normal file
15
plugins/mascot/dashboard/manifest.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "mascot",
|
||||
"label": "Mascot",
|
||||
"description": "Animated agent mascot with real-time state tracking",
|
||||
"icon": "Cat",
|
||||
"version": "1.0.0",
|
||||
"tab": {
|
||||
"path": "/mascot",
|
||||
"position": "after:achievements"
|
||||
},
|
||||
"slots": ["sidebar:bottom"],
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "dashboard/plugin_api.py"
|
||||
}
|
||||
200
plugins/mascot/dashboard/plugin_api.py
Normal file
200
plugins/mascot/dashboard/plugin_api.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"""
|
||||
Mascot Dashboard Plugin — Backend API routes.
|
||||
|
||||
Mounted at /api/plugins/mascot/ by the dashboard plugin system.
|
||||
|
||||
Provides:
|
||||
- GET /state — Current mascot state
|
||||
- POST /state — Update mascot state
|
||||
- POST /reset — Reset to idle
|
||||
- WebSocket /events — Live state stream
|
||||
|
||||
Security note:
|
||||
HTTP routes are unauthenticated (dashboard binds to localhost).
|
||||
WebSocket requires session token via ?token= query param.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ..mascot_state import get_manager, MascotState, VALID_STATES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _state_to_dict(state: MascotState) -> dict:
|
||||
"""Convert state to API response."""
|
||||
return {
|
||||
"status": state.status,
|
||||
"task": state.task,
|
||||
"mood": state.mood,
|
||||
"last_update": state.last_update,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/state")
|
||||
def get_state():
|
||||
"""Get current mascot state."""
|
||||
manager = get_manager()
|
||||
state = manager.get_state()
|
||||
return _state_to_dict(state)
|
||||
|
||||
|
||||
class UpdateStateBody:
|
||||
"""Request body for POST /state."""
|
||||
status: Optional[str] = None
|
||||
task: Optional[str] = None
|
||||
mood: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/state")
|
||||
def update_state(
|
||||
status: Optional[str] = None,
|
||||
task: Optional[str] = None,
|
||||
mood: Optional[str] = None,
|
||||
):
|
||||
"""Update mascot state."""
|
||||
manager = get_manager()
|
||||
|
||||
# Validate status if provided
|
||||
if status is not None and status not in VALID_STATES:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Invalid status: {status}. Must be one of {VALID_STATES}",
|
||||
}
|
||||
|
||||
new_state = manager.set_state(status=status, task=task, mood=mood)
|
||||
return {
|
||||
"success": True,
|
||||
"state": _state_to_dict(new_state),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
def reset_state():
|
||||
"""Reset mascot to idle state."""
|
||||
manager = get_manager()
|
||||
new_state = manager.reset()
|
||||
return {
|
||||
"success": True,
|
||||
"state": _state_to_dict(new_state),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _check_ws_token(provided: Optional[str]) -> bool:
|
||||
"""Validate WebSocket session token."""
|
||||
if not provided:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli import web_server as _ws
|
||||
except Exception:
|
||||
# No dashboard context (tests), accept
|
||||
return True
|
||||
expected = getattr(_ws, "_SESSION_TOKEN", None)
|
||||
if not expected:
|
||||
return True
|
||||
import hmac
|
||||
return hmac.compare_digest(str(provided), str(expected))
|
||||
|
||||
|
||||
# Track active WebSocket connections for broadcasting
|
||||
_active_ws_clients: list = []
|
||||
|
||||
|
||||
@router.websocket("/events")
|
||||
async def stream_events(ws: WebSocket):
|
||||
"""
|
||||
Stream mascot state changes over WebSocket.
|
||||
|
||||
Client sends ?token=<session> for auth.
|
||||
Server sends: {"type": "state", "state": {...}}
|
||||
Server also sends immediate current state on connect.
|
||||
|
||||
Reconnection: client should reconnect with same logic; the state
|
||||
manager will push current state immediately.
|
||||
"""
|
||||
token = ws.query_params.get("token")
|
||||
if not _check_ws_token(token):
|
||||
await ws.close(code=1008, reason="Invalid token")
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
_active_ws_clients.append(ws)
|
||||
log.debug("Mascot WS client connected (%d active)", len(_active_ws_clients))
|
||||
|
||||
# Send current state immediately
|
||||
manager = get_manager()
|
||||
current = manager.get_state()
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "state",
|
||||
"state": _state_to_dict(current),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Set up state change callback
|
||||
state_changed = asyncio.Event()
|
||||
latest_state = [current]
|
||||
|
||||
def on_state_change(new_state: MascotState):
|
||||
latest_state[0] = new_state
|
||||
# Signal from sync thread to async loop
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_soon_threadsafe(state_changed.set)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
manager.subscribe(on_state_change)
|
||||
|
||||
try:
|
||||
# Polling loop with fallback (300ms)
|
||||
# This is simpler than pushing from callback and works
|
||||
# reliably across asyncio contexts
|
||||
poll_interval = 0.3 # seconds
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Wait for state change or timeout
|
||||
try:
|
||||
await asyncio.wait_for(state_changed.wait(), timeout=poll_interval)
|
||||
state_changed.clear()
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
# Send current state
|
||||
current = latest_state[0]
|
||||
await ws.send_json({
|
||||
"type": "state",
|
||||
"state": _state_to_dict(current),
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
log.debug("Mascot WS error: %s", e)
|
||||
break
|
||||
|
||||
finally:
|
||||
manager.unsubscribe(on_state_change)
|
||||
if ws in _active_ws_clients:
|
||||
_active_ws_clients.remove(ws)
|
||||
log.debug("Mascot WS client disconnected (%d active)", len(_active_ws_clients))
|
||||
3
plugins/mascot/dashboard/static/sprites/.gitkeep
Normal file
3
plugins/mascot/dashboard/static/sprites/.gitkeep
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Mascot sprite assets directory
|
||||
# Place sprite files here: hermes_idle.png, hermes_thinking.png, etc.
|
||||
# Size: 64x64 or 128x128 recommended (CSS scales uniformly)
|
||||
BIN
plugins/mascot/dashboard/static/sprites/hermes_error.png
Normal file
BIN
plugins/mascot/dashboard/static/sprites/hermes_error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 B |
BIN
plugins/mascot/dashboard/static/sprites/hermes_idle.png
Normal file
BIN
plugins/mascot/dashboard/static/sprites/hermes_idle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 B |
BIN
plugins/mascot/dashboard/static/sprites/hermes_thinking.png
Normal file
BIN
plugins/mascot/dashboard/static/sprites/hermes_thinking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 B |
BIN
plugins/mascot/dashboard/static/sprites/hermes_waiting_input.png
Normal file
BIN
plugins/mascot/dashboard/static/sprites/hermes_waiting_input.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 B |
BIN
plugins/mascot/dashboard/static/sprites/hermes_working.png
Normal file
BIN
plugins/mascot/dashboard/static/sprites/hermes_working.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 263 B |
185
plugins/mascot/mascot_state.py
Normal file
185
plugins/mascot/mascot_state.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""
|
||||
Mascot state manager - singleton for tracking agent state.
|
||||
|
||||
State values: idle, thinking, working, waiting_input, error
|
||||
Track: status, task (description), mood (optional), last_update (timestamp)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# State values
|
||||
STATE_IDLE = "idle"
|
||||
STATE_THINKING = "thinking"
|
||||
STATE_WORKING = "working"
|
||||
STATE_WAITING_INPUT = "waiting_input"
|
||||
STATE_ERROR = "error"
|
||||
|
||||
VALID_STATES = (STATE_IDLE, STATE_THINKING, STATE_WORKING, STATE_WAITING_INPUT, STATE_ERROR)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MascotState:
|
||||
"""Current mascot state."""
|
||||
status: str = STATE_IDLE
|
||||
task: Optional[str] = None
|
||||
mood: Optional[str] = None
|
||||
last_update: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class MascotStateManager:
|
||||
"""
|
||||
Singleton state manager for mascot.
|
||||
|
||||
Thread-safe updates via set_state().
|
||||
Pub/sub for WebSocket broadcasting.
|
||||
File persistence for cross-restart state survival.
|
||||
"""
|
||||
|
||||
_instance: Optional["MascotStateManager"] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls) -> "MascotStateManager":
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self._state = MascotState()
|
||||
self._subscribers: List[Callable[[MascotState], None]] = []
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
# Persistence path
|
||||
self._state_path = Path.home() / ".hermes" / "plugins" / "mascot" / "state.json"
|
||||
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load persisted state
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Load state from disk if available."""
|
||||
try:
|
||||
if self._state_path.exists():
|
||||
data = json.loads(self._state_path.read_text())
|
||||
with self._state_lock:
|
||||
self._state = MascotState(
|
||||
status=data.get("status", STATE_IDLE),
|
||||
task=data.get("task"),
|
||||
mood=data.get("mood"),
|
||||
last_update=data.get("last_update", time.time()),
|
||||
)
|
||||
# Reset transient states that don't survive restarts
|
||||
if self._state.status == STATE_THINKING:
|
||||
self._state.status = STATE_IDLE
|
||||
log.debug("Loaded mascot state from %s", self._state_path)
|
||||
except Exception as e:
|
||||
log.warning("Failed to load mascot state: %s", e)
|
||||
|
||||
def _save_state(self) -> None:
|
||||
"""Persist state to disk."""
|
||||
try:
|
||||
with self._state_lock:
|
||||
data = self._state.to_dict()
|
||||
self._state_path.write_text(json.dumps(data, indent=2))
|
||||
log.debug("Saved mascot state to %s", self._state_path)
|
||||
except Exception as e:
|
||||
log.warning("Failed to save mascot state: %s", e)
|
||||
|
||||
def get_state(self) -> MascotState:
|
||||
"""Get current state (thread-safe copy)."""
|
||||
with self._state_lock:
|
||||
return MascotState(
|
||||
status=self._state.status,
|
||||
task=self._state.task,
|
||||
mood=self._state.mood,
|
||||
last_update=self._state.last_update,
|
||||
)
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
status: Optional[str] = None,
|
||||
task: Optional[str] = None,
|
||||
mood: Optional[str] = None,
|
||||
) -> MascotState:
|
||||
"""
|
||||
Update mascot state.
|
||||
|
||||
Thread-safe. Persists to disk. Broadcast to subscribers.
|
||||
|
||||
Args:
|
||||
status: New status (idle/thinking/working/waiting_input/error)
|
||||
task: Task description (optional)
|
||||
mood: Mood override (optional)
|
||||
|
||||
Returns:
|
||||
The new state (copy)
|
||||
"""
|
||||
if status is not None and status not in VALID_STATES:
|
||||
raise ValueError(f"Invalid status: {status}. Must be one of {VALID_STATES}")
|
||||
|
||||
with self._state_lock:
|
||||
if status is not None:
|
||||
self._state.status = status
|
||||
if task is not None:
|
||||
self._state.task = task if task else None
|
||||
if mood is not None:
|
||||
self._state.mood = mood
|
||||
self._state.last_update = time.time()
|
||||
|
||||
new_state = self.get_state()
|
||||
|
||||
# Persist
|
||||
self._save_state()
|
||||
|
||||
# Broadcast to subscribers
|
||||
for callback in self._subscribers[:]: # Copy to avoid modification during iteration
|
||||
try:
|
||||
callback(new_state)
|
||||
except Exception as e:
|
||||
log.warning("Subscriber callback failed: %s", e)
|
||||
|
||||
return new_state
|
||||
|
||||
def reset(self) -> MascotState:
|
||||
"""Reset to idle state."""
|
||||
return self.set_state(status=STATE_IDLE, task=None, mood=None)
|
||||
|
||||
def subscribe(self, callback: Callable[[MascotState], None]) -> None:
|
||||
"""Register a callback for state changes."""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[MascotState], None]) -> None:
|
||||
"""Unregister a callback."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
|
||||
# Global singleton accessor
|
||||
_manager: Optional[MascotStateManager] = None
|
||||
|
||||
|
||||
def get_manager() -> MascotStateManager:
|
||||
"""Get the global mascot state manager singleton."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = MascotStateManager()
|
||||
return _manager
|
||||
157
plugins/mascot/test_mascot_state.py
Normal file
157
plugins/mascot/test_mascot_state.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
Unit tests for mascot_state module.
|
||||
Run with: python -m pytest plugins/mascot/test_mascot_state.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
# Import the module under test
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from plugins.mascot.mascot_state import (
|
||||
MascotState,
|
||||
MascotStateManager,
|
||||
get_manager,
|
||||
STATE_IDLE,
|
||||
STATE_THINKING,
|
||||
STATE_WORKING,
|
||||
STATE_WAITING_INPUT,
|
||||
STATE_ERROR,
|
||||
VALID_STATES,
|
||||
)
|
||||
|
||||
|
||||
class TestMascotState(unittest.TestCase):
|
||||
"""Tests for MascotState dataclass."""
|
||||
|
||||
def test_default_state(self):
|
||||
"""Default state should be idle."""
|
||||
state = MascotState()
|
||||
self.assertEqual(state.status, STATE_IDLE)
|
||||
self.assertIsNone(state.task)
|
||||
self.assertIsNone(state.mood)
|
||||
self.assertGreater(state.last_update, 0)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""to_dict should return all fields."""
|
||||
state = MascotState(
|
||||
status=STATE_THINKING,
|
||||
task="Test task",
|
||||
mood="happy",
|
||||
last_update=12345.0,
|
||||
)
|
||||
d = state.to_dict()
|
||||
self.assertEqual(d["status"], STATE_THINKING)
|
||||
self.assertEqual(d["task"], "Test task")
|
||||
self.assertEqual(d["mood"], "happy")
|
||||
self.assertEqual(d["last_update"], 12345.0)
|
||||
|
||||
|
||||
class TestMascotStateManager(unittest.TestCase):
|
||||
"""Tests for MascotStateManager singleton."""
|
||||
|
||||
def setUp(self):
|
||||
"""Use temp directory for state persistence."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.state_path = Path(self.temp_dir) / "plugins" / "mascot" / "state.json"
|
||||
|
||||
# Patch the state path
|
||||
self.patcher = patch(
|
||||
"plugins.mascot.mascot_state.MascotStateManager._state_path",
|
||||
new_callable=lambda: self.state_path,
|
||||
)
|
||||
self.patcher.start()
|
||||
|
||||
# Reset singleton
|
||||
MascotStateManager._instance = None
|
||||
|
||||
def tearDown(self):
|
||||
self.patcher.stop()
|
||||
|
||||
def test_singleton(self):
|
||||
"""get_manager should return the same instance."""
|
||||
m1 = get_manager()
|
||||
m2 = get_manager()
|
||||
self.assertIs(m1, m2)
|
||||
|
||||
def test_get_state(self):
|
||||
"""get_state should return a copy."""
|
||||
manager = get_manager()
|
||||
state1 = manager.get_state()
|
||||
state2 = manager.get_state()
|
||||
self.assertIsNot(state1, state2)
|
||||
self.assertEqual(state1.status, state2.status)
|
||||
|
||||
def test_set_state(self):
|
||||
"""set_state should update and persist."""
|
||||
manager = get_manager()
|
||||
manager.reset() # Start from known state
|
||||
|
||||
new_state = manager.set_state(status=STATE_WORKING, task="Testing")
|
||||
self.assertEqual(new_state.status, STATE_WORKING)
|
||||
self.assertEqual(new_state.task, "Testing")
|
||||
|
||||
# Check persistence
|
||||
self.assertTrue(self.state_path.exists())
|
||||
data = json.loads(self.state_path.read_text())
|
||||
self.assertEqual(data["status"], STATE_WORKING)
|
||||
|
||||
def test_invalid_status(self):
|
||||
"""Invalid status should raise ValueError."""
|
||||
manager = get_manager()
|
||||
with self.assertRaises(ValueError):
|
||||
manager.set_state(status="invalid_status")
|
||||
|
||||
def test_reset(self):
|
||||
"""reset should return to idle."""
|
||||
manager = get_manager()
|
||||
manager.set_state(status=STATE_WORKING, task="Something")
|
||||
|
||||
new_state = manager.reset()
|
||||
self.assertEqual(new_state.status, STATE_IDLE)
|
||||
self.assertIsNone(new_state.task)
|
||||
self.assertIsNone(new_state.mood)
|
||||
|
||||
def test_subscribers(self):
|
||||
"""Subscribers should be called on state change."""
|
||||
manager = get_manager()
|
||||
manager.reset()
|
||||
|
||||
received = []
|
||||
def callback(state):
|
||||
received.append(state.status)
|
||||
|
||||
manager.subscribe(callback)
|
||||
manager.set_state(status=STATE_THINKING)
|
||||
manager.set_state(status=STATE_WORKING)
|
||||
manager.unsubscribe(callback)
|
||||
manager.set_state(status=STATE_IDLE)
|
||||
|
||||
self.assertEqual(received, [STATE_THINKING, STATE_WORKING])
|
||||
|
||||
def test_transient_state_reset(self):
|
||||
"""Transient states should reset on load."""
|
||||
manager = get_manager()
|
||||
manager.set_state(status=STATE_THINKING, task="Was thinking")
|
||||
|
||||
# Persist
|
||||
data = json.loads(self.state_path.read_text())
|
||||
self.assertEqual(data["status"], STATE_THINKING)
|
||||
|
||||
# Reset singleton and reload
|
||||
MascotStateManager._instance = None
|
||||
new_manager = get_manager()
|
||||
|
||||
# Thinking should be reset to idle (it's transient)
|
||||
state = new_manager.get_state()
|
||||
self.assertEqual(state.status, STATE_IDLE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 65f084ee8054a5d02aeac76e24ed60388511c82b
|
||||
Loading…
Add table
Add a link
Reference in a new issue