feat: add card examples and speed up rtc connect
This commit is contained in:
parent
04afead5af
commit
23fd806e6d
41 changed files with 3327 additions and 3 deletions
11
examples/cards/README.md
Normal file
11
examples/cards/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Example Cards
|
||||||
|
|
||||||
|
This directory contains checked-in snapshots of the card templates and saved card instances
|
||||||
|
currently used in the local Nanobot web UI setup.
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
- `templates/`: reusable card templates (`manifest.json` + `template.html`)
|
||||||
|
- `instances/`: example saved cards (`card.json` + `state.json`)
|
||||||
|
|
||||||
|
These files are examples for reference, iteration, and testing. The running web UI loads cards
|
||||||
|
from `~/.nanobot/cards`, not from this directory.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"id": "6633c6dc-4772-4e47-a402-1e103026208a",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "3-Day Agenda",
|
||||||
|
"content": "",
|
||||||
|
"question": "",
|
||||||
|
"choices": [],
|
||||||
|
"response_value": "",
|
||||||
|
"slot": "live-calendar-3day",
|
||||||
|
"lane": "context",
|
||||||
|
"priority": 50,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "calendar-agenda-live",
|
||||||
|
"context_summary": "",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-11T18:18:25.621797+00:00",
|
||||||
|
"updated_at": "2026-03-11T18:18:25.621797+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"title": "3-Day Agenda",
|
||||||
|
"subtitle": "Family Calendar",
|
||||||
|
"tool_name": "mcp_home_assistant_calendar_get_events",
|
||||||
|
"calendar_names": [
|
||||||
|
"Family Calendar"
|
||||||
|
],
|
||||||
|
"range_days": 3,
|
||||||
|
"max_events": 8,
|
||||||
|
"refresh_ms": 900000,
|
||||||
|
"empty_text": "No events scheduled."
|
||||||
|
}
|
||||||
17
examples/cards/instances/live-bedroom-co2/card.json
Normal file
17
examples/cards/instances/live-bedroom-co2/card.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"id": "live-bedroom-co2",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "Bedroom CO2",
|
||||||
|
"question": "",
|
||||||
|
"choices": [],
|
||||||
|
"response_value": "",
|
||||||
|
"slot": "live-bedroom-co2",
|
||||||
|
"lane": "context",
|
||||||
|
"priority": 50,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "sensor-live",
|
||||||
|
"context_summary": "",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-09T22:39:28.502257+00:00",
|
||||||
|
"updated_at": "2026-03-11T04:12:48.601255+00:00"
|
||||||
|
}
|
||||||
14
examples/cards/instances/live-bedroom-co2/state.json
Normal file
14
examples/cards/instances/live-bedroom-co2/state.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"title": "Bedroom CO2",
|
||||||
|
"subtitle": "Home Assistant live context",
|
||||||
|
"tool_name": "mcp_home_assistant_GetLiveContext",
|
||||||
|
"match_name": "Bedroom-Esp-Sensor CO2",
|
||||||
|
"device_class": "carbon_dioxide",
|
||||||
|
"unit": "ppm",
|
||||||
|
"refresh_ms": 15000,
|
||||||
|
"value_decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"good_max": 900,
|
||||||
|
"elevated_max": 1200
|
||||||
|
}
|
||||||
|
}
|
||||||
17
examples/cards/instances/live-calendar-today/card.json
Normal file
17
examples/cards/instances/live-calendar-today/card.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"id": "live-calendar-today",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "Today's Agenda",
|
||||||
|
"question": "",
|
||||||
|
"choices": [],
|
||||||
|
"response_value": "",
|
||||||
|
"slot": "live-calendar-today",
|
||||||
|
"lane": "context",
|
||||||
|
"priority": 50,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "calendar-agenda-live",
|
||||||
|
"context_summary": "",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-09T23:37:10.542306+00:00",
|
||||||
|
"updated_at": "2026-03-11T04:12:48.601255+00:00"
|
||||||
|
}
|
||||||
12
examples/cards/instances/live-calendar-today/state.json
Normal file
12
examples/cards/instances/live-calendar-today/state.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"title": "Today's Agenda",
|
||||||
|
"subtitle": "Family Calendar",
|
||||||
|
"tool_name": "mcp_home_assistant_calendar_get_events",
|
||||||
|
"calendar_names": [
|
||||||
|
"Family Calendar"
|
||||||
|
],
|
||||||
|
"range_days": 1,
|
||||||
|
"max_events": 8,
|
||||||
|
"refresh_ms": 900000,
|
||||||
|
"empty_text": "No events for today."
|
||||||
|
}
|
||||||
18
examples/cards/instances/live-litellm-ups-usage/card.json
Normal file
18
examples/cards/instances/live-litellm-ups-usage/card.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"id": "live-litellm-ups-usage",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "LiteLLM + UPS Usage",
|
||||||
|
"content": "",
|
||||||
|
"question": "",
|
||||||
|
"choices": [],
|
||||||
|
"response_value": "",
|
||||||
|
"slot": "live-litellm-ups-usage",
|
||||||
|
"lane": "context",
|
||||||
|
"priority": 48,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "litellm-ups-usage-live",
|
||||||
|
"context_summary": "LiteLLM token usage and UPS energy usage for the last 24 hours and current month.",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-11T23:05:00+00:00",
|
||||||
|
"updated_at": "2026-03-11T23:05:00+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"subtitle": "GLM local usage",
|
||||||
|
"tool_name_24h": "exec",
|
||||||
|
"tool_arguments_24h": {
|
||||||
|
"command": "python3 $HOME/.nanobot/workspace/litellm_ups_price_per_token.py --24h"
|
||||||
|
},
|
||||||
|
"refresh_ms": 900000
|
||||||
|
}
|
||||||
14
examples/cards/instances/live-nanobot-git-diff/card.json
Normal file
14
examples/cards/instances/live-nanobot-git-diff/card.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"id": "live-nanobot-git-diff",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "nanobot git diff",
|
||||||
|
"slot": "git-diff:/home/kacper/nanobot",
|
||||||
|
"lane": "work",
|
||||||
|
"priority": 72,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "git-diff-live",
|
||||||
|
"context_summary": "Live git working tree summary for /home/kacper/nanobot.",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-12T14:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-12T14:00:00+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"subtitle": "nanobot",
|
||||||
|
"tool_name": "exec",
|
||||||
|
"tool_arguments": {
|
||||||
|
"command": "python3 $HOME/.nanobot/workspace/git_repo_diff_summary.py --repo /home/kacper/nanobot --max-files 8",
|
||||||
|
"max_output_chars": 200000
|
||||||
|
}
|
||||||
|
}
|
||||||
17
examples/cards/instances/live-weather-01545/card.json
Normal file
17
examples/cards/instances/live-weather-01545/card.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"id": "live-weather-01545",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "Weather 01545",
|
||||||
|
"question": "",
|
||||||
|
"choices": [],
|
||||||
|
"response_value": "",
|
||||||
|
"slot": "live-weather-01545",
|
||||||
|
"lane": "context",
|
||||||
|
"priority": 50,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "weather-live",
|
||||||
|
"context_summary": "",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-09T23:26:30.195376+00:00",
|
||||||
|
"updated_at": "2026-03-11T04:12:48.601255+00:00"
|
||||||
|
}
|
||||||
10
examples/cards/instances/live-weather-01545/state.json
Normal file
10
examples/cards/instances/live-weather-01545/state.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"title": "Weather 01545",
|
||||||
|
"subtitle": "OpenWeatherMap live context",
|
||||||
|
"tool_name": "mcp_home_assistant_GetLiveContext",
|
||||||
|
"provider_prefix": "OpenWeatherMap",
|
||||||
|
"temperature_name": "OpenWeatherMap Temperature",
|
||||||
|
"humidity_name": "OpenWeatherMap Humidity",
|
||||||
|
"condition_label": "OpenWeatherMap live context",
|
||||||
|
"refresh_ms": 86400000
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"id": "todo-todo-kacpers-to-do-031fac06-1f50-11f1-b5e6-001e06480aef",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "buy socks",
|
||||||
|
"slot": "todo:todo.kacpers_to_do:031fac06-1f50-11f1-b5e6-001e06480aef",
|
||||||
|
"lane": "attention",
|
||||||
|
"priority": 70,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "todo-item-live",
|
||||||
|
"context_summary": "Todo item from Kacper’s To-Do: buy socks",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-14T02:46:33.662524+00:00",
|
||||||
|
"updated_at": "2026-03-14T21:56:17.153704+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"source_id": "ha-todo-kacpers-to-do",
|
||||||
|
"entity_id": "todo.kacpers_to_do",
|
||||||
|
"list_name": "Kacper’s To-Do",
|
||||||
|
"uid": "031fac06-1f50-11f1-b5e6-001e06480aef",
|
||||||
|
"summary": "buy socks",
|
||||||
|
"status": "needs_action",
|
||||||
|
"completed": false,
|
||||||
|
"due": null,
|
||||||
|
"due_datetime": null,
|
||||||
|
"description": null,
|
||||||
|
"generated_at": "2026-03-14T21:56:17.117855+00:00",
|
||||||
|
"can_complete": true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"id": "todo-todo-kacpers-to-do-7994f6d2-1f9d-11f1-b5e6-001e06480aef",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "Do the laundry",
|
||||||
|
"slot": "todo:todo.kacpers_to_do:7994f6d2-1f9d-11f1-b5e6-001e06480aef",
|
||||||
|
"lane": "attention",
|
||||||
|
"priority": 70,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "todo-item-live",
|
||||||
|
"context_summary": "Todo item from Kacper’s To-Do: Do the laundry",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-14T12:01:03.651640+00:00",
|
||||||
|
"updated_at": "2026-03-14T21:56:17.153704+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"source_id": "ha-todo-kacpers-to-do",
|
||||||
|
"entity_id": "todo.kacpers_to_do",
|
||||||
|
"list_name": "Kacper’s To-Do",
|
||||||
|
"uid": "7994f6d2-1f9d-11f1-b5e6-001e06480aef",
|
||||||
|
"summary": "Do the laundry",
|
||||||
|
"status": "needs_action",
|
||||||
|
"completed": false,
|
||||||
|
"due": null,
|
||||||
|
"due_datetime": null,
|
||||||
|
"description": "Scheduled laundry reminder",
|
||||||
|
"generated_at": "2026-03-14T21:56:17.117855+00:00",
|
||||||
|
"can_complete": true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"id": "todo-todo-kacpers-to-do-b9b79f12-1ef3-11f1-b5e6-001e06480aef",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "Pack for Japan",
|
||||||
|
"slot": "todo:todo.kacpers_to_do:b9b79f12-1ef3-11f1-b5e6-001e06480aef",
|
||||||
|
"lane": "attention",
|
||||||
|
"priority": 70,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "todo-item-live",
|
||||||
|
"context_summary": "Todo item from Kacper’s To-Do: Pack for Japan",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-13T16:03:26.838387+00:00",
|
||||||
|
"updated_at": "2026-03-14T21:56:17.153704+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"source_id": "ha-todo-kacpers-to-do",
|
||||||
|
"entity_id": "todo.kacpers_to_do",
|
||||||
|
"list_name": "Kacper’s To-Do",
|
||||||
|
"uid": "b9b79f12-1ef3-11f1-b5e6-001e06480aef",
|
||||||
|
"summary": "Pack for Japan",
|
||||||
|
"status": "needs_action",
|
||||||
|
"completed": false,
|
||||||
|
"due": null,
|
||||||
|
"due_datetime": null,
|
||||||
|
"description": null,
|
||||||
|
"generated_at": "2026-03-14T21:56:17.117855+00:00",
|
||||||
|
"can_complete": true
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"id": "todo-todo-kacpers-to-do-dc554d5e-1fa6-11f1-b5e6-001e06480aef",
|
||||||
|
"kind": "text",
|
||||||
|
"title": "return the sneakers",
|
||||||
|
"slot": "todo:todo.kacpers_to_do:dc554d5e-1fa6-11f1-b5e6-001e06480aef",
|
||||||
|
"lane": "attention",
|
||||||
|
"priority": 70,
|
||||||
|
"state": "active",
|
||||||
|
"template_key": "todo-item-live",
|
||||||
|
"context_summary": "Todo item from Kacper’s To-Do: return the sneakers",
|
||||||
|
"chat_id": "web",
|
||||||
|
"created_at": "2026-03-14T13:08:14.788903+00:00",
|
||||||
|
"updated_at": "2026-03-14T21:56:17.153704+00:00"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"source_id": "ha-todo-kacpers-to-do",
|
||||||
|
"entity_id": "todo.kacpers_to_do",
|
||||||
|
"list_name": "Kacper’s To-Do",
|
||||||
|
"uid": "dc554d5e-1fa6-11f1-b5e6-001e06480aef",
|
||||||
|
"summary": "return the sneakers",
|
||||||
|
"status": "needs_action",
|
||||||
|
"completed": false,
|
||||||
|
"due": null,
|
||||||
|
"due_datetime": null,
|
||||||
|
"description": null,
|
||||||
|
"generated_at": "2026-03-14T21:56:17.117855+00:00",
|
||||||
|
"can_complete": true
|
||||||
|
}
|
||||||
18
examples/cards/templates/calendar-agenda-live/manifest.json
Normal file
18
examples/cards/templates/calendar-agenda-live/manifest.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"key": "calendar-agenda-live",
|
||||||
|
"title": "Live Calendar Agenda",
|
||||||
|
"notes": "Agenda card for one or more Home Assistant calendars. Fill template_state with subtitle, tool_name (defaults to calendar_get_events), optional calendar_names, range_days up to 7, max_events, refresh_ms, and empty_text. The card title comes from the feed header, not the template body.",
|
||||||
|
"example_state": {
|
||||||
|
"subtitle": "Family Calendar",
|
||||||
|
"tool_name": "mcp_home_assistant_calendar_get_events",
|
||||||
|
"calendar_names": [
|
||||||
|
"Family Calendar"
|
||||||
|
],
|
||||||
|
"range_days": 3,
|
||||||
|
"max_events": 8,
|
||||||
|
"refresh_ms": 900000,
|
||||||
|
"empty_text": "No events scheduled."
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-11T04:12:48.601255+00:00",
|
||||||
|
"updated_at": "2026-03-11T19:18:04.632189+00:00"
|
||||||
|
}
|
||||||
257
examples/cards/templates/calendar-agenda-live/template.html
Normal file
257
examples/cards/templates/calendar-agenda-live/template.html
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
<div data-calendar-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
|
||||||
|
<div data-calendar-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-calendar-range style="font-size:0.88rem; line-height:1.35; color:#6b7280; margin-bottom:6px;">--</div>
|
||||||
|
<div data-calendar-empty style="display:none; padding:10px 0 2px; color:#475569; font-size:0.96rem; line-height:1.4;">No events scheduled.</div>
|
||||||
|
<ul data-calendar-list style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:0;"></ul>
|
||||||
|
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">Updated <span data-calendar-updated>--</span></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitleEl = root.querySelector('[data-calendar-subtitle]');
|
||||||
|
const statusEl = root.querySelector('[data-calendar-status]');
|
||||||
|
const rangeEl = root.querySelector('[data-calendar-range]');
|
||||||
|
const emptyEl = root.querySelector('[data-calendar-empty]');
|
||||||
|
const listEl = root.querySelector('[data-calendar-list]');
|
||||||
|
const updatedEl = root.querySelector('[data-calendar-updated]');
|
||||||
|
if (!(subtitleEl instanceof HTMLElement) || !(rangeEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(listEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const calendarNames = Array.isArray(state.calendar_names)
|
||||||
|
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const rangeDaysRaw = Number(state.range_days);
|
||||||
|
const rangeDays = Number.isFinite(rangeDaysRaw) && rangeDaysRaw >= 1 ? Math.min(rangeDaysRaw, 7) : 1;
|
||||||
|
const maxEventsRaw = Number(state.max_events);
|
||||||
|
const maxEvents = Number.isFinite(maxEventsRaw) && maxEventsRaw >= 1 ? Math.min(maxEventsRaw, 30) : 8;
|
||||||
|
const refreshMsRaw = Number(state.refresh_ms);
|
||||||
|
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
|
||||||
|
const emptyText = typeof state.empty_text === 'string' && state.empty_text.trim() ? state.empty_text.trim() : 'No events scheduled.';
|
||||||
|
|
||||||
|
subtitleEl.textContent = subtitle || (calendarNames.length > 0 ? calendarNames.join(', ') : 'Loading calendars');
|
||||||
|
emptyEl.textContent = emptyText;
|
||||||
|
const updateLiveContent = (snapshot) => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
if (!(statusEl instanceof HTMLElement)) return;
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateValue = (value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
if (typeof value.dateTime === 'string') return value.dateTime;
|
||||||
|
if (typeof value.date === 'string') return value.date;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllDay = (start, end) => /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(start)) || /^\d{4}-\d{2}-\d{2}$/.test(normalizeDateValue(end));
|
||||||
|
const eventSortKey = (event) => {
|
||||||
|
const raw = normalizeDateValue(event && event.start);
|
||||||
|
const time = new Date(raw).getTime();
|
||||||
|
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
const formatTime = (value) => {
|
||||||
|
const raw = normalizeDateValue(value);
|
||||||
|
if (!raw) return '--:--';
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return 'All day';
|
||||||
|
const date = new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) return '--:--';
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
const formatDay = (value) => {
|
||||||
|
const raw = normalizeDateValue(value);
|
||||||
|
if (!raw) return '--';
|
||||||
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T00:00:00`) : new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) return '--';
|
||||||
|
return date.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
const formatRange = (start, end) => `${start.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })}`;
|
||||||
|
|
||||||
|
const extractEvents = (toolResult) => {
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||||
|
const parsed = toolResult.parsed;
|
||||||
|
if (Array.isArray(parsed.result)) return parsed.result;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventTime = (value) => {
|
||||||
|
const raw = normalizeDateValue(value);
|
||||||
|
if (!raw) return Number.MAX_SAFE_INTEGER;
|
||||||
|
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T00:00:00` : raw;
|
||||||
|
const time = new Date(normalized).getTime();
|
||||||
|
return Number.isFinite(time) ? time : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolConfig = async () => {
|
||||||
|
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
|
||||||
|
if (!window.__nanobotListTools) {
|
||||||
|
return { name: fallbackName, availableCalendars: calendarNames };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const tool = Array.isArray(tools)
|
||||||
|
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
|
||||||
|
: null;
|
||||||
|
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
|
||||||
|
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
name: tool?.name || fallbackName,
|
||||||
|
availableCalendars: enumValues,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { name: fallbackName, availableCalendars: calendarNames };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEvents = (events) => {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (!Array.isArray(events) || events.length === 0) {
|
||||||
|
emptyEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.style.display = 'none';
|
||||||
|
|
||||||
|
for (const [index, event] of events.slice(0, maxEvents).entries()) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.style.padding = index === 0 ? '10px 0 8px' : '10px 0 8px';
|
||||||
|
item.style.borderTop = index === 0 ? '1px solid #e5e7eb' : '1px solid #e5e7eb';
|
||||||
|
|
||||||
|
const summary = document.createElement('div');
|
||||||
|
summary.style.fontSize = '0.98rem';
|
||||||
|
summary.style.lineHeight = '1.3';
|
||||||
|
summary.style.fontWeight = '700';
|
||||||
|
summary.style.color = '#111827';
|
||||||
|
summary.textContent = String(event.summary || '(No title)');
|
||||||
|
item.appendChild(summary);
|
||||||
|
|
||||||
|
const timing = document.createElement('div');
|
||||||
|
timing.style.marginTop = '4px';
|
||||||
|
timing.style.fontSize = '0.9rem';
|
||||||
|
timing.style.lineHeight = '1.35';
|
||||||
|
timing.style.color = '#4b5563';
|
||||||
|
const dayLabel = formatDay(event.start);
|
||||||
|
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
|
||||||
|
timing.textContent = dayLabel === '--' ? timeLabel : `${dayLabel} · ${timeLabel}`;
|
||||||
|
item.appendChild(timing);
|
||||||
|
|
||||||
|
listEl.appendChild(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setStatus('Refreshing', '#6b7280');
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(end.getDate() + Math.max(rangeDays - 1, 0));
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
rangeEl.textContent = formatRange(start, end);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toolConfig = await resolveToolConfig();
|
||||||
|
const selectedCalendars = calendarNames.length > 0 ? calendarNames : toolConfig.availableCalendars;
|
||||||
|
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
|
||||||
|
if (!Array.isArray(selectedCalendars) || selectedCalendars.length === 0) {
|
||||||
|
throw new Error('No calendars configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSubtitle = subtitle || selectedCalendars.join(', ');
|
||||||
|
subtitleEl.textContent = resolvedSubtitle;
|
||||||
|
|
||||||
|
const allEvents = [];
|
||||||
|
const rangeMode = rangeDays > 1 ? 'week' : 'today';
|
||||||
|
const endExclusiveTime = end.getTime() + 1;
|
||||||
|
for (const calendarName of selectedCalendars) {
|
||||||
|
const toolResult = await window.__nanobotCallTool?.(toolConfig.name, {
|
||||||
|
calendar: calendarName,
|
||||||
|
range: rangeMode,
|
||||||
|
});
|
||||||
|
const events = extractEvents(toolResult);
|
||||||
|
for (const event of events) {
|
||||||
|
const startTime = eventTime(event?.start);
|
||||||
|
if (startTime < start.getTime() || startTime >= endExclusiveTime) continue;
|
||||||
|
allEvents.push({ ...event, _calendarName: calendarName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allEvents.sort((left, right) => eventSortKey(left) - eventSortKey(right));
|
||||||
|
renderEvents(allEvents);
|
||||||
|
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
updatedEl.textContent = updatedText;
|
||||||
|
const statusLabel = rangeDays > 1 ? `${rangeDays}-day` : 'Today';
|
||||||
|
setStatus(statusLabel, '#047857');
|
||||||
|
const snapshotEvents = allEvents.slice(0, maxEvents).map((event) => {
|
||||||
|
const dayLabel = formatDay(event.start);
|
||||||
|
const timeLabel = isAllDay(event.start, event.end) ? 'All day' : `${formatTime(event.start)} - ${formatTime(event.end)}`;
|
||||||
|
return {
|
||||||
|
summary: String(event.summary || '(No title)'),
|
||||||
|
start: normalizeDateValue(event.start) || null,
|
||||||
|
end: normalizeDateValue(event.end) || null,
|
||||||
|
day_label: dayLabel === '--' ? null : dayLabel,
|
||||||
|
time_label: timeLabel,
|
||||||
|
all_day: isAllDay(event.start, event.end),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'calendar_agenda',
|
||||||
|
subtitle: resolvedSubtitle || null,
|
||||||
|
tool_name: toolConfig.name,
|
||||||
|
calendar_names: selectedCalendars,
|
||||||
|
range_label: rangeEl.textContent || null,
|
||||||
|
status: statusLabel,
|
||||||
|
updated_at: updatedText,
|
||||||
|
event_count: snapshotEvents.length,
|
||||||
|
events: snapshotEvents,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorText = String(error);
|
||||||
|
renderEvents([]);
|
||||||
|
updatedEl.textContent = errorText;
|
||||||
|
setStatus('Unavailable', '#b91c1c');
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'calendar_agenda',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
|
||||||
|
calendar_names: calendarNames,
|
||||||
|
range_label: rangeEl.textContent || null,
|
||||||
|
status: 'Unavailable',
|
||||||
|
updated_at: errorText,
|
||||||
|
event_count: 0,
|
||||||
|
events: [],
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => { void refresh(); }, refreshMs);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
15
examples/cards/templates/git-diff-live/manifest.json
Normal file
15
examples/cards/templates/git-diff-live/manifest.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"key": "git-diff-live",
|
||||||
|
"title": "Git Repo Diff",
|
||||||
|
"notes": "Manual-refresh git working tree summary for a specific repo directory. Fill template_state with tool_name `exec`, tool_arguments.command running `$HOME/.nanobot/workspace/git_repo_diff_summary.py`, `tool_arguments.max_output_chars` sized for the repo diff payload, and optional subtitle. Refresh it from the card menu. The card shows branch, changed/untracked counts, insertions/deletions, and the top changed files.",
|
||||||
|
"example_state": {
|
||||||
|
"subtitle": "nanobot",
|
||||||
|
"tool_name": "exec",
|
||||||
|
"tool_arguments": {
|
||||||
|
"command": "python3 $HOME/.nanobot/workspace/git_repo_diff_summary.py --repo /home/kacper/nanobot --max-files 8",
|
||||||
|
"max_output_chars": 200000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-12T14:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-14T17:00:00-04:00"
|
||||||
|
}
|
||||||
869
examples/cards/templates/git-diff-live/template.html
Normal file
869
examples/cards/templates/git-diff-live/template.html
Normal file
|
|
@ -0,0 +1,869 @@
|
||||||
|
<style>
|
||||||
|
[data-git-diff-card] {
|
||||||
|
font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
|
||||||
|
background: #f7ecdf;
|
||||||
|
color: #65483a;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-header] {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-header-left] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-subtitle] {
|
||||||
|
font-size: 1.28rem;
|
||||||
|
line-height: 1.06;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #c9694b;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-branch] {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #9a7b68;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-status] {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-metrics] {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin: 12px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-metric] {
|
||||||
|
min-width: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.34);
|
||||||
|
border: 1px solid rgba(186, 143, 113, 0.10);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px 9px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-metric-label] {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #a48a78;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-changed],
|
||||||
|
[data-git-untracked] {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 0.92;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
color: #7d4f3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-staging],
|
||||||
|
[data-git-upstream],
|
||||||
|
[data-git-updated] {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #947662;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-diff-values] {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-plus] {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
line-height: 0.95;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #6f8f5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-minus] {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
line-height: 0.95;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #b46457;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-files] {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(177, 140, 112, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-patch-row][data-selectable='true'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-git-patch-row][data-selected='true'] {
|
||||||
|
box-shadow: inset 3px 0 0 rgba(101, 72, 58, 0.72);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div data-git-diff-card>
|
||||||
|
<div data-git-header>
|
||||||
|
<div data-git-header-left>
|
||||||
|
<div data-git-subtitle>Loading…</div>
|
||||||
|
<div data-git-branch>--</div>
|
||||||
|
</div>
|
||||||
|
<span data-git-status>Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-git-metrics>
|
||||||
|
<section data-git-metric>
|
||||||
|
<div data-git-metric-label>Changed</div>
|
||||||
|
<div data-git-changed>--</div>
|
||||||
|
<div data-git-staging>--</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-git-metric>
|
||||||
|
<div data-git-metric-label>Untracked</div>
|
||||||
|
<div data-git-untracked>--</div>
|
||||||
|
<div data-git-upstream>--</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-git-metric>
|
||||||
|
<div data-git-metric-label>Diff</div>
|
||||||
|
<div data-git-diff-values>
|
||||||
|
<span data-git-plus>+--</span>
|
||||||
|
<span data-git-minus>- --</span>
|
||||||
|
</div>
|
||||||
|
<div data-git-updated>--</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-git-files></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitleEl = root.querySelector('[data-git-subtitle]');
|
||||||
|
const branchEl = root.querySelector('[data-git-branch]');
|
||||||
|
const statusEl = root.querySelector('[data-git-status]');
|
||||||
|
const changedEl = root.querySelector('[data-git-changed]');
|
||||||
|
const stagingEl = root.querySelector('[data-git-staging]');
|
||||||
|
const untrackedEl = root.querySelector('[data-git-untracked]');
|
||||||
|
const upstreamEl = root.querySelector('[data-git-upstream]');
|
||||||
|
const plusEl = root.querySelector('[data-git-plus]');
|
||||||
|
const minusEl = root.querySelector('[data-git-minus]');
|
||||||
|
const updatedEl = root.querySelector('[data-git-updated]');
|
||||||
|
const filesEl = root.querySelector('[data-git-files]');
|
||||||
|
if (!(subtitleEl instanceof HTMLElement) || !(branchEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(changedEl instanceof HTMLElement) || !(stagingEl instanceof HTMLElement) || !(untrackedEl instanceof HTMLElement) || !(upstreamEl instanceof HTMLElement) || !(plusEl instanceof HTMLElement) || !(minusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(filesEl instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const rawToolArguments = state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
|
||||||
|
? state.tool_arguments
|
||||||
|
: {};
|
||||||
|
const subtitle = typeof state.subtitle === 'string' ? state.subtitle.trim() : '';
|
||||||
|
const numberFormatter = new Intl.NumberFormat([], { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
const setStatus = (label, fg, bg) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = fg;
|
||||||
|
statusEl.style.background = bg;
|
||||||
|
statusEl.style.padding = '3px 7px';
|
||||||
|
statusEl.style.borderRadius = '999px';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTone = (value) => {
|
||||||
|
if (value === 'Clean') return { fg: '#6c8b63', bg: '#dfe9d8' };
|
||||||
|
if (value === 'Dirty') return { fg: '#9a6a2f', bg: '#f4e2b8' };
|
||||||
|
return { fg: '#a14d43', bg: '#f3d8d2' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBranch = (payload) => {
|
||||||
|
const parts = [];
|
||||||
|
const branch = typeof payload.branch === 'string' ? payload.branch : '';
|
||||||
|
if (branch) parts.push(branch);
|
||||||
|
if (typeof payload.upstream === 'string' && payload.upstream) {
|
||||||
|
parts.push(payload.upstream);
|
||||||
|
}
|
||||||
|
const ahead = Number(payload.ahead || 0);
|
||||||
|
const behind = Number(payload.behind || 0);
|
||||||
|
if (ahead || behind) {
|
||||||
|
parts.push(`+${ahead} / -${behind}`);
|
||||||
|
}
|
||||||
|
if (!parts.length && typeof payload.head === 'string' && payload.head) {
|
||||||
|
parts.push(payload.head);
|
||||||
|
}
|
||||||
|
return parts.join(' · ') || 'No branch information';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUpdated = (raw) => {
|
||||||
|
if (typeof raw !== 'string' || !raw) return '--';
|
||||||
|
const parsed = new Date(raw);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return raw;
|
||||||
|
return parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const chipStyle = (status) => {
|
||||||
|
if (status === '??') return { fg: '#6f7582', bg: '#e8edf2' };
|
||||||
|
if (status.includes('D')) return { fg: '#a45b51', bg: '#f3d7d2' };
|
||||||
|
if (status.includes('A')) return { fg: '#6d8a5d', bg: '#dce7d6' };
|
||||||
|
return { fg: '#9a6a2f', bg: '#f3e1ba' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const asLineNumber = (value) => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRangePart = (label, start, end) => {
|
||||||
|
if (start === null || end === null) return '';
|
||||||
|
return start === end ? `${label} ${start}` : `${label} ${start}-${end}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSelectionPayload = (filePath, lines) => {
|
||||||
|
const oldNumbers = lines.map((line) => line.oldNumber).filter((value) => value !== null);
|
||||||
|
const newNumbers = lines.map((line) => line.newNumber).filter((value) => value !== null);
|
||||||
|
const oldStart = oldNumbers.length ? Math.min(...oldNumbers) : null;
|
||||||
|
const oldEnd = oldNumbers.length ? Math.max(...oldNumbers) : null;
|
||||||
|
const newStart = newNumbers.length ? Math.min(...newNumbers) : null;
|
||||||
|
const newEnd = newNumbers.length ? Math.max(...newNumbers) : null;
|
||||||
|
const rangeParts = [
|
||||||
|
formatRangePart('old', oldStart, oldEnd),
|
||||||
|
formatRangePart('new', newStart, newEnd),
|
||||||
|
].filter(Boolean);
|
||||||
|
const fileLabel = filePath || 'Selected diff';
|
||||||
|
const rangeLabel = rangeParts.join(' · ') || 'Selected diff lines';
|
||||||
|
return {
|
||||||
|
kind: 'git_diff_range',
|
||||||
|
file_path: filePath || fileLabel,
|
||||||
|
file_label: fileLabel,
|
||||||
|
range_label: rangeLabel,
|
||||||
|
label: `${fileLabel} · ${rangeLabel}`,
|
||||||
|
old_start: oldStart,
|
||||||
|
old_end: oldEnd,
|
||||||
|
new_start: newStart,
|
||||||
|
new_end: newEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let activeSelectionController = null;
|
||||||
|
|
||||||
|
const clearActiveSelection = () => {
|
||||||
|
if (activeSelectionController) {
|
||||||
|
const controller = activeSelectionController;
|
||||||
|
activeSelectionController = null;
|
||||||
|
controller.clear(false);
|
||||||
|
}
|
||||||
|
window.__nanobotSetCardSelection?.(script, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPatchBody = (target, item) => {
|
||||||
|
target.innerHTML = '';
|
||||||
|
target.dataset.noSwipe = '1';
|
||||||
|
target.style.marginTop = '10px';
|
||||||
|
target.style.marginLeft = '-12px';
|
||||||
|
target.style.marginRight = '-12px';
|
||||||
|
target.style.width = 'calc(100% + 24px)';
|
||||||
|
target.style.paddingTop = '10px';
|
||||||
|
target.style.borderTop = '1px solid rgba(177, 140, 112, 0.16)';
|
||||||
|
target.style.overflow = 'hidden';
|
||||||
|
target.style.minWidth = '0';
|
||||||
|
target.style.maxWidth = 'none';
|
||||||
|
|
||||||
|
const viewport = document.createElement('div');
|
||||||
|
viewport.dataset.noSwipe = '1';
|
||||||
|
viewport.style.width = '100%';
|
||||||
|
viewport.style.maxWidth = 'none';
|
||||||
|
viewport.style.minWidth = '0';
|
||||||
|
viewport.style.overflowX = 'auto';
|
||||||
|
viewport.style.overflowY = 'hidden';
|
||||||
|
viewport.style.touchAction = 'auto';
|
||||||
|
viewport.style.overscrollBehavior = 'contain';
|
||||||
|
viewport.style.webkitOverflowScrolling = 'touch';
|
||||||
|
viewport.style.scrollbarWidth = 'thin';
|
||||||
|
viewport.style.scrollbarColor = 'rgba(120, 94, 74, 0.28) transparent';
|
||||||
|
|
||||||
|
const diffText = typeof item?.diff === 'string' ? item.diff : '';
|
||||||
|
const diffLines = Array.isArray(item?.diff_lines) ? item.diff_lines : [];
|
||||||
|
if (!diffText && diffLines.length === 0) {
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.textContent = 'No line diff available for this path.';
|
||||||
|
message.style.fontSize = '0.76rem';
|
||||||
|
message.style.lineHeight = '1.4';
|
||||||
|
message.style.color = '#9a7b68';
|
||||||
|
message.style.fontWeight = '600';
|
||||||
|
target.appendChild(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = document.createElement('div');
|
||||||
|
block.dataset.noSwipe = '1';
|
||||||
|
block.style.display = 'grid';
|
||||||
|
block.style.gap = '0';
|
||||||
|
block.style.padding = '0';
|
||||||
|
block.style.borderRadius = '0';
|
||||||
|
block.style.background = 'rgba(255,255,255,0.58)';
|
||||||
|
block.style.border = '1px solid rgba(153, 118, 92, 0.14)';
|
||||||
|
block.style.borderLeft = '0';
|
||||||
|
block.style.borderRight = '0';
|
||||||
|
block.style.fontFamily =
|
||||||
|
"var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace)";
|
||||||
|
block.style.fontSize = '0.64rem';
|
||||||
|
block.style.lineHeight = '1.45';
|
||||||
|
block.style.color = '#5f4a3f';
|
||||||
|
block.style.width = 'max-content';
|
||||||
|
block.style.minWidth = '100%';
|
||||||
|
|
||||||
|
const selectableLines = [];
|
||||||
|
let localSelection = null;
|
||||||
|
let localAnchorIndex = null;
|
||||||
|
|
||||||
|
const setSelectedState = (entry, selected) => {
|
||||||
|
if (selected) entry.lineEl.dataset.selected = 'true';
|
||||||
|
else delete entry.lineEl.dataset.selected;
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = {
|
||||||
|
clear(publish = true) {
|
||||||
|
localSelection = null;
|
||||||
|
localAnchorIndex = null;
|
||||||
|
for (const entry of selectableLines) setSelectedState(entry, false);
|
||||||
|
if (publish) {
|
||||||
|
if (activeSelectionController === controller) activeSelectionController = null;
|
||||||
|
window.__nanobotSetCardSelection?.(script, null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySelection = (startIndex, endIndex) => {
|
||||||
|
const lower = Math.min(startIndex, endIndex);
|
||||||
|
const upper = Math.max(startIndex, endIndex);
|
||||||
|
localSelection = { startIndex: lower, endIndex: upper };
|
||||||
|
for (const [index, entry] of selectableLines.entries()) {
|
||||||
|
setSelectedState(entry, index >= lower && index <= upper);
|
||||||
|
}
|
||||||
|
if (activeSelectionController && activeSelectionController !== controller) {
|
||||||
|
activeSelectionController.clear(false);
|
||||||
|
}
|
||||||
|
activeSelectionController = controller;
|
||||||
|
window.__nanobotSetCardSelection?.(
|
||||||
|
script,
|
||||||
|
buildSelectionPayload(String(item?.path || ''), selectableLines.slice(lower, upper + 1)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectableLine = (index) => {
|
||||||
|
if (!localSelection) {
|
||||||
|
localAnchorIndex = index;
|
||||||
|
applySelection(index, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const singleLine = localSelection.startIndex === localSelection.endIndex;
|
||||||
|
if (singleLine) {
|
||||||
|
const anchorIndex = localAnchorIndex ?? localSelection.startIndex;
|
||||||
|
if (index === anchorIndex) {
|
||||||
|
controller.clear(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySelection(anchorIndex, index);
|
||||||
|
localAnchorIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localAnchorIndex = index;
|
||||||
|
applySelection(index, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerSelectableLine = (lineEl, oldNumber, newNumber) => {
|
||||||
|
const entry = {
|
||||||
|
lineEl,
|
||||||
|
oldNumber: asLineNumber(oldNumber),
|
||||||
|
newNumber: asLineNumber(newNumber),
|
||||||
|
};
|
||||||
|
const index = selectableLines.push(entry) - 1;
|
||||||
|
lineEl.dataset.selectable = 'true';
|
||||||
|
lineEl.tabIndex = 0;
|
||||||
|
lineEl.setAttribute('role', 'button');
|
||||||
|
lineEl.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleSelectableLine(index);
|
||||||
|
});
|
||||||
|
lineEl.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleSelectableLine(index);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (diffLines.length > 0) {
|
||||||
|
for (const row of diffLines) {
|
||||||
|
const lineEl = document.createElement('div');
|
||||||
|
lineEl.dataset.gitPatchRow = '1';
|
||||||
|
lineEl.style.display = 'grid';
|
||||||
|
lineEl.style.gridTemplateColumns = 'max-content max-content';
|
||||||
|
lineEl.style.columnGap = '8px';
|
||||||
|
lineEl.style.alignItems = 'start';
|
||||||
|
lineEl.style.justifyContent = 'start';
|
||||||
|
lineEl.style.padding = '0';
|
||||||
|
lineEl.style.borderRadius = '0';
|
||||||
|
lineEl.style.width = 'max-content';
|
||||||
|
lineEl.style.minWidth = '100%';
|
||||||
|
|
||||||
|
const numberEl = document.createElement('span');
|
||||||
|
const lineNumber =
|
||||||
|
typeof row?.line_number === 'number' || typeof row?.line_number === 'string'
|
||||||
|
? String(row.line_number)
|
||||||
|
: '';
|
||||||
|
numberEl.textContent = lineNumber;
|
||||||
|
numberEl.style.minWidth = '2.2em';
|
||||||
|
numberEl.style.textAlign = 'right';
|
||||||
|
numberEl.style.color = '#8c7464';
|
||||||
|
numberEl.style.opacity = '0.92';
|
||||||
|
|
||||||
|
const textEl = document.createElement('span');
|
||||||
|
textEl.dataset.gitPatchText = '1';
|
||||||
|
textEl.textContent = typeof row?.text === 'string' ? row.text : '';
|
||||||
|
textEl.style.whiteSpace = 'pre';
|
||||||
|
textEl.style.wordBreak = 'normal';
|
||||||
|
|
||||||
|
const kind = typeof row?.kind === 'string' ? row.kind : '';
|
||||||
|
if (kind === 'added') {
|
||||||
|
lineEl.style.color = '#0c3f12';
|
||||||
|
lineEl.style.background = 'rgba(158, 232, 147, 0.98)';
|
||||||
|
} else if (kind === 'removed') {
|
||||||
|
lineEl.style.color = '#6d0d08';
|
||||||
|
lineEl.style.background = 'rgba(249, 156, 145, 0.98)';
|
||||||
|
} else {
|
||||||
|
lineEl.style.color = '#5f4a3f';
|
||||||
|
}
|
||||||
|
|
||||||
|
lineEl.append(numberEl, textEl);
|
||||||
|
block.appendChild(lineEl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const makePatchLine = (line, kind, oldNumber = '', newNumber = '') => {
|
||||||
|
const lineEl = document.createElement('div');
|
||||||
|
lineEl.dataset.gitPatchRow = '1';
|
||||||
|
lineEl.style.display = 'grid';
|
||||||
|
lineEl.style.gridTemplateColumns = 'max-content max-content max-content';
|
||||||
|
lineEl.style.columnGap = '8px';
|
||||||
|
lineEl.style.alignItems = 'start';
|
||||||
|
lineEl.style.justifyContent = 'start';
|
||||||
|
lineEl.style.padding = '0';
|
||||||
|
lineEl.style.borderRadius = '0';
|
||||||
|
lineEl.style.width = 'max-content';
|
||||||
|
lineEl.style.minWidth = '100%';
|
||||||
|
|
||||||
|
const oldEl = document.createElement('span');
|
||||||
|
oldEl.textContent = oldNumber ? String(oldNumber) : '';
|
||||||
|
oldEl.style.minWidth = '2.4em';
|
||||||
|
oldEl.style.textAlign = 'right';
|
||||||
|
oldEl.style.color = '#8c7464';
|
||||||
|
oldEl.style.opacity = '0.92';
|
||||||
|
|
||||||
|
const newEl = document.createElement('span');
|
||||||
|
newEl.textContent = newNumber ? String(newNumber) : '';
|
||||||
|
newEl.style.minWidth = '2.4em';
|
||||||
|
newEl.style.textAlign = 'right';
|
||||||
|
newEl.style.color = '#8c7464';
|
||||||
|
newEl.style.opacity = '0.92';
|
||||||
|
|
||||||
|
const textEl = document.createElement('span');
|
||||||
|
textEl.dataset.gitPatchText = '1';
|
||||||
|
textEl.textContent = line || ' ';
|
||||||
|
textEl.style.whiteSpace = 'pre';
|
||||||
|
textEl.style.wordBreak = 'normal';
|
||||||
|
|
||||||
|
if (kind === 'hunk') {
|
||||||
|
lineEl.style.color = '#6c523f';
|
||||||
|
lineEl.style.background = 'rgba(224, 204, 184, 0.94)';
|
||||||
|
lineEl.style.fontWeight = '800';
|
||||||
|
} else if (kind === 'added') {
|
||||||
|
lineEl.style.color = '#0f4515';
|
||||||
|
lineEl.style.background = 'rgba(170, 232, 160, 0.98)';
|
||||||
|
} else if (kind === 'removed') {
|
||||||
|
lineEl.style.color = '#74110a';
|
||||||
|
lineEl.style.background = 'rgba(247, 170, 160, 0.98)';
|
||||||
|
} else if (kind === 'context') {
|
||||||
|
lineEl.style.color = '#6f5b4d';
|
||||||
|
lineEl.style.background = 'rgba(247, 236, 223, 0.72)';
|
||||||
|
} else if (kind === 'meta') {
|
||||||
|
lineEl.style.color = '#725c4f';
|
||||||
|
lineEl.style.background = 'rgba(255, 255, 255, 0.42)';
|
||||||
|
} else if (kind === 'note') {
|
||||||
|
lineEl.style.color = '#8a6f5c';
|
||||||
|
lineEl.style.background = 'rgba(236, 226, 216, 0.72)';
|
||||||
|
lineEl.style.fontStyle = 'italic';
|
||||||
|
}
|
||||||
|
|
||||||
|
lineEl.append(oldEl, newEl, textEl);
|
||||||
|
if (kind === 'added' || kind === 'removed' || kind === 'context') {
|
||||||
|
registerSelectableLine(lineEl, oldNumber, newNumber);
|
||||||
|
}
|
||||||
|
return lineEl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseHunkHeader = (line) => {
|
||||||
|
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
oldLine: Number(match[1] || '0'),
|
||||||
|
newLine: Number(match[3] || '0'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const prelude = [];
|
||||||
|
const hunks = [];
|
||||||
|
let currentHunk = null;
|
||||||
|
for (const line of diffText.split('\n')) {
|
||||||
|
if (line.startsWith('@@')) {
|
||||||
|
if (currentHunk) hunks.push(currentHunk);
|
||||||
|
currentHunk = { header: line, lines: [] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (currentHunk) {
|
||||||
|
currentHunk.lines.push(line);
|
||||||
|
} else {
|
||||||
|
prelude.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentHunk) hunks.push(currentHunk);
|
||||||
|
|
||||||
|
for (const line of prelude) {
|
||||||
|
let kind = 'meta';
|
||||||
|
if (line.startsWith('Binary files') || line.startsWith('\\')) kind = 'note';
|
||||||
|
else if (line.startsWith('+') && !line.startsWith('+++')) kind = 'added';
|
||||||
|
else if (line.startsWith('-') && !line.startsWith('---')) kind = 'removed';
|
||||||
|
else if (line.startsWith(' ')) kind = 'context';
|
||||||
|
block.appendChild(makePatchLine(line, kind));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hunk of hunks) {
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.style.display = 'grid';
|
||||||
|
section.style.gap = '0';
|
||||||
|
section.style.marginTop = block.childNodes.length ? '10px' : '0';
|
||||||
|
section.style.borderTop = '1px solid rgba(177, 140, 112, 0.24)';
|
||||||
|
section.style.borderBottom = '1px solid rgba(177, 140, 112, 0.24)';
|
||||||
|
|
||||||
|
section.appendChild(makePatchLine(hunk.header, 'hunk'));
|
||||||
|
const parsed = parseHunkHeader(hunk.header);
|
||||||
|
let oldLine = parsed ? parsed.oldLine : 0;
|
||||||
|
let newLine = parsed ? parsed.newLine : 0;
|
||||||
|
for (const line of hunk.lines) {
|
||||||
|
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||||
|
section.appendChild(makePatchLine(line, 'added', '', newLine));
|
||||||
|
newLine += 1;
|
||||||
|
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||||
|
section.appendChild(makePatchLine(line, 'removed', oldLine, ''));
|
||||||
|
oldLine += 1;
|
||||||
|
} else if (line.startsWith('\\')) {
|
||||||
|
section.appendChild(makePatchLine(line, 'note'));
|
||||||
|
} else if (line.startsWith('+++') || line.startsWith('---')) {
|
||||||
|
section.appendChild(makePatchLine(line, 'meta'));
|
||||||
|
} else {
|
||||||
|
const oldNumber = oldLine ? oldLine : '';
|
||||||
|
const newNumber = newLine ? newLine : '';
|
||||||
|
section.appendChild(makePatchLine(line, 'context', oldNumber, newNumber));
|
||||||
|
oldLine += 1;
|
||||||
|
newLine += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block.appendChild(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewport.appendChild(block);
|
||||||
|
target.appendChild(viewport);
|
||||||
|
|
||||||
|
if (item?.diff_truncated) {
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.textContent = 'Diff truncated for readability.';
|
||||||
|
note.style.marginTop = '8px';
|
||||||
|
note.style.fontSize = '0.72rem';
|
||||||
|
note.style.lineHeight = '1.35';
|
||||||
|
note.style.color = '#9a7b68';
|
||||||
|
note.style.fontWeight = '600';
|
||||||
|
target.appendChild(note);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFiles = (items) => {
|
||||||
|
clearActiveSelection();
|
||||||
|
filesEl.innerHTML = '';
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.textContent = 'Working tree clean.';
|
||||||
|
empty.style.fontSize = '0.92rem';
|
||||||
|
empty.style.lineHeight = '1.4';
|
||||||
|
empty.style.color = '#7d8f73';
|
||||||
|
empty.style.fontWeight = '700';
|
||||||
|
empty.style.padding = '12px';
|
||||||
|
empty.style.borderRadius = '12px';
|
||||||
|
empty.style.background = 'rgba(223, 233, 216, 0.55)';
|
||||||
|
empty.style.border = '1px solid rgba(109, 138, 93, 0.12)';
|
||||||
|
filesEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.display = 'block';
|
||||||
|
row.style.minWidth = '0';
|
||||||
|
row.style.maxWidth = '100%';
|
||||||
|
row.style.padding = '0';
|
||||||
|
row.style.borderRadius = '0';
|
||||||
|
row.style.background = 'transparent';
|
||||||
|
row.style.border = '0';
|
||||||
|
row.style.boxShadow = 'none';
|
||||||
|
|
||||||
|
const summaryButton = document.createElement('button');
|
||||||
|
summaryButton.type = 'button';
|
||||||
|
summaryButton.style.display = 'flex';
|
||||||
|
summaryButton.style.alignItems = 'flex-start';
|
||||||
|
summaryButton.style.justifyContent = 'space-between';
|
||||||
|
summaryButton.style.gap = '8px';
|
||||||
|
summaryButton.style.width = '100%';
|
||||||
|
summaryButton.style.minWidth = '0';
|
||||||
|
summaryButton.style.padding = '0';
|
||||||
|
summaryButton.style.margin = '0';
|
||||||
|
summaryButton.style.border = '0';
|
||||||
|
summaryButton.style.background = 'transparent';
|
||||||
|
summaryButton.style.textAlign = 'left';
|
||||||
|
summaryButton.style.cursor = 'pointer';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.style.display = 'flex';
|
||||||
|
left.style.alignItems = 'flex-start';
|
||||||
|
left.style.gap = '8px';
|
||||||
|
left.style.minWidth = '0';
|
||||||
|
left.style.flex = '1 1 auto';
|
||||||
|
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
const chipTone = chipStyle(String(item?.status || 'M'));
|
||||||
|
chip.textContent = String(item?.status || 'M');
|
||||||
|
chip.style.fontSize = '0.72rem';
|
||||||
|
chip.style.lineHeight = '1.1';
|
||||||
|
chip.style.fontWeight = '800';
|
||||||
|
chip.style.color = chipTone.fg;
|
||||||
|
chip.style.background = chipTone.bg;
|
||||||
|
chip.style.padding = '4px 7px';
|
||||||
|
chip.style.borderRadius = '999px';
|
||||||
|
chip.style.flex = '0 0 auto';
|
||||||
|
|
||||||
|
const pathWrap = document.createElement('div');
|
||||||
|
pathWrap.style.minWidth = '0';
|
||||||
|
|
||||||
|
const pathEl = document.createElement('div');
|
||||||
|
pathEl.textContent = String(item?.path || '--');
|
||||||
|
pathEl.style.fontSize = '0.92rem';
|
||||||
|
pathEl.style.lineHeight = '1.3';
|
||||||
|
pathEl.style.fontWeight = '700';
|
||||||
|
pathEl.style.color = '#65483a';
|
||||||
|
pathEl.style.wordBreak = 'break-word';
|
||||||
|
|
||||||
|
const detailEl = document.createElement('div');
|
||||||
|
detailEl.style.marginTop = '3px';
|
||||||
|
detailEl.style.fontSize = '0.77rem';
|
||||||
|
detailEl.style.lineHeight = '1.35';
|
||||||
|
detailEl.style.color = '#9a7b68';
|
||||||
|
const insertions = Number(item?.insertions || 0);
|
||||||
|
const deletions = Number(item?.deletions || 0);
|
||||||
|
detailEl.textContent = insertions || deletions
|
||||||
|
? `+${numberFormatter.format(insertions)} / -${numberFormatter.format(deletions)}`
|
||||||
|
: 'No line diff';
|
||||||
|
|
||||||
|
pathWrap.append(pathEl, detailEl);
|
||||||
|
left.append(chip, pathWrap);
|
||||||
|
const toggle = document.createElement('span');
|
||||||
|
toggle.setAttribute('aria-hidden', 'true');
|
||||||
|
toggle.style.fontSize = '0.95rem';
|
||||||
|
toggle.style.lineHeight = '1';
|
||||||
|
toggle.style.fontWeight = '800';
|
||||||
|
toggle.style.color = '#9a7b68';
|
||||||
|
toggle.style.whiteSpace = 'nowrap';
|
||||||
|
toggle.style.flex = '0 0 auto';
|
||||||
|
toggle.style.paddingTop = '1px';
|
||||||
|
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.hidden = true;
|
||||||
|
body.style.width = '100%';
|
||||||
|
body.style.maxWidth = '100%';
|
||||||
|
body.style.minWidth = '0';
|
||||||
|
renderPatchBody(body, item);
|
||||||
|
|
||||||
|
const hasDiff = Boolean(item?.diff_available) || Boolean(item?.diff);
|
||||||
|
const setExpanded = (expanded) => {
|
||||||
|
body.hidden = !expanded;
|
||||||
|
summaryButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
toggle.textContent = expanded ? '▴' : '▾';
|
||||||
|
};
|
||||||
|
setExpanded(false);
|
||||||
|
|
||||||
|
summaryButton.addEventListener('click', () => {
|
||||||
|
setExpanded(body.hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
summaryButton.append(left, toggle);
|
||||||
|
row.append(summaryButton, body);
|
||||||
|
filesEl.appendChild(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLiveContent = (snapshot) => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (payload) => {
|
||||||
|
subtitleEl.textContent = subtitle || payload.repo_name || payload.repo_path || 'Git repo';
|
||||||
|
branchEl.textContent = formatBranch(payload);
|
||||||
|
changedEl.textContent = numberFormatter.format(Number(payload.changed_files || 0));
|
||||||
|
stagingEl.textContent = `${numberFormatter.format(Number(payload.staged_files || 0))} staged · ${numberFormatter.format(Number(payload.unstaged_files || 0))} unstaged`;
|
||||||
|
untrackedEl.textContent = numberFormatter.format(Number(payload.untracked_files || 0));
|
||||||
|
upstreamEl.textContent = typeof payload.repo_path === 'string' ? payload.repo_path : '--';
|
||||||
|
plusEl.textContent = `+${numberFormatter.format(Number(payload.insertions || 0))}`;
|
||||||
|
minusEl.textContent = `-${numberFormatter.format(Number(payload.deletions || 0))}`;
|
||||||
|
updatedEl.textContent = `Updated ${formatUpdated(payload.generated_at)}`;
|
||||||
|
const label = payload.dirty ? 'Dirty' : 'Clean';
|
||||||
|
const tone = statusTone(label);
|
||||||
|
setStatus(label, tone.fg, tone.bg);
|
||||||
|
renderFiles(payload.files);
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'git_repo_diff',
|
||||||
|
repo_name: payload.repo_name || null,
|
||||||
|
repo_path: payload.repo_path || null,
|
||||||
|
branch: payload.branch || null,
|
||||||
|
upstream: payload.upstream || null,
|
||||||
|
ahead: Number(payload.ahead || 0),
|
||||||
|
behind: Number(payload.behind || 0),
|
||||||
|
dirty: Boolean(payload.dirty),
|
||||||
|
changed_files: Number(payload.changed_files || 0),
|
||||||
|
staged_files: Number(payload.staged_files || 0),
|
||||||
|
unstaged_files: Number(payload.unstaged_files || 0),
|
||||||
|
untracked_files: Number(payload.untracked_files || 0),
|
||||||
|
insertions: Number(payload.insertions || 0),
|
||||||
|
deletions: Number(payload.deletions || 0),
|
||||||
|
files: Array.isArray(payload.files)
|
||||||
|
? payload.files.map((item) => ({
|
||||||
|
path: item?.path || null,
|
||||||
|
status: item?.status || null,
|
||||||
|
insertions: Number(item?.insertions || 0),
|
||||||
|
deletions: Number(item?.deletions || 0),
|
||||||
|
diff_available: Boolean(item?.diff_available),
|
||||||
|
diff_truncated: Boolean(item?.diff_truncated),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
generated_at: payload.generated_at || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderError = (message) => {
|
||||||
|
clearActiveSelection();
|
||||||
|
subtitleEl.textContent = subtitle || 'Git repo';
|
||||||
|
branchEl.textContent = 'Unable to load repo diff';
|
||||||
|
changedEl.textContent = '--';
|
||||||
|
stagingEl.textContent = '--';
|
||||||
|
untrackedEl.textContent = '--';
|
||||||
|
upstreamEl.textContent = '--';
|
||||||
|
plusEl.textContent = '+--';
|
||||||
|
minusEl.textContent = '- --';
|
||||||
|
updatedEl.textContent = message;
|
||||||
|
const tone = statusTone('Unavailable');
|
||||||
|
setStatus('Unavailable', tone.fg, tone.bg);
|
||||||
|
filesEl.innerHTML = '';
|
||||||
|
const error = document.createElement('div');
|
||||||
|
error.textContent = message;
|
||||||
|
error.style.fontSize = '0.88rem';
|
||||||
|
error.style.lineHeight = '1.4';
|
||||||
|
error.style.color = '#a45b51';
|
||||||
|
error.style.fontWeight = '700';
|
||||||
|
error.style.padding = '12px';
|
||||||
|
error.style.borderRadius = '12px';
|
||||||
|
error.style.background = 'rgba(243, 216, 210, 0.55)';
|
||||||
|
error.style.border = '1px solid rgba(164, 91, 81, 0.14)';
|
||||||
|
filesEl.appendChild(error);
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'git_repo_diff',
|
||||||
|
repo_name: null,
|
||||||
|
repo_path: null,
|
||||||
|
dirty: null,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPayload = async () => {
|
||||||
|
if (!configuredToolName) throw new Error('Missing template_state.tool_name');
|
||||||
|
if (!window.__nanobotCallToolAsync) throw new Error('Async tool helper unavailable');
|
||||||
|
const toolResult = await window.__nanobotCallToolAsync(
|
||||||
|
configuredToolName,
|
||||||
|
rawToolArguments,
|
||||||
|
{ timeoutMs: 180000 },
|
||||||
|
);
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
|
||||||
|
return toolResult.parsed;
|
||||||
|
}
|
||||||
|
const rawContent = typeof toolResult?.content === 'string' ? toolResult.content.trim() : '';
|
||||||
|
if (rawContent) {
|
||||||
|
if (rawContent.includes('(truncated,')) {
|
||||||
|
throw new Error('Tool output was truncated. Increase exec max_output_chars for this card.');
|
||||||
|
}
|
||||||
|
const normalizedContent = rawContent.replace(/\n+Exit code:\s*-?\d+\s*$/i, '').trim();
|
||||||
|
if (!normalizedContent.startsWith('{')) {
|
||||||
|
throw new Error(rawContent);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(normalizedContent);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Tool returned invalid JSON: ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Tool returned invalid JSON');
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const loadingTone = { fg: '#9a7b68', bg: '#efe3d6' };
|
||||||
|
setStatus('Refreshing', loadingTone.fg, loadingTone.bg);
|
||||||
|
try {
|
||||||
|
const payload = await loadPayload();
|
||||||
|
render(payload);
|
||||||
|
} catch (error) {
|
||||||
|
renderError(String(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"key": "litellm-ups-usage-live",
|
||||||
|
"title": "LiteLLM + UPS Usage",
|
||||||
|
"notes": "Exec-backed live usage card for LiteLLM token activity and UPS energy usage. Fill template_state with subtitle, tool_name_24h/tool_arguments_24h, and optional tool_name_month/tool_arguments_month plus refresh_ms. If the month tool call is omitted, the card renders only the 24-hour section. The card title comes from the feed header, not the template body.",
|
||||||
|
"example_state": {
|
||||||
|
"subtitle": "GLM local usage",
|
||||||
|
"tool_name_24h": "exec",
|
||||||
|
"tool_arguments_24h": {
|
||||||
|
"command": "python3 $HOME/.nanobot/workspace/litellm_ups_price_per_token.py --24h"
|
||||||
|
},
|
||||||
|
"tool_name_month": "exec",
|
||||||
|
"tool_arguments_month": {
|
||||||
|
"command": "python3 $HOME/.nanobot/workspace/litellm_ups_price_per_token.py --month"
|
||||||
|
},
|
||||||
|
"refresh_ms": 900000
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-11T23:05:00+00:00",
|
||||||
|
"updated_at": "2026-03-11T23:19:00+00:00"
|
||||||
|
}
|
||||||
282
examples/cards/templates/litellm-ups-usage-live/template.html
Normal file
282
examples/cards/templates/litellm-ups-usage-live/template.html
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<div data-litellm-usage-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:10px;">
|
||||||
|
<div data-usage-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
|
||||||
|
<span data-usage-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-usage-grid style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:12px;">
|
||||||
|
<section style="min-width:0;">
|
||||||
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Last 24h</div>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:6px; margin-top:4px;">
|
||||||
|
<span data-usage-tokens-24h style="font-size:2rem; line-height:0.95; font-weight:800; letter-spacing:-0.04em;">--</span>
|
||||||
|
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:#4b5563; padding-bottom:0.22rem;">tokens</span>
|
||||||
|
</div>
|
||||||
|
<div data-usage-power-24h style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:#1f2937; font-weight:700;">--</div>
|
||||||
|
<div data-usage-window-24h style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:#6b7280;">--</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section data-usage-month-section style="min-width:0;">
|
||||||
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">This Month</div>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:6px; margin-top:4px;">
|
||||||
|
<span data-usage-tokens-month style="font-size:2rem; line-height:0.95; font-weight:800; letter-spacing:-0.04em;">--</span>
|
||||||
|
<span style="font-size:0.9rem; line-height:1.2; font-weight:700; color:#4b5563; padding-bottom:0.22rem;">tokens</span>
|
||||||
|
</div>
|
||||||
|
<div data-usage-power-month style="margin-top:4px; font-size:0.9rem; line-height:1.3; color:#1f2937; font-weight:700;">--</div>
|
||||||
|
<div data-usage-window-month style="margin-top:2px; font-size:0.76rem; line-height:1.3; color:#6b7280;">--</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:10px; font-size:0.82rem; line-height:1.35; color:#6b7280;">Updated <span data-usage-updated>--</span></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitleEl = root.querySelector('[data-usage-subtitle]');
|
||||||
|
const statusEl = root.querySelector('[data-usage-status]');
|
||||||
|
const updatedEl = root.querySelector('[data-usage-updated]');
|
||||||
|
const gridEl = root.querySelector('[data-usage-grid]');
|
||||||
|
const monthSectionEl = root.querySelector('[data-usage-month-section]');
|
||||||
|
const tokens24hEl = root.querySelector('[data-usage-tokens-24h]');
|
||||||
|
const power24hEl = root.querySelector('[data-usage-power-24h]');
|
||||||
|
const window24hEl = root.querySelector('[data-usage-window-24h]');
|
||||||
|
const tokensMonthEl = root.querySelector('[data-usage-tokens-month]');
|
||||||
|
const powerMonthEl = root.querySelector('[data-usage-power-month]');
|
||||||
|
const windowMonthEl = root.querySelector('[data-usage-window-month]');
|
||||||
|
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(gridEl instanceof HTMLElement) || !(monthSectionEl instanceof HTMLElement) || !(tokens24hEl instanceof HTMLElement) || !(power24hEl instanceof HTMLElement) || !(window24hEl instanceof HTMLElement) || !(tokensMonthEl instanceof HTMLElement) || !(powerMonthEl instanceof HTMLElement) || !(windowMonthEl instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
|
||||||
|
const configuredToolName24h = typeof state.tool_name_24h === 'string'
|
||||||
|
? state.tool_name_24h.trim()
|
||||||
|
: typeof state.tool_name === 'string'
|
||||||
|
? state.tool_name.trim()
|
||||||
|
: '';
|
||||||
|
const configuredToolNameMonth = typeof state.tool_name_month === 'string'
|
||||||
|
? state.tool_name_month.trim()
|
||||||
|
: '';
|
||||||
|
const rawToolArguments24h = state && typeof state.tool_arguments_24h === 'object' && state.tool_arguments_24h && !Array.isArray(state.tool_arguments_24h)
|
||||||
|
? state.tool_arguments_24h
|
||||||
|
: state && typeof state.tool_arguments === 'object' && state.tool_arguments && !Array.isArray(state.tool_arguments)
|
||||||
|
? state.tool_arguments
|
||||||
|
: {};
|
||||||
|
const rawToolArgumentsMonth = state && typeof state.tool_arguments_month === 'object' && state.tool_arguments_month && !Array.isArray(state.tool_arguments_month)
|
||||||
|
? state.tool_arguments_month
|
||||||
|
: {};
|
||||||
|
const source24h = typeof state.source_url_24h === 'string' ? state.source_url_24h.trim() : '';
|
||||||
|
const sourceMonth = typeof state.source_url_month === 'string' ? state.source_url_month.trim() : '';
|
||||||
|
const refreshMsRaw = Number(state.refresh_ms);
|
||||||
|
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const tokenFormatter = new Intl.NumberFormat([], { notation: 'compact', maximumFractionDigits: 1 });
|
||||||
|
const kwhFormatter = new Intl.NumberFormat([], { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||||
|
const moneyFormatter = new Intl.NumberFormat([], { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
subtitleEl.textContent = subtitle || 'LiteLLM activity vs local UPS energy';
|
||||||
|
const hasMonthSource = Boolean(
|
||||||
|
sourceMonth ||
|
||||||
|
configuredToolNameMonth ||
|
||||||
|
Object.keys(rawToolArgumentsMonth).length,
|
||||||
|
);
|
||||||
|
if (!hasMonthSource) {
|
||||||
|
monthSectionEl.style.display = 'none';
|
||||||
|
gridEl.style.gridTemplateColumns = 'minmax(0, 1fr)';
|
||||||
|
}
|
||||||
|
const updateLiveContent = (snapshot) => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseLocalTimestamp = (raw) => {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
|
const match = raw.trim().match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = `${match[1]}T${match[2]}${match[3]}:${match[4]}`;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRangeLabel = (payload, fallbackLabel) => {
|
||||||
|
const startRaw = typeof payload?.range_start_local === 'string' ? payload.range_start_local : '';
|
||||||
|
const endRaw = typeof payload?.range_end_local === 'string' ? payload.range_end_local : '';
|
||||||
|
const start = parseLocalTimestamp(startRaw);
|
||||||
|
const end = parseLocalTimestamp(endRaw);
|
||||||
|
if (!(start instanceof Date) || Number.isNaN(start.getTime()) || !(end instanceof Date) || Number.isNaN(end.getTime())) {
|
||||||
|
return fallbackLabel;
|
||||||
|
}
|
||||||
|
return `${start.toLocaleDateString([], { month: 'short', day: 'numeric' })} to ${end.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = (elements, payload, fallbackLabel) => {
|
||||||
|
const tokens = Number(payload?.total_tokens_processed);
|
||||||
|
const kwh = Number(payload?.total_ups_kwh_in_range);
|
||||||
|
const localCost = Number(payload?.local_cost_usd_in_range);
|
||||||
|
|
||||||
|
elements.tokens.textContent = Number.isFinite(tokens) ? tokenFormatter.format(tokens) : '--';
|
||||||
|
elements.power.textContent =
|
||||||
|
Number.isFinite(kwh) && Number.isFinite(localCost)
|
||||||
|
? `${kwhFormatter.format(kwh)} kWh · ${moneyFormatter.format(localCost)}`
|
||||||
|
: Number.isFinite(kwh)
|
||||||
|
? `${kwhFormatter.format(kwh)} kWh`
|
||||||
|
: '--';
|
||||||
|
elements.window.textContent = formatRangeLabel(payload, fallbackLabel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const blankSection = (elements, fallbackLabel) => {
|
||||||
|
elements.tokens.textContent = '--';
|
||||||
|
elements.power.textContent = '--';
|
||||||
|
elements.window.textContent = fallbackLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shellEscape = (value) => `'${String(value ?? '').replace(/'/g, `'\"'\"'`)}'`;
|
||||||
|
|
||||||
|
const buildLegacyExecCommand = (rawUrl) => {
|
||||||
|
if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/script/proxy/')) return '';
|
||||||
|
const [pathPart, queryPart = ''] = rawUrl.split('?', 2);
|
||||||
|
const relativeScript = pathPart.slice('/script/proxy/'.length).replace(/^\/+/, '');
|
||||||
|
if (!relativeScript) return '';
|
||||||
|
const params = new URLSearchParams(queryPart);
|
||||||
|
const args = params.getAll('arg').map((value) => value.trim()).filter(Boolean);
|
||||||
|
const scriptPath = `$HOME/.nanobot/workspace/${relativeScript}`;
|
||||||
|
return `python3 ${scriptPath}${args.length ? ` ${args.map(shellEscape).join(' ')}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolCall = (toolName, toolArguments, legacySourceUrl) => {
|
||||||
|
if (toolName) {
|
||||||
|
return {
|
||||||
|
toolName,
|
||||||
|
toolArguments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const legacyCommand = buildLegacyExecCommand(legacySourceUrl);
|
||||||
|
if (!legacyCommand) return null;
|
||||||
|
return {
|
||||||
|
toolName: 'exec',
|
||||||
|
toolArguments: { command: legacyCommand },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPayload = async (toolCall) => {
|
||||||
|
if (!toolCall) throw new Error('Missing tool_name/tool_arguments');
|
||||||
|
if (!window.__nanobotCallToolAsync) throw new Error('Async tool helper unavailable');
|
||||||
|
const toolResult = await window.__nanobotCallToolAsync(
|
||||||
|
toolCall.toolName,
|
||||||
|
toolCall.toolArguments,
|
||||||
|
{ timeoutMs: 180000 },
|
||||||
|
);
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object' && !Array.isArray(toolResult.parsed)) {
|
||||||
|
return toolResult.parsed;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string' && toolResult.content.trim()) {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Tool returned invalid JSON');
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setStatus('Refreshing', '#6b7280');
|
||||||
|
try {
|
||||||
|
const toolCall24h = resolveToolCall(configuredToolName24h, rawToolArguments24h, source24h);
|
||||||
|
const toolCallMonth = hasMonthSource
|
||||||
|
? resolveToolCall(configuredToolNameMonth, rawToolArgumentsMonth, sourceMonth)
|
||||||
|
: null;
|
||||||
|
const jobs = [loadPayload(toolCall24h), hasMonthSource ? loadPayload(toolCallMonth) : Promise.resolve(null)];
|
||||||
|
const results = await Promise.allSettled(jobs);
|
||||||
|
const twentyFourHour = results[0].status === 'fulfilled' ? results[0].value : null;
|
||||||
|
const month = hasMonthSource && results[1].status === 'fulfilled' ? results[1].value : null;
|
||||||
|
|
||||||
|
if (twentyFourHour) {
|
||||||
|
renderSection(
|
||||||
|
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
|
||||||
|
twentyFourHour,
|
||||||
|
'Last 24 hours',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
blankSection(
|
||||||
|
{ tokens: tokens24hEl, power: power24hEl, window: window24hEl },
|
||||||
|
'Last 24 hours',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMonthSource && month) {
|
||||||
|
renderSection(
|
||||||
|
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
|
||||||
|
month,
|
||||||
|
'This month',
|
||||||
|
);
|
||||||
|
} else if (hasMonthSource) {
|
||||||
|
blankSection(
|
||||||
|
{ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl },
|
||||||
|
'This month',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = [twentyFourHour, month].filter(Boolean).length;
|
||||||
|
const expectedCount = hasMonthSource ? 2 : 1;
|
||||||
|
if (successCount === expectedCount) {
|
||||||
|
setStatus('Live', '#047857');
|
||||||
|
} else if (successCount === 1) {
|
||||||
|
setStatus('Partial', '#b45309');
|
||||||
|
} else {
|
||||||
|
setStatus('Unavailable', '#b91c1c');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
updatedEl.textContent = updatedText;
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'litellm_ups_usage',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
status: statusEl.textContent || null,
|
||||||
|
updated_at: updatedText,
|
||||||
|
last_24h: twentyFourHour
|
||||||
|
? {
|
||||||
|
total_tokens_processed: Number(twentyFourHour.total_tokens_processed) || 0,
|
||||||
|
total_ups_kwh_in_range: Number(twentyFourHour.total_ups_kwh_in_range) || 0,
|
||||||
|
local_cost_usd_in_range: Number(twentyFourHour.local_cost_usd_in_range) || 0,
|
||||||
|
range_start_local: twentyFourHour.range_start_local || null,
|
||||||
|
range_end_local: twentyFourHour.range_end_local || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
this_month: month
|
||||||
|
? {
|
||||||
|
total_tokens_processed: Number(month.total_tokens_processed) || 0,
|
||||||
|
total_ups_kwh_in_range: Number(month.total_ups_kwh_in_range) || 0,
|
||||||
|
local_cost_usd_in_range: Number(month.local_cost_usd_in_range) || 0,
|
||||||
|
range_start_local: month.range_start_local || null,
|
||||||
|
range_end_local: month.range_end_local || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorText = String(error);
|
||||||
|
blankSection({ tokens: tokens24hEl, power: power24hEl, window: window24hEl }, 'Last 24 hours');
|
||||||
|
if (hasMonthSource) {
|
||||||
|
blankSection({ tokens: tokensMonthEl, power: powerMonthEl, window: windowMonthEl }, 'This month');
|
||||||
|
}
|
||||||
|
updatedEl.textContent = errorText;
|
||||||
|
setStatus('Unavailable', '#b91c1c');
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'litellm_ups_usage',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
status: 'Unavailable',
|
||||||
|
updated_at: errorText,
|
||||||
|
last_24h: null,
|
||||||
|
this_month: null,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => { void refresh(); }, refreshMs);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
8
examples/cards/templates/live-bedroom-co2/manifest.json
Normal file
8
examples/cards/templates/live-bedroom-co2/manifest.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"key": "live-bedroom-co2",
|
||||||
|
"title": "Live Bedroom CO2",
|
||||||
|
"notes": "Use for live CO2 telemetry card. Pull data through the Home Assistant GetLiveContext MCP tool and match the Bedroom-Esp-Sensor CO2 entry.",
|
||||||
|
"created_at": "2026-03-09T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-11T04:12:48.601255+00:00",
|
||||||
|
"deprecated": true
|
||||||
|
}
|
||||||
204
examples/cards/templates/live-bedroom-co2/template.html
Normal file
204
examples/cards/templates/live-bedroom-co2/template.html
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
<div data-co2-card="bedroom" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 520px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom: 12px;">
|
||||||
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Bedroom CO2</h2>
|
||||||
|
<span data-co2-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:baseline; gap:8px;">
|
||||||
|
<span data-co2-value style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
|
||||||
|
<span style="font-size:1rem; font-weight:600; color:#4b5563;">ppm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
|
||||||
|
Updated: <span data-co2-updated>--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const valueEl = root.querySelector("[data-co2-value]");
|
||||||
|
const statusEl = root.querySelector("[data-co2-status]");
|
||||||
|
const updatedEl = root.querySelector("[data-co2-updated]");
|
||||||
|
if (!(valueEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : 'Bedroom-Esp-Sensor CO2';
|
||||||
|
const INTERVAL_RAW = Number(state.refresh_ms);
|
||||||
|
const INTERVAL_MS = Number.isFinite(INTERVAL_RAW) && INTERVAL_RAW >= 1000 ? INTERVAL_RAW : 15000;
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setUpdatedNow = () => {
|
||||||
|
updatedEl.textContent = new Date().toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseValue = (raw) => {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? Math.round(n) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripQuotes = (value) => {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => String(value || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const parseLiveContextEntries = (payloadText) => {
|
||||||
|
const text = String(payloadText || '').replace(/\r/g, '');
|
||||||
|
const startIndex = text.indexOf('- names: ');
|
||||||
|
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
|
||||||
|
const entries = [];
|
||||||
|
let current = null;
|
||||||
|
let inAttributes = false;
|
||||||
|
|
||||||
|
const pushCurrent = () => {
|
||||||
|
if (current) entries.push(current);
|
||||||
|
current = null;
|
||||||
|
inAttributes = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of relevant.split('\n')) {
|
||||||
|
if (rawLine.startsWith('- names: ')) {
|
||||||
|
pushCurrent();
|
||||||
|
current = {
|
||||||
|
name: stripQuotes(rawLine.slice(9)),
|
||||||
|
domain: '',
|
||||||
|
state: '',
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) continue;
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (trimmed === 'attributes:') {
|
||||||
|
inAttributes = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' domain:')) {
|
||||||
|
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' state:')) {
|
||||||
|
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inAttributes && rawLine.startsWith(' ')) {
|
||||||
|
const separatorIndex = rawLine.indexOf(':');
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const key = rawLine.slice(4, separatorIndex).trim();
|
||||||
|
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
|
||||||
|
current.attributes[key] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inAttributes = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCurrent();
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLiveContextText = (toolResult) => {
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||||
|
const parsed = toolResult.parsed;
|
||||||
|
if (typeof parsed.result === 'string') return parsed.result;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolName = async () => {
|
||||||
|
if (configuredToolName) return configuredToolName;
|
||||||
|
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const liveContextTool = Array.isArray(tools)
|
||||||
|
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
|
||||||
|
: null;
|
||||||
|
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
|
||||||
|
} catch {
|
||||||
|
return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setStatus("Refreshing", "#6b7280");
|
||||||
|
try {
|
||||||
|
const toolName = await resolveToolName();
|
||||||
|
const toolResult = await window.__nanobotCallTool?.(toolName, {});
|
||||||
|
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
|
||||||
|
const entry = entries.find((item) => normalizeText(item.name) === normalizeText(matchName));
|
||||||
|
if (!entry) throw new Error(`Missing sensor ${matchName}`);
|
||||||
|
const value = parseValue(entry.state);
|
||||||
|
if (value === null) throw new Error("Invalid sensor payload");
|
||||||
|
|
||||||
|
valueEl.textContent = String(value);
|
||||||
|
if (value >= 1200) setStatus("High", "#b91c1c");
|
||||||
|
else if (value >= 900) setStatus("Elevated", "#b45309");
|
||||||
|
else setStatus("Good", "#047857");
|
||||||
|
setUpdatedNow();
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, {
|
||||||
|
kind: 'sensor',
|
||||||
|
tool_name: toolName,
|
||||||
|
match_name: entry.name,
|
||||||
|
value,
|
||||||
|
display_value: String(value),
|
||||||
|
unit: entry.attributes?.unit_of_measurement || 'ppm',
|
||||||
|
status: statusEl.textContent || null,
|
||||||
|
updated_at: updatedEl.textContent || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
valueEl.textContent = "--";
|
||||||
|
setStatus("Unavailable", "#b91c1c");
|
||||||
|
updatedEl.textContent = String(err);
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, {
|
||||||
|
kind: 'sensor',
|
||||||
|
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
|
||||||
|
match_name: matchName,
|
||||||
|
value: null,
|
||||||
|
display_value: '--',
|
||||||
|
unit: 'ppm',
|
||||||
|
status: 'Unavailable',
|
||||||
|
updated_at: String(err),
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, INTERVAL_MS);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"key": "live-calendar-today",
|
||||||
|
"title": "Live Calendar Today",
|
||||||
|
"notes": "Use for today's calendar summary. Pull events through the Home Assistant calendar_get_events MCP tool, optionally pinning calendar_names in template_state.",
|
||||||
|
"created_at": "2026-03-09T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-11T04:12:48.601255+00:00",
|
||||||
|
"deprecated": true
|
||||||
|
}
|
||||||
273
examples/cards/templates/live-calendar-today/template.html
Normal file
273
examples/cards/templates/live-calendar-today/template.html
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
<div data-calendar-card="today" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 640px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom: 12px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Today's Calendar</h2>
|
||||||
|
<div data-cal-subtitle style="margin-top:4px; font-size:0.84rem; color:#6b7280;">Loading calendars...</div>
|
||||||
|
</div>
|
||||||
|
<span data-cal-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:0.88rem; color:#6b7280; margin-bottom:10px;">
|
||||||
|
Date: <strong data-cal-date style="color:#374151;">--</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-cal-empty style="display:none; padding:12px; border-radius:10px; background:#f8fafc; color:#475569; font-size:0.94rem;">
|
||||||
|
No events for today.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul data-cal-list style="list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:10px;"></ul>
|
||||||
|
|
||||||
|
<div style="margin-top:12px; font-size:0.84rem; color:#6b7280;">
|
||||||
|
Updated: <span data-cal-updated>--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const statusEl = root.querySelector("[data-cal-status]");
|
||||||
|
const subtitleEl = root.querySelector("[data-cal-subtitle]");
|
||||||
|
const dateEl = root.querySelector("[data-cal-date]");
|
||||||
|
const listEl = root.querySelector("[data-cal-list]");
|
||||||
|
const emptyEl = root.querySelector("[data-cal-empty]");
|
||||||
|
const updatedEl = root.querySelector("[data-cal-updated]");
|
||||||
|
|
||||||
|
if (!(statusEl instanceof HTMLElement) || !(subtitleEl instanceof HTMLElement) || !(dateEl instanceof HTMLElement) ||
|
||||||
|
!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const configuredCalendarNames = Array.isArray(state.calendar_names)
|
||||||
|
? state.calendar_names.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const refreshMsRaw = Number(state.refresh_ms);
|
||||||
|
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLiveContent = (snapshot) => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayBounds = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
return { start, end };
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateHeader = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return now.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric", year: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateValue = (value) => {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if (typeof value.dateTime === "string") return value.dateTime;
|
||||||
|
if (typeof value.date === "string") return value.date;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (value) => {
|
||||||
|
const raw = normalizeDateValue(value);
|
||||||
|
if (!raw) return "--:--";
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "All day";
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return "--:--";
|
||||||
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllDay = (start, end) => {
|
||||||
|
const s = normalizeDateValue(start);
|
||||||
|
const e = normalizeDateValue(end);
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(s) || /^\d{4}-\d{2}-\d{2}$/.test(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventSortKey = (evt) => {
|
||||||
|
const raw = normalizeDateValue(evt && evt.start);
|
||||||
|
const t = new Date(raw).getTime();
|
||||||
|
return Number.isFinite(t) ? t : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractEvents = (toolResult) => {
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||||
|
const parsed = toolResult.parsed;
|
||||||
|
if (Array.isArray(parsed.result)) return parsed.result;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.result)) {
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolConfig = async () => {
|
||||||
|
const fallbackName = configuredToolName || 'mcp_home_assistant_calendar_get_events';
|
||||||
|
if (!window.__nanobotListTools) {
|
||||||
|
return { name: fallbackName, calendars: configuredCalendarNames };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const tool = Array.isArray(tools)
|
||||||
|
? tools.find((item) => /(^|_)calendar_get_events$/i.test(String(item?.name || '')))
|
||||||
|
: null;
|
||||||
|
const enumValues = Array.isArray(tool?.parameters?.properties?.calendar?.enum)
|
||||||
|
? tool.parameters.properties.calendar.enum.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
name: tool?.name || fallbackName,
|
||||||
|
calendars: configuredCalendarNames.length > 0 ? configuredCalendarNames : enumValues,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { name: fallbackName, calendars: configuredCalendarNames };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEvents = (events) => {
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
|
||||||
|
if (!Array.isArray(events) || events.length === 0) {
|
||||||
|
emptyEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyEl.style.display = "none";
|
||||||
|
|
||||||
|
for (const evt of events) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.style.border = "1px solid #e5e7eb";
|
||||||
|
li.style.borderRadius = "10px";
|
||||||
|
li.style.padding = "10px 12px";
|
||||||
|
li.style.background = "#ffffff";
|
||||||
|
|
||||||
|
const summary = document.createElement("div");
|
||||||
|
summary.style.fontSize = "0.96rem";
|
||||||
|
summary.style.fontWeight = "600";
|
||||||
|
summary.style.color = "#111827";
|
||||||
|
summary.textContent = String(evt.summary || "(No title)");
|
||||||
|
|
||||||
|
const timing = document.createElement("div");
|
||||||
|
timing.style.marginTop = "4px";
|
||||||
|
timing.style.fontSize = "0.86rem";
|
||||||
|
timing.style.color = "#475569";
|
||||||
|
|
||||||
|
if (isAllDay(evt.start, evt.end)) {
|
||||||
|
timing.textContent = "All day";
|
||||||
|
} else {
|
||||||
|
timing.textContent = `${formatTime(evt.start)} - ${formatTime(evt.end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.appendChild(summary);
|
||||||
|
li.appendChild(timing);
|
||||||
|
|
||||||
|
if (evt.location) {
|
||||||
|
const location = document.createElement("div");
|
||||||
|
location.style.marginTop = "4px";
|
||||||
|
location.style.fontSize = "0.84rem";
|
||||||
|
location.style.color = "#6b7280";
|
||||||
|
location.textContent = `Location: ${String(evt.location)}`;
|
||||||
|
li.appendChild(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.appendChild(li);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
dateEl.textContent = formatDateHeader();
|
||||||
|
setStatus("Refreshing", "#6b7280");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toolConfig = await resolveToolConfig();
|
||||||
|
if (!toolConfig.name) throw new Error('Calendar tool unavailable');
|
||||||
|
if (!Array.isArray(toolConfig.calendars) || toolConfig.calendars.length === 0) {
|
||||||
|
subtitleEl.textContent = "No Home Assistant calendars available";
|
||||||
|
renderEvents([]);
|
||||||
|
updatedEl.textContent = new Date().toLocaleTimeString();
|
||||||
|
setStatus("OK", "#047857");
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'calendar_today',
|
||||||
|
tool_name: toolConfig.name,
|
||||||
|
calendar_names: [],
|
||||||
|
updated_at: updatedEl.textContent || null,
|
||||||
|
event_count: 0,
|
||||||
|
events: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleEl.textContent = `${toolConfig.calendars.length} calendar${toolConfig.calendars.length === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
|
const allEvents = [];
|
||||||
|
for (const calendarName of toolConfig.calendars) {
|
||||||
|
const toolResult = await window.__nanobotCallTool?.(toolConfig.name, {
|
||||||
|
calendar: calendarName,
|
||||||
|
range: 'today',
|
||||||
|
});
|
||||||
|
const events = extractEvents(toolResult);
|
||||||
|
for (const evt of events) {
|
||||||
|
allEvents.push({ ...evt, _calendarName: calendarName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allEvents.sort((a, b) => eventSortKey(a) - eventSortKey(b));
|
||||||
|
renderEvents(allEvents);
|
||||||
|
updatedEl.textContent = new Date().toLocaleTimeString();
|
||||||
|
setStatus("Daily", "#047857");
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'calendar_today',
|
||||||
|
tool_name: toolConfig.name,
|
||||||
|
calendar_names: toolConfig.calendars,
|
||||||
|
updated_at: updatedEl.textContent || null,
|
||||||
|
event_count: allEvents.length,
|
||||||
|
events: allEvents.map((evt) => ({
|
||||||
|
summary: String(evt.summary || '(No title)'),
|
||||||
|
start: normalizeDateValue(evt.start) || null,
|
||||||
|
end: normalizeDateValue(evt.end) || null,
|
||||||
|
location: typeof evt.location === 'string' ? evt.location : null,
|
||||||
|
calendar_name: evt._calendarName || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
subtitleEl.textContent = "Could not load Home Assistant calendar";
|
||||||
|
renderEvents([]);
|
||||||
|
updatedEl.textContent = String(err);
|
||||||
|
setStatus("Unavailable", "#b91c1c");
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'calendar_today',
|
||||||
|
tool_name: configuredToolName || 'mcp_home_assistant_calendar_get_events',
|
||||||
|
calendar_names: configuredCalendarNames,
|
||||||
|
updated_at: String(err),
|
||||||
|
event_count: 0,
|
||||||
|
events: [],
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, REFRESH_MS);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"key": "live-weather-01545",
|
||||||
|
"title": "Live Weather 01545",
|
||||||
|
"notes": "Use for daily weather card in zip 01545. Pull data through the Home Assistant GetLiveContext MCP tool and match the OpenWeatherMap sensor entries.",
|
||||||
|
"created_at": "2026-03-09T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-11T04:12:48.601255+00:00",
|
||||||
|
"deprecated": true
|
||||||
|
}
|
||||||
250
examples/cards/templates/live-weather-01545/template.html
Normal file
250
examples/cards/templates/live-weather-01545/template.html
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
<div data-weather-card="01545" style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background: #ffffff; color: #111827; border: 1px solid #e5e7eb; border-radius: 16px; box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12); padding: 24px; max-width: 560px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom: 10px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0; font-size:1.15rem; font-weight:700; color:#111827;">Weather 01545</h2>
|
||||||
|
<div style="margin-top:4px; font-size:0.84rem; color:#6b7280;">Source: Home Assistant (weather.openweathermap)</div>
|
||||||
|
</div>
|
||||||
|
<span data-weather-status style="font-size:0.82rem; color:#6b7280;">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:baseline; gap:10px; margin-bottom:10px;">
|
||||||
|
<span data-weather-temp style="font-size:2.6rem; font-weight:800; line-height:1; letter-spacing:-0.03em;">--</span>
|
||||||
|
<span data-weather-unit style="font-size:1rem; font-weight:600; color:#4b5563;">°F</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-weather-condition style="font-size:1rem; font-weight:600; color:#1f2937; margin-bottom:8px; text-transform:capitalize;">--</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:8px; font-size:0.9rem; color:#374151;">
|
||||||
|
<div>Humidity: <strong data-weather-humidity>--</strong></div>
|
||||||
|
<div>Wind: <strong data-weather-wind>--</strong></div>
|
||||||
|
<div>Pressure: <strong data-weather-pressure>--</strong></div>
|
||||||
|
<div>Updated: <strong data-weather-updated>--</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const tempEl = root.querySelector("[data-weather-temp]");
|
||||||
|
const unitEl = root.querySelector("[data-weather-unit]");
|
||||||
|
const condEl = root.querySelector("[data-weather-condition]");
|
||||||
|
const humidityEl = root.querySelector("[data-weather-humidity]");
|
||||||
|
const windEl = root.querySelector("[data-weather-wind]");
|
||||||
|
const pressureEl = root.querySelector("[data-weather-pressure]");
|
||||||
|
const updatedEl = root.querySelector("[data-weather-updated]");
|
||||||
|
const statusEl = root.querySelector("[data-weather-status]");
|
||||||
|
|
||||||
|
if (!(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) ||
|
||||||
|
!(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) ||
|
||||||
|
!(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : 'OpenWeatherMap';
|
||||||
|
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : `${providerPrefix} Temperature`;
|
||||||
|
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : `${providerPrefix} Humidity`;
|
||||||
|
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : `${providerPrefix} Pressure`;
|
||||||
|
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : `${providerPrefix} Wind speed`;
|
||||||
|
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : `${providerPrefix} live context`;
|
||||||
|
const refreshMsRaw = Number(state.refresh_ms);
|
||||||
|
const REFRESH_MS = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nowLabel = () => new Date().toLocaleString([], {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripQuotes = (value) => {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => String(value || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const parseLiveContextEntries = (payloadText) => {
|
||||||
|
const text = String(payloadText || '').replace(/\r/g, '');
|
||||||
|
const startIndex = text.indexOf('- names: ');
|
||||||
|
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
|
||||||
|
const entries = [];
|
||||||
|
let current = null;
|
||||||
|
let inAttributes = false;
|
||||||
|
|
||||||
|
const pushCurrent = () => {
|
||||||
|
if (current) entries.push(current);
|
||||||
|
current = null;
|
||||||
|
inAttributes = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of relevant.split('\n')) {
|
||||||
|
if (rawLine.startsWith('- names: ')) {
|
||||||
|
pushCurrent();
|
||||||
|
current = {
|
||||||
|
name: stripQuotes(rawLine.slice(9)),
|
||||||
|
domain: '',
|
||||||
|
state: '',
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) continue;
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (trimmed === 'attributes:') {
|
||||||
|
inAttributes = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' domain:')) {
|
||||||
|
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' state:')) {
|
||||||
|
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inAttributes && rawLine.startsWith(' ')) {
|
||||||
|
const separatorIndex = rawLine.indexOf(':');
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const key = rawLine.slice(4, separatorIndex).trim();
|
||||||
|
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
|
||||||
|
current.attributes[key] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inAttributes = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCurrent();
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLiveContextText = (toolResult) => {
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||||
|
const parsed = toolResult.parsed;
|
||||||
|
if (typeof parsed.result === 'string') return parsed.result;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolName = async () => {
|
||||||
|
if (configuredToolName) return configuredToolName;
|
||||||
|
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const liveContextTool = Array.isArray(tools)
|
||||||
|
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
|
||||||
|
: null;
|
||||||
|
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
|
||||||
|
} catch {
|
||||||
|
return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findEntry = (entries, candidates) => {
|
||||||
|
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
|
||||||
|
if (normalizedCandidates.length === 0) return null;
|
||||||
|
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
|
||||||
|
if (exactMatch) return exactMatch;
|
||||||
|
return entries.find((entry) => {
|
||||||
|
const entryName = normalizeText(entry.name);
|
||||||
|
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
|
||||||
|
}) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setStatus("Refreshing", "#6b7280");
|
||||||
|
try {
|
||||||
|
const toolName = await resolveToolName();
|
||||||
|
const toolResult = await window.__nanobotCallTool?.(toolName, {});
|
||||||
|
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
|
||||||
|
const temperatureEntry = findEntry(entries, [temperatureName]);
|
||||||
|
const humidityEntry = findEntry(entries, [humidityName]);
|
||||||
|
const pressureEntry = findEntry(entries, [pressureName]);
|
||||||
|
const windEntry = findEntry(entries, [windName]);
|
||||||
|
|
||||||
|
const tempNum = Number(temperatureEntry?.state);
|
||||||
|
tempEl.textContent = Number.isFinite(tempNum) ? String(Math.round(tempNum)) : "--";
|
||||||
|
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || "°F");
|
||||||
|
condEl.textContent = conditionLabel;
|
||||||
|
|
||||||
|
const humidity = Number(humidityEntry?.state);
|
||||||
|
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : "--";
|
||||||
|
|
||||||
|
const windSpeed = Number(windEntry?.state);
|
||||||
|
const windUnit = String(windEntry?.attributes?.unit_of_measurement || "mph");
|
||||||
|
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : "--";
|
||||||
|
|
||||||
|
const pressureNum = Number(pressureEntry?.state);
|
||||||
|
pressureEl.textContent = Number.isFinite(pressureNum)
|
||||||
|
? `${pressureNum} ${String(pressureEntry?.attributes?.unit_of_measurement || "")}`.trim()
|
||||||
|
: "--";
|
||||||
|
|
||||||
|
updatedEl.textContent = nowLabel();
|
||||||
|
setStatus("Live", "#047857");
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, {
|
||||||
|
kind: 'weather',
|
||||||
|
tool_name: toolName,
|
||||||
|
provider_prefix: providerPrefix,
|
||||||
|
temperature: Number.isFinite(tempNum) ? Math.round(tempNum) : null,
|
||||||
|
temperature_unit: unitEl.textContent || null,
|
||||||
|
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
|
||||||
|
wind: windEl.textContent || null,
|
||||||
|
pressure: pressureEl.textContent || null,
|
||||||
|
condition: condEl.textContent || null,
|
||||||
|
updated_at: updatedEl.textContent || null,
|
||||||
|
status: statusEl.textContent || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("Unavailable", "#b91c1c");
|
||||||
|
updatedEl.textContent = String(err);
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, {
|
||||||
|
kind: 'weather',
|
||||||
|
tool_name: configuredToolName || 'mcp_home_assistant_GetLiveContext',
|
||||||
|
provider_prefix: providerPrefix,
|
||||||
|
temperature: null,
|
||||||
|
humidity: null,
|
||||||
|
wind: null,
|
||||||
|
pressure: null,
|
||||||
|
condition: null,
|
||||||
|
updated_at: String(err),
|
||||||
|
status: 'Unavailable',
|
||||||
|
error: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => {
|
||||||
|
void refresh();
|
||||||
|
}, REFRESH_MS);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
20
examples/cards/templates/sensor-live/manifest.json
Normal file
20
examples/cards/templates/sensor-live/manifest.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"key": "sensor-live",
|
||||||
|
"title": "Live Sensor",
|
||||||
|
"notes": "Generic live numeric sensor card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), match_name or match_names, optional device_class, unit, refresh_ms, value_decimals, and optional thresholds.good_max/elevated_max. The card title comes from the feed header, not the template body.",
|
||||||
|
"example_state": {
|
||||||
|
"subtitle": "Home Assistant sensor",
|
||||||
|
"tool_name": "mcp_home_assistant_GetLiveContext",
|
||||||
|
"match_name": "Bedroom-Esp-Sensor CO2",
|
||||||
|
"device_class": "carbon_dioxide",
|
||||||
|
"unit": "ppm",
|
||||||
|
"refresh_ms": 15000,
|
||||||
|
"value_decimals": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"good_max": 900,
|
||||||
|
"elevated_max": 1200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-11T04:12:48.601255+00:00",
|
||||||
|
"updated_at": "2026-03-11T19:18:04.632189+00:00"
|
||||||
|
}
|
||||||
287
examples/cards/templates/sensor-live/template.html
Normal file
287
examples/cards/templates/sensor-live/template.html
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
<div data-sensor-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
|
||||||
|
<div data-sensor-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
|
||||||
|
<span data-sensor-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:8px;">
|
||||||
|
<span data-sensor-value style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
|
||||||
|
<span data-sensor-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:8px; font-size:0.82rem; line-height:1.35; color:#6b7280;">
|
||||||
|
Updated <span data-sensor-updated>--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitleEl = root.querySelector('[data-sensor-subtitle]');
|
||||||
|
const valueEl = root.querySelector('[data-sensor-value]');
|
||||||
|
const unitEl = root.querySelector('[data-sensor-unit]');
|
||||||
|
const statusEl = root.querySelector('[data-sensor-status]');
|
||||||
|
const updatedEl = root.querySelector('[data-sensor-updated]');
|
||||||
|
if (!(subtitleEl instanceof HTMLElement) || !(valueEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const matchName = typeof state.match_name === 'string' ? state.match_name.trim() : '';
|
||||||
|
const matchNames = Array.isArray(state.match_names)
|
||||||
|
? state.match_names.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const searchTerms = Array.isArray(state.search_terms)
|
||||||
|
? state.search_terms.map((value) => String(value || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const deviceClass = typeof state.device_class === 'string' ? state.device_class.trim().toLowerCase() : '';
|
||||||
|
const refreshMsRaw = Number(state.refresh_ms);
|
||||||
|
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 1000 ? refreshMsRaw : 15000;
|
||||||
|
const decimalsRaw = Number(state.value_decimals);
|
||||||
|
const valueDecimals = Number.isFinite(decimalsRaw) && decimalsRaw >= 0 ? decimalsRaw : 0;
|
||||||
|
const fallbackUnit = typeof state.unit === 'string' ? state.unit : '';
|
||||||
|
const thresholds = state && typeof state.thresholds === 'object' && state.thresholds ? state.thresholds : {};
|
||||||
|
const goodMax = Number(thresholds.good_max);
|
||||||
|
const elevatedMax = Number(thresholds.elevated_max);
|
||||||
|
|
||||||
|
subtitleEl.textContent = subtitle || matchName || matchNames[0] || 'Waiting for sensor data';
|
||||||
|
unitEl.textContent = fallbackUnit || '--';
|
||||||
|
const updateLiveContent = (snapshot) => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValue = (value) => {
|
||||||
|
if (!Number.isFinite(value)) return '--';
|
||||||
|
return valueDecimals > 0 ? value.toFixed(valueDecimals) : String(Math.round(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripQuotes = (value) => {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => String(value || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const parseLiveContextEntries = (payloadText) => {
|
||||||
|
const text = String(payloadText || '').replace(/\r/g, '');
|
||||||
|
const startIndex = text.indexOf('- names: ');
|
||||||
|
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
|
||||||
|
const entries = [];
|
||||||
|
let current = null;
|
||||||
|
let inAttributes = false;
|
||||||
|
|
||||||
|
const pushCurrent = () => {
|
||||||
|
if (current) entries.push(current);
|
||||||
|
current = null;
|
||||||
|
inAttributes = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of relevant.split('\n')) {
|
||||||
|
if (rawLine.startsWith('- names: ')) {
|
||||||
|
pushCurrent();
|
||||||
|
current = {
|
||||||
|
name: stripQuotes(rawLine.slice(9)),
|
||||||
|
domain: '',
|
||||||
|
state: '',
|
||||||
|
areas: '',
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) continue;
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (trimmed === 'attributes:') {
|
||||||
|
inAttributes = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' domain:')) {
|
||||||
|
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' state:')) {
|
||||||
|
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' areas:')) {
|
||||||
|
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inAttributes && rawLine.startsWith(' ')) {
|
||||||
|
const separatorIndex = rawLine.indexOf(':');
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const key = rawLine.slice(4, separatorIndex).trim();
|
||||||
|
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
|
||||||
|
current.attributes[key] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inAttributes = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCurrent();
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLiveContextText = (toolResult) => {
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||||
|
const parsed = toolResult.parsed;
|
||||||
|
if (typeof parsed.result === 'string') return parsed.result;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const allMatchNames = [matchName, ...matchNames].filter(Boolean);
|
||||||
|
const allSearchTerms = [...allMatchNames, ...searchTerms].filter(Boolean);
|
||||||
|
|
||||||
|
const scoreEntry = (entry) => {
|
||||||
|
if (!entry || normalizeText(entry.domain) !== 'sensor') return Number.NEGATIVE_INFINITY;
|
||||||
|
const entryName = normalizeText(entry.name);
|
||||||
|
let score = 0;
|
||||||
|
for (const candidate of allMatchNames) {
|
||||||
|
const normalized = normalizeText(candidate);
|
||||||
|
if (!normalized) continue;
|
||||||
|
if (entryName === normalized) score += 100;
|
||||||
|
else if (entryName.includes(normalized)) score += 40;
|
||||||
|
}
|
||||||
|
for (const term of allSearchTerms) {
|
||||||
|
const normalized = normalizeText(term);
|
||||||
|
if (!normalized) continue;
|
||||||
|
if (entryName.includes(normalized)) score += 10;
|
||||||
|
}
|
||||||
|
const entryDeviceClass = normalizeText(entry.attributes?.device_class);
|
||||||
|
if (deviceClass && entryDeviceClass === deviceClass) score += 30;
|
||||||
|
if (fallbackUnit && normalizeText(entry.attributes?.unit_of_measurement) === normalizeText(fallbackUnit)) score += 8;
|
||||||
|
return score;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findSensorEntry = (entries) => {
|
||||||
|
const scored = entries
|
||||||
|
.map((entry) => ({ entry, score: scoreEntry(entry) }))
|
||||||
|
.filter((item) => Number.isFinite(item.score) && item.score > 0)
|
||||||
|
.sort((left, right) => right.score - left.score);
|
||||||
|
return scored.length > 0 ? scored[0].entry : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolName = async () => {
|
||||||
|
if (configuredToolName) return configuredToolName;
|
||||||
|
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const liveContextTool = Array.isArray(tools)
|
||||||
|
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
|
||||||
|
: null;
|
||||||
|
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
|
||||||
|
} catch {
|
||||||
|
return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const classify = (value) => {
|
||||||
|
if (!Number.isFinite(value)) return { label: 'Unavailable', color: '#b91c1c' };
|
||||||
|
if (Number.isFinite(elevatedMax) && value > elevatedMax) return { label: 'High', color: '#b91c1c' };
|
||||||
|
if (Number.isFinite(goodMax) && value > goodMax) return { label: 'Elevated', color: '#b45309' };
|
||||||
|
return { label: 'Good', color: '#047857' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const resolvedToolName = await resolveToolName();
|
||||||
|
if (!resolvedToolName) {
|
||||||
|
const errorText = 'Missing tool_name';
|
||||||
|
valueEl.textContent = '--';
|
||||||
|
setStatus('No tool', '#b91c1c');
|
||||||
|
updatedEl.textContent = errorText;
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'sensor',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: null,
|
||||||
|
match_name: matchName || matchNames[0] || null,
|
||||||
|
value: null,
|
||||||
|
display_value: '--',
|
||||||
|
unit: fallbackUnit || null,
|
||||||
|
status: 'No tool',
|
||||||
|
updated_at: errorText,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Refreshing', '#6b7280');
|
||||||
|
try {
|
||||||
|
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
|
||||||
|
const entries = parseLiveContextEntries(extractLiveContextText(toolResult));
|
||||||
|
const entry = findSensorEntry(entries);
|
||||||
|
if (!entry) throw new Error('Matching sensor not found in live context');
|
||||||
|
const attrs = entry.attributes && typeof entry.attributes === 'object' ? entry.attributes : {};
|
||||||
|
const numericValue = Number(entry.state);
|
||||||
|
const renderedValue = renderValue(numericValue);
|
||||||
|
valueEl.textContent = renderedValue;
|
||||||
|
const unit = fallbackUnit || String(attrs.unit_of_measurement || '--');
|
||||||
|
unitEl.textContent = unit;
|
||||||
|
subtitleEl.textContent = subtitle || entry.name || matchName || matchNames[0] || 'Sensor';
|
||||||
|
const status = classify(numericValue);
|
||||||
|
setStatus(status.label, status.color);
|
||||||
|
const updatedText = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
updatedEl.textContent = updatedText;
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'sensor',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: resolvedToolName,
|
||||||
|
match_name: entry.name || matchName || matchNames[0] || null,
|
||||||
|
value: Number.isFinite(numericValue) ? numericValue : null,
|
||||||
|
display_value: renderedValue,
|
||||||
|
unit,
|
||||||
|
status: status.label,
|
||||||
|
updated_at: updatedText,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorText = String(error);
|
||||||
|
valueEl.textContent = '--';
|
||||||
|
setStatus('Unavailable', '#b91c1c');
|
||||||
|
updatedEl.textContent = errorText;
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'sensor',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: resolvedToolName,
|
||||||
|
match_name: matchName || matchNames[0] || null,
|
||||||
|
value: null,
|
||||||
|
display_value: '--',
|
||||||
|
unit: fallbackUnit || null,
|
||||||
|
status: 'Unavailable',
|
||||||
|
updated_at: errorText,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => { void refresh(); }, refreshMs);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
23
examples/cards/templates/todo-item-live/manifest.json
Normal file
23
examples/cards/templates/todo-item-live/manifest.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"key": "todo-item-live",
|
||||||
|
"title": "Todo Item",
|
||||||
|
"notes": "Source-generated card for a single Home Assistant todo item. Do not use a live fetch URL. A card source script writes one card instance per todo uid and fills template_state with the current item fields plus source_id for refresh. The card completes the task by calling the Home Assistant HassListCompleteItem MCP tool with list_name and the task summary.",
|
||||||
|
"example_state": {
|
||||||
|
"source_id": "ha-todo-kacpers-to-do",
|
||||||
|
"entity_id": "todo.kacpers_to_do",
|
||||||
|
"list_name": "Kacper's To-Do",
|
||||||
|
"uid": "55be123e-1ef3-11f1-b5e6-001e06480aef",
|
||||||
|
"summary": "Get sneakers",
|
||||||
|
"complete_tool_name": "mcp_home_assistant_HassListCompleteItem",
|
||||||
|
"complete_item": "Get sneakers",
|
||||||
|
"status": "needs_action",
|
||||||
|
"completed": false,
|
||||||
|
"due": null,
|
||||||
|
"due_datetime": null,
|
||||||
|
"description": null,
|
||||||
|
"generated_at": "2026-03-13T16:03:26+00:00",
|
||||||
|
"can_complete": true
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-13T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-13T00:00:00+00:00"
|
||||||
|
}
|
||||||
176
examples/cards/templates/todo-item-live/template.html
Normal file
176
examples/cards/templates/todo-item-live/template.html
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<div data-todo-item-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#f7ecdf; color:#65483a; padding:12px 14px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
|
||||||
|
<div style="min-width:0; flex:1 1 auto;">
|
||||||
|
<div data-todo-subtitle style="font-size:0.78rem; line-height:1.25; color:#9a7b68; font-weight:700; text-transform:uppercase; letter-spacing:0.08em;">Todo</div>
|
||||||
|
<div data-todo-summary style="margin-top:6px; font-size:1.08rem; line-height:1.15; font-weight:800; letter-spacing:-0.02em; color:#65483a; word-break:break-word;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<span data-todo-status style="display:none; font-size:0.78rem; line-height:1.2; font-weight:800; color:#9a6a2f; background:#f4e2b8; white-space:nowrap; border-radius:999px; padding:4px 8px;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-todo-due style="display:none; margin-top:8px; font-size:0.82rem; line-height:1.35; color:#947662; font-weight:700;"></div>
|
||||||
|
<div data-todo-description style="display:none; margin-top:8px; font-size:0.82rem; line-height:1.45; color:#7d5f4e;"></div>
|
||||||
|
|
||||||
|
<div style="margin-top:10px; display:flex; align-items:center; gap:10px;">
|
||||||
|
<button data-todo-complete type="button" style="display:none; border:none; border-radius:999px; padding:6px 10px; background:#dfe9d8; color:#35562c; font:800 0.76rem/1 var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); cursor:pointer;">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitleEl = root.querySelector('[data-todo-subtitle]');
|
||||||
|
const statusEl = root.querySelector('[data-todo-status]');
|
||||||
|
const summaryEl = root.querySelector('[data-todo-summary]');
|
||||||
|
const dueEl = root.querySelector('[data-todo-due]');
|
||||||
|
const descriptionEl = root.querySelector('[data-todo-description]');
|
||||||
|
const completeEl = root.querySelector('[data-todo-complete]');
|
||||||
|
if (!(subtitleEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(summaryEl instanceof HTMLElement) || !(dueEl instanceof HTMLElement) || !(descriptionEl instanceof HTMLElement) || !(completeEl instanceof HTMLButtonElement)) return;
|
||||||
|
|
||||||
|
const sourceId = typeof state.source_id === 'string' ? state.source_id.trim() : '';
|
||||||
|
const entityId = typeof state.entity_id === 'string' ? state.entity_id.trim() : '';
|
||||||
|
const uid = typeof state.uid === 'string' ? state.uid.trim() : '';
|
||||||
|
const summary = typeof state.summary === 'string' ? state.summary.trim() : '';
|
||||||
|
const listName = typeof state.list_name === 'string' ? state.list_name.trim() : '';
|
||||||
|
const rawStatus = typeof state.status === 'string' ? state.status.trim() : 'needs_action';
|
||||||
|
const completed = Boolean(state.completed) || rawStatus === 'completed';
|
||||||
|
const due = typeof state.due === 'string' ? state.due.trim() : '';
|
||||||
|
const dueDateTime = typeof state.due_datetime === 'string' ? state.due_datetime.trim() : '';
|
||||||
|
const description = typeof state.description === 'string' ? state.description.trim() : '';
|
||||||
|
const configuredCompleteTool = typeof state.complete_tool_name === 'string' ? state.complete_tool_name.trim() : '';
|
||||||
|
const completeItem = typeof state.complete_item === 'string' ? state.complete_item.trim() : summary;
|
||||||
|
const canComplete = Boolean(state.can_complete) && Boolean(listName) && Boolean(completeItem) && !completed;
|
||||||
|
|
||||||
|
const setStatus = (label, fg, bg) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = fg;
|
||||||
|
statusEl.style.background = bg;
|
||||||
|
statusEl.style.display = label ? 'inline-flex' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDue = () => {
|
||||||
|
if (dueDateTime) {
|
||||||
|
const parsed = new Date(dueDateTime);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return `Due ${parsed.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`;
|
||||||
|
}
|
||||||
|
return `Due ${dueDateTime}`;
|
||||||
|
}
|
||||||
|
if (due) {
|
||||||
|
const parsed = new Date(`${due}T00:00:00`);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return `Due ${parsed.toLocaleDateString([], { month: 'short', day: 'numeric' })}`;
|
||||||
|
}
|
||||||
|
return `Due ${due}`;
|
||||||
|
}
|
||||||
|
return completed ? 'Completed' : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishLiveContent = (statusValue, exists = true, error = '') => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, {
|
||||||
|
kind: 'ha_todo_item',
|
||||||
|
exists,
|
||||||
|
source_id: sourceId || null,
|
||||||
|
entity_id: entityId || null,
|
||||||
|
list_name: listName || null,
|
||||||
|
uid: uid || null,
|
||||||
|
status: statusValue,
|
||||||
|
summary: summary || null,
|
||||||
|
due: due || null,
|
||||||
|
due_datetime: dueDateTime || null,
|
||||||
|
description: description || null,
|
||||||
|
complete_tool_name: configuredCompleteTool || null,
|
||||||
|
can_complete: canComplete,
|
||||||
|
error: error || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveCompleteToolName = async () => {
|
||||||
|
if (configuredCompleteTool) return configuredCompleteTool;
|
||||||
|
if (!window.__nanobotListTools) return 'mcp_home_assistant_HassListCompleteItem';
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const completeTool = Array.isArray(tools)
|
||||||
|
? tools.find((tool) => /(^|_)HassListCompleteItem$/i.test(String(tool?.name || '')))
|
||||||
|
: null;
|
||||||
|
return completeTool?.name || 'mcp_home_assistant_HassListCompleteItem';
|
||||||
|
} catch {
|
||||||
|
return 'mcp_home_assistant_HassListCompleteItem';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
subtitleEl.textContent = listName || 'Todo';
|
||||||
|
summaryEl.textContent = summary || '(Untitled task)';
|
||||||
|
const dueText = formatDue();
|
||||||
|
dueEl.textContent = dueText;
|
||||||
|
dueEl.style.display = dueText ? 'block' : 'none';
|
||||||
|
descriptionEl.textContent = description;
|
||||||
|
descriptionEl.style.display = description ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
|
setStatus('Done', '#6c8b63', '#dfe9d8');
|
||||||
|
summaryEl.style.textDecoration = 'line-through';
|
||||||
|
summaryEl.style.color = '#7d5f4e';
|
||||||
|
completeEl.style.display = 'none';
|
||||||
|
completeEl.disabled = true;
|
||||||
|
} else {
|
||||||
|
setStatus('', '', 'transparent');
|
||||||
|
summaryEl.style.textDecoration = 'none';
|
||||||
|
summaryEl.style.color = '#65483a';
|
||||||
|
if (canComplete) {
|
||||||
|
completeEl.style.display = 'inline-flex';
|
||||||
|
completeEl.disabled = false;
|
||||||
|
} else {
|
||||||
|
completeEl.style.display = 'none';
|
||||||
|
completeEl.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
publishLiveContent(completed ? 'completed' : rawStatus, true, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestCardResync = async () => {
|
||||||
|
if (!sourceId) return;
|
||||||
|
await fetch('/cards/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source_id: sourceId }),
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event('nanobot:cards-refresh'));
|
||||||
|
};
|
||||||
|
|
||||||
|
completeEl.addEventListener('click', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!canComplete) return;
|
||||||
|
completeEl.disabled = true;
|
||||||
|
setStatus('Saving', '#9a7b68', 'rgba(231, 220, 209, 0.92)');
|
||||||
|
try {
|
||||||
|
const completeToolName = await resolveCompleteToolName();
|
||||||
|
await window.__nanobotCallTool?.(completeToolName, {
|
||||||
|
name: listName,
|
||||||
|
item: completeItem,
|
||||||
|
});
|
||||||
|
setStatus('Done', '#6c8b63', '#dfe9d8');
|
||||||
|
summaryEl.style.textDecoration = 'line-through';
|
||||||
|
summaryEl.style.color = '#7d5f4e';
|
||||||
|
dueEl.textContent = 'Completed';
|
||||||
|
dueEl.style.display = 'block';
|
||||||
|
completeEl.style.display = 'none';
|
||||||
|
publishLiveContent('completed', true, '');
|
||||||
|
await requestCardResync();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('Unavailable', '#a14d43', '#f3d8d2');
|
||||||
|
console.error('Todo completion failed', error);
|
||||||
|
completeEl.disabled = false;
|
||||||
|
publishLiveContent(rawStatus, true, String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
16
examples/cards/templates/weather-live/manifest.json
Normal file
16
examples/cards/templates/weather-live/manifest.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"key": "weather-live",
|
||||||
|
"title": "Live Weather",
|
||||||
|
"notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.",
|
||||||
|
"example_state": {
|
||||||
|
"subtitle": "OpenWeatherMap live context",
|
||||||
|
"tool_name": "mcp_home_assistant_GetLiveContext",
|
||||||
|
"provider_prefix": "OpenWeatherMap",
|
||||||
|
"temperature_name": "OpenWeatherMap Temperature",
|
||||||
|
"humidity_name": "OpenWeatherMap Humidity",
|
||||||
|
"condition_label": "OpenWeatherMap live context",
|
||||||
|
"refresh_ms": 86400000
|
||||||
|
},
|
||||||
|
"created_at": "2026-03-11T04:12:48.601255+00:00",
|
||||||
|
"updated_at": "2026-03-11T19:18:04.632189+00:00"
|
||||||
|
}
|
||||||
293
examples/cards/templates/weather-live/template.html
Normal file
293
examples/cards/templates/weather-live/template.html
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
<div data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;">
|
||||||
|
<div data-weather-subtitle style="font-size:0.86rem; line-height:1.35; color:#4b5563; font-weight:600;">Loading…</div>
|
||||||
|
<span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:8px; margin-bottom:4px;">
|
||||||
|
<span data-weather-temp style="font-size:3rem; font-weight:800; line-height:0.95; letter-spacing:-0.045em;">--</span>
|
||||||
|
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-weather-condition style="font-size:1rem; line-height:1.3; font-weight:700; color:#1f2937; margin-bottom:10px; text-transform:capitalize;">--</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Humidity</div>
|
||||||
|
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Wind</div>
|
||||||
|
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Pressure</div>
|
||||||
|
<div data-weather-pressure style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Updated</div>
|
||||||
|
<div data-weather-updated style="margin-top:2px; font-size:0.94rem; line-height:1.25; font-weight:700; color:#374151;">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const script = document.currentScript;
|
||||||
|
const root = script?.closest('[data-nanobot-card-root]');
|
||||||
|
const state = window.__nanobotGetCardState?.(script) || {};
|
||||||
|
if (!(root instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitleEl = root.querySelector('[data-weather-subtitle]');
|
||||||
|
const tempEl = root.querySelector('[data-weather-temp]');
|
||||||
|
const unitEl = root.querySelector('[data-weather-unit]');
|
||||||
|
const condEl = root.querySelector('[data-weather-condition]');
|
||||||
|
const humidityEl = root.querySelector('[data-weather-humidity]');
|
||||||
|
const windEl = root.querySelector('[data-weather-wind]');
|
||||||
|
const pressureEl = root.querySelector('[data-weather-pressure]');
|
||||||
|
const updatedEl = root.querySelector('[data-weather-updated]');
|
||||||
|
const statusEl = root.querySelector('[data-weather-status]');
|
||||||
|
if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(condEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(pressureEl instanceof HTMLElement) || !(updatedEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
|
||||||
|
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
|
||||||
|
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
|
||||||
|
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
|
||||||
|
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : '';
|
||||||
|
const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : '';
|
||||||
|
const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : '';
|
||||||
|
const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : '';
|
||||||
|
const refreshMsRaw = Number(state.refresh_ms);
|
||||||
|
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
subtitleEl.textContent = subtitle || providerPrefix || 'Waiting for weather data';
|
||||||
|
const updateLiveContent = (snapshot) => {
|
||||||
|
window.__nanobotSetCardLiveContent?.(script, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (label, color) => {
|
||||||
|
statusEl.textContent = label;
|
||||||
|
statusEl.style.color = color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripQuotes = (value) => {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
if ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"'))) {
|
||||||
|
return text.slice(1, -1);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => String(value || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
const parseLiveContextEntries = (payloadText) => {
|
||||||
|
const text = String(payloadText || '').replace(/\r/g, '');
|
||||||
|
const startIndex = text.indexOf('- names: ');
|
||||||
|
const relevant = startIndex >= 0 ? text.slice(startIndex) : text;
|
||||||
|
const entries = [];
|
||||||
|
let current = null;
|
||||||
|
let inAttributes = false;
|
||||||
|
|
||||||
|
const pushCurrent = () => {
|
||||||
|
if (current) entries.push(current);
|
||||||
|
current = null;
|
||||||
|
inAttributes = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of relevant.split('\n')) {
|
||||||
|
if (rawLine.startsWith('- names: ')) {
|
||||||
|
pushCurrent();
|
||||||
|
current = {
|
||||||
|
name: stripQuotes(rawLine.slice(9)),
|
||||||
|
domain: '',
|
||||||
|
state: '',
|
||||||
|
areas: '',
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) continue;
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (trimmed === 'attributes:') {
|
||||||
|
inAttributes = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' domain:')) {
|
||||||
|
current.domain = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' state:')) {
|
||||||
|
current.state = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.startsWith(' areas:')) {
|
||||||
|
current.areas = stripQuotes(rawLine.slice(rawLine.indexOf(':') + 1));
|
||||||
|
inAttributes = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inAttributes && rawLine.startsWith(' ')) {
|
||||||
|
const separatorIndex = rawLine.indexOf(':');
|
||||||
|
if (separatorIndex >= 0) {
|
||||||
|
const key = rawLine.slice(4, separatorIndex).trim();
|
||||||
|
const value = stripQuotes(rawLine.slice(separatorIndex + 1));
|
||||||
|
current.attributes[key] = value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inAttributes = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCurrent();
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLiveContextText = (toolResult) => {
|
||||||
|
if (toolResult && toolResult.parsed && typeof toolResult.parsed === 'object') {
|
||||||
|
const parsed = toolResult.parsed;
|
||||||
|
if (typeof parsed.result === 'string') return parsed.result;
|
||||||
|
}
|
||||||
|
if (typeof toolResult?.content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolResult.content);
|
||||||
|
if (parsed && typeof parsed === 'object' && typeof parsed.result === 'string') {
|
||||||
|
return parsed.result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return toolResult.content;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveToolName = async () => {
|
||||||
|
if (configuredToolName) return configuredToolName;
|
||||||
|
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
try {
|
||||||
|
const tools = await window.__nanobotListTools();
|
||||||
|
const liveContextTool = Array.isArray(tools)
|
||||||
|
? tools.find((tool) => /(^|_)GetLiveContext$/i.test(String(tool?.name || '')))
|
||||||
|
: null;
|
||||||
|
return liveContextTool?.name || 'mcp_home_assistant_GetLiveContext';
|
||||||
|
} catch {
|
||||||
|
return 'mcp_home_assistant_GetLiveContext';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findEntry = (entries, candidates) => {
|
||||||
|
const normalizedCandidates = candidates.map((value) => normalizeText(value)).filter(Boolean);
|
||||||
|
if (normalizedCandidates.length === 0) return null;
|
||||||
|
const exactMatch = entries.find((entry) => normalizedCandidates.includes(normalizeText(entry.name)));
|
||||||
|
if (exactMatch) return exactMatch;
|
||||||
|
return entries.find((entry) => {
|
||||||
|
const entryName = normalizeText(entry.name);
|
||||||
|
return normalizedCandidates.some((candidate) => entryName.includes(candidate));
|
||||||
|
}) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
const resolvedToolName = await resolveToolName();
|
||||||
|
if (!resolvedToolName) {
|
||||||
|
const errorText = 'Missing tool_name';
|
||||||
|
setStatus('No tool', '#b91c1c');
|
||||||
|
updatedEl.textContent = errorText;
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'weather',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: null,
|
||||||
|
temperature: null,
|
||||||
|
temperature_unit: String(state.unit || '°F'),
|
||||||
|
condition: null,
|
||||||
|
humidity: null,
|
||||||
|
wind: null,
|
||||||
|
pressure: null,
|
||||||
|
status: 'No tool',
|
||||||
|
updated_at: errorText,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Refreshing', '#6b7280');
|
||||||
|
try {
|
||||||
|
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {});
|
||||||
|
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
|
||||||
|
const prefix = providerPrefix || 'OpenWeatherMap';
|
||||||
|
const temperatureEntry = findEntry(entries, [
|
||||||
|
temperatureName,
|
||||||
|
`${prefix} Temperature`,
|
||||||
|
]);
|
||||||
|
const humidityEntry = findEntry(entries, [
|
||||||
|
humidityName,
|
||||||
|
`${prefix} Humidity`,
|
||||||
|
]);
|
||||||
|
const pressureEntry = findEntry(entries, [
|
||||||
|
pressureName,
|
||||||
|
`${prefix} Pressure`,
|
||||||
|
]);
|
||||||
|
const windEntry = findEntry(entries, [
|
||||||
|
windName,
|
||||||
|
`${prefix} Wind speed`,
|
||||||
|
`${prefix} Wind`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const temperature = Number(temperatureEntry?.state);
|
||||||
|
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
|
||||||
|
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
|
||||||
|
condEl.textContent = conditionLabel || `${prefix || 'Weather'} live context`;
|
||||||
|
const humidity = Number(humidityEntry?.state);
|
||||||
|
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
|
||||||
|
const windSpeed = Number(windEntry?.state);
|
||||||
|
const windUnit = String(windEntry?.attributes?.unit_of_measurement || 'mph');
|
||||||
|
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : '--';
|
||||||
|
const pressure = Number(pressureEntry?.state);
|
||||||
|
pressureEl.textContent = Number.isFinite(pressure)
|
||||||
|
? `${pressure} ${String(pressureEntry?.attributes?.unit_of_measurement || '').trim()}`.trim()
|
||||||
|
: '--';
|
||||||
|
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
updatedEl.textContent = updatedText;
|
||||||
|
subtitleEl.textContent = subtitle || prefix || 'Home Assistant live context';
|
||||||
|
setStatus('Live', '#047857');
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'weather',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: resolvedToolName,
|
||||||
|
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
|
||||||
|
temperature_unit: unitEl.textContent || null,
|
||||||
|
condition: condEl.textContent || null,
|
||||||
|
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
|
||||||
|
wind: windEl.textContent || null,
|
||||||
|
pressure: pressureEl.textContent || null,
|
||||||
|
status: 'Live',
|
||||||
|
updated_at: updatedText,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorText = String(error);
|
||||||
|
setStatus('Unavailable', '#b91c1c');
|
||||||
|
updatedEl.textContent = errorText;
|
||||||
|
updateLiveContent({
|
||||||
|
kind: 'weather',
|
||||||
|
subtitle: subtitleEl.textContent || null,
|
||||||
|
tool_name: resolvedToolName,
|
||||||
|
temperature: null,
|
||||||
|
temperature_unit: unitEl.textContent || null,
|
||||||
|
condition: null,
|
||||||
|
humidity: null,
|
||||||
|
wind: null,
|
||||||
|
pressure: null,
|
||||||
|
status: 'Unavailable',
|
||||||
|
updated_at: errorText,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__nanobotSetCardRefresh?.(script, () => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
void refresh();
|
||||||
|
window.setInterval(() => { void refresh(); }, refreshMs);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
@ -11,6 +11,8 @@ import type {
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "";
|
||||||
|
const WEBRTC_STUN_URL = import.meta.env.VITE_WEBRTC_STUN_URL?.trim() ?? "";
|
||||||
|
const LOCAL_ICE_GATHER_TIMEOUT_MS = 350;
|
||||||
|
|
||||||
let cardIdCounter = 0;
|
let cardIdCounter = 0;
|
||||||
let logIdCounter = 0;
|
let logIdCounter = 0;
|
||||||
|
|
@ -235,7 +237,10 @@ function waitForIceComplete(pc: RTCPeerConnection): Promise<void> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pc.addEventListener("icegatheringstatechange", check);
|
pc.addEventListener("icegatheringstatechange", check);
|
||||||
setTimeout(resolve, 5000);
|
setTimeout(() => {
|
||||||
|
pc.removeEventListener("icegatheringstatechange", check);
|
||||||
|
resolve();
|
||||||
|
}, LOCAL_ICE_GATHER_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,7 +282,11 @@ async function runConnect(
|
||||||
refs.localTracksRef.current = audioTracks;
|
refs.localTracksRef.current = audioTracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
|
// Local-only deployments do not need a public STUN server; host candidates are enough
|
||||||
|
// and avoiding external ICE gathering removes several seconds from startup latency.
|
||||||
|
const pc = new RTCPeerConnection(
|
||||||
|
WEBRTC_STUN_URL ? { iceServers: [{ urls: WEBRTC_STUN_URL }] } : undefined,
|
||||||
|
);
|
||||||
refs.pcRef.current = pc;
|
refs.pcRef.current = pc;
|
||||||
|
|
||||||
const newRemoteStream = new MediaStream();
|
const newRemoteStream = new MediaStream();
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ BRAILLE_SPINNER_RE = re.compile(r"[\u2800-\u28ff]")
|
||||||
TTS_ALLOWED_ASCII = set(
|
TTS_ALLOWED_ASCII = set(
|
||||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?;:'\"()[]{}@#%&*+-_/<>|"
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?;:'\"()[]{}@#%&*+-_/<>|"
|
||||||
)
|
)
|
||||||
|
LOCAL_ICE_GATHER_TIMEOUT_S = 0.35
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_tts_text(text: str) -> str:
|
def _sanitize_tts_text(text: str) -> str:
|
||||||
|
|
@ -1346,7 +1347,7 @@ class WebRTCVoiceSession:
|
||||||
completed.set()
|
completed.set()
|
||||||
|
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(completed.wait(), timeout=3)
|
await asyncio.wait_for(completed.wait(), timeout=LOCAL_ICE_GATHER_TIMEOUT_S)
|
||||||
|
|
||||||
async def _warmup_stt(self) -> None:
|
async def _warmup_stt(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue