feat: add card examples and speed up rtc connect
Some checks failed
CI / Backend Checks (push) Failing after 29s
CI / Frontend Checks (push) Failing after 36s

This commit is contained in:
kacper 2026-03-15 21:44:47 -04:00
parent 04afead5af
commit 23fd806e6d
41 changed files with 3327 additions and 3 deletions

11
examples/cards/README.md Normal file
View 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.

View file

@ -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"
}

View file

@ -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."
}

View 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"
}

View 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
}
}

View 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"
}

View 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."
}

View 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"
}

View file

@ -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
}

View 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"
}

View file

@ -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
}
}

View 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"
}

View 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
}

View file

@ -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 Kacpers 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"
}

View file

@ -0,0 +1,14 @@
{
"source_id": "ha-todo-kacpers-to-do",
"entity_id": "todo.kacpers_to_do",
"list_name": "Kacpers 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
}

View file

@ -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 Kacpers 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"
}

View file

@ -0,0 +1,14 @@
{
"source_id": "ha-todo-kacpers-to-do",
"entity_id": "todo.kacpers_to_do",
"list_name": "Kacpers 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
}

View file

@ -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 Kacpers 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"
}

View file

@ -0,0 +1,14 @@
{
"source_id": "ha-todo-kacpers-to-do",
"entity_id": "todo.kacpers_to_do",
"list_name": "Kacpers 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
}

View file

@ -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 Kacpers 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"
}

View file

@ -0,0 +1,14 @@
{
"source_id": "ha-todo-kacpers-to-do",
"entity_id": "todo.kacpers_to_do",
"list_name": "Kacpers 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
}

View 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"
}

View 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>

View 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"
}

View 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>

View file

@ -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"
}

View 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>

View 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
}

View 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>

View file

@ -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
}

View 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>

View file

@ -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
}

View 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>

View 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"
}

View 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>

View 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"
}

View 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>

View 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"
}

View 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>