feat: polish life os cards and voice stack

This commit is contained in:
kacper 2026-03-24 08:54:47 -04:00
parent 66362c7176
commit 0edf8c3fef
21 changed files with 3681 additions and 502 deletions

157
app.py
View file

@ -38,6 +38,10 @@ _JSONRPC_VERSION = "2.0"
_TOOL_JOB_TIMEOUT_SECONDS = 300.0 _TOOL_JOB_TIMEOUT_SECONDS = 300.0
_TOOL_JOB_RETENTION_SECONDS = 15 * 60 _TOOL_JOB_RETENTION_SECONDS = 15 * 60
_NANOBOT_API_STREAM_LIMIT = 2 * 1024 * 1024 _NANOBOT_API_STREAM_LIMIT = 2 * 1024 * 1024
_TTS_SENTENCE_BREAK_RE = re.compile(r"(?<=[.!?])\s+")
_TTS_CLAUSE_BREAK_RE = re.compile(r"(?<=[,;:])\s+")
_TTS_SEGMENT_TARGET_CHARS = 180
_TTS_SEGMENT_MAX_CHARS = 260
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) CARD_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
@ -104,6 +108,19 @@ def _decode_object(raw: str) -> dict[str, Any] | None:
return payload if isinstance(payload, dict) else None return payload if isinstance(payload, dict) else None
def _parse_iso_datetime(raw: str) -> datetime | None:
value = raw.strip()
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
async def _read_json_request(request: Request) -> dict[str, Any]: async def _read_json_request(request: Request) -> dict[str, Any]:
try: try:
payload = await request.json() payload = await request.json()
@ -114,6 +131,76 @@ async def _read_json_request(request: Request) -> dict[str, Any]:
return payload return payload
def _wrap_tts_words(text: str, max_chars: int) -> list[str]:
words = text.split()
if not words:
return []
chunks: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if len(candidate) <= max_chars:
current = candidate
continue
chunks.append(current)
current = word
chunks.append(current)
return chunks
def _chunk_tts_text(text: str) -> list[str]:
normalized = text.replace("\r\n", "\n").strip()
if not normalized:
return []
chunks: list[str] = []
paragraphs = [part.strip() for part in re.split(r"\n{2,}", normalized) if part.strip()]
for paragraph in paragraphs:
compact = re.sub(r"\s+", " ", paragraph).strip()
if not compact:
continue
sentences = [
sentence.strip()
for sentence in _TTS_SENTENCE_BREAK_RE.split(compact)
if sentence.strip()
]
if not sentences:
sentences = [compact]
current = ""
for sentence in sentences:
parts = [sentence]
if len(sentence) > _TTS_SEGMENT_MAX_CHARS:
parts = [
clause.strip()
for clause in _TTS_CLAUSE_BREAK_RE.split(sentence)
if clause.strip()
] or [sentence]
for part in parts:
if len(part) > _TTS_SEGMENT_MAX_CHARS:
if current:
chunks.append(current)
current = ""
chunks.extend(_wrap_tts_words(part, _TTS_SEGMENT_MAX_CHARS))
continue
candidate = part if not current else f"{current} {part}"
if len(candidate) <= _TTS_SEGMENT_TARGET_CHARS:
current = candidate
continue
if current:
chunks.append(current)
current = part
if current:
chunks.append(current)
return chunks or [re.sub(r"\s+", " ", normalized).strip()]
def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None: def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
card_id = _normalize_card_id(str(raw.get("id", ""))) card_id = _normalize_card_id(str(raw.get("id", "")))
if not card_id: if not card_id:
@ -158,6 +245,7 @@ def _coerce_card_record(raw: dict[str, Any]) -> dict[str, Any] | None:
"template_state": template_state, "template_state": template_state,
"context_summary": str(raw.get("context_summary", "")), "context_summary": str(raw.get("context_summary", "")),
"chat_id": str(raw.get("chat_id", "web") or "web"), "chat_id": str(raw.get("chat_id", "web") or "web"),
"snooze_until": str(raw.get("snooze_until", "") or ""),
"created_at": str(raw.get("created_at", "")), "created_at": str(raw.get("created_at", "")),
"updated_at": str(raw.get("updated_at", "")), "updated_at": str(raw.get("updated_at", "")),
} }
@ -287,6 +375,7 @@ def _sort_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _load_cards() -> list[dict[str, Any]]: def _load_cards() -> list[dict[str, Any]]:
CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True)
cards: list[dict[str, Any]] = [] cards: list[dict[str, Any]] = []
now = datetime.now(timezone.utc)
for instance_dir in CARD_INSTANCES_DIR.iterdir(): for instance_dir in CARD_INSTANCES_DIR.iterdir():
if not instance_dir.is_dir(): if not instance_dir.is_dir():
continue continue
@ -295,6 +384,9 @@ def _load_cards() -> list[dict[str, Any]]:
continue continue
if card.get("state") == "archived": if card.get("state") == "archived":
continue continue
snooze_until = _parse_iso_datetime(str(card.get("snooze_until", "") or ""))
if snooze_until is not None and snooze_until > now:
continue
cards.append(card) cards.append(card)
return _sort_cards(cards) return _sort_cards(cards)
@ -817,6 +909,62 @@ async def delete_card(card_id: str) -> JSONResponse:
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@app.post("/cards/{card_id}/snooze")
async def snooze_card(card_id: str, request: Request) -> JSONResponse:
if not _normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await _read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
until_raw = str(payload.get("until", "")).strip()
until_dt = _parse_iso_datetime(until_raw)
if until_dt is None:
return JSONResponse({"error": "until must be a valid ISO datetime"}, status_code=400)
card = _load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
card["snooze_until"] = until_dt.isoformat()
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = _write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to snooze card"}, status_code=500)
return JSONResponse({"status": "ok", "card": persisted})
@app.post("/cards/{card_id}/state")
async def update_card_state(card_id: str, request: Request) -> JSONResponse:
if not _normalize_card_id(card_id):
return JSONResponse({"error": "invalid card id"}, status_code=400)
try:
payload = await _read_json_request(request)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
template_state = payload.get("template_state")
if not isinstance(template_state, dict):
return JSONResponse({"error": "template_state must be an object"}, status_code=400)
card = _load_card(card_id)
if card is None:
return JSONResponse({"error": "card not found"}, status_code=404)
if str(card.get("kind", "")) != "text":
return JSONResponse({"error": "only text cards support template_state"}, status_code=400)
card["template_state"] = template_state
card["updated_at"] = datetime.now(timezone.utc).isoformat()
persisted = _write_card(card)
if persisted is None:
return JSONResponse({"error": "failed to update card state"}, status_code=500)
return JSONResponse({"status": "ok", "card": persisted})
@app.get("/templates") @app.get("/templates")
async def get_templates() -> JSONResponse: async def get_templates() -> JSONResponse:
return JSONResponse(_list_templates()) return JSONResponse(_list_templates())
@ -975,8 +1123,15 @@ async def _sender_loop(
) -> None: ) -> None:
while True: while True:
event = await queue.get() event = await queue.get()
if event.role == "nanobot-tts-partial":
await voice_session.queue_output_text(event.text, partial=True)
continue
if event.role == "nanobot-tts-flush":
await voice_session.flush_partial_output_text()
continue
if event.role == "nanobot-tts": if event.role == "nanobot-tts":
await voice_session.queue_output_text(event.text) for segment in _chunk_tts_text(event.text):
await voice_session.queue_output_text(segment)
continue continue
typed_event = _to_typed_message(event.to_dict()) typed_event = _to_typed_message(event.to_dict())
if typed_event is None: if typed_event is None:

View file

@ -0,0 +1,17 @@
{
"id": "live-calorie-tracker",
"kind": "text",
"title": "Calories",
"question": "",
"choices": [],
"response_value": "",
"slot": "live-calorie-tracker",
"lane": "context",
"priority": 76,
"state": "active",
"template_key": "list-total-live",
"context_summary": "",
"chat_id": "web",
"created_at": "2026-03-21T00:00:00+00:00",
"updated_at": "2026-03-21T00:00:00+00:00"
}

View file

@ -0,0 +1,9 @@
{
"left_label": "Cal",
"right_label": "Food",
"total_label": "Total",
"total_suffix": "cal",
"max_digits": 4,
"score": 76,
"rows": []
}

View file

@ -1,10 +1,12 @@
{ {
"title": "Weather 01545", "title": "Weather 01545",
"subtitle": "OpenWeatherMap live context", "subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext", "tool_name": "mcp_home_assistant_GetLiveContext",
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4",
"provider_prefix": "OpenWeatherMap", "provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature", "temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity", "humidity_name": "OpenWeatherMap Humidity",
"condition_label": "OpenWeatherMap live context", "condition_label": "Weather",
"refresh_ms": 86400000 "refresh_ms": 86400000
} }

View file

@ -0,0 +1,24 @@
{
"key": "list-total-live",
"title": "List Total",
"notes": "Generic editable two-column list card with a numeric left column, freeform right column, and a running total persisted in template_state. Configure left_label, right_label, total_label, total_suffix, max_digits, and rows.",
"example_state": {
"left_label": "Cal",
"right_label": "Food",
"total_label": "Total",
"total_suffix": "cal",
"max_digits": 4,
"rows": [
{
"value": "420",
"name": "Lunch"
},
{
"value": "180",
"name": "Snack"
}
]
},
"created_at": "2026-03-21T00:00:00+00:00",
"updated_at": "2026-03-21T00:00:00+00:00"
}

View file

@ -0,0 +1,336 @@
<style>
.list-total-card {
display: grid;
gap: 10px;
color: #4d392d;
}
.list-total-card__labels,
.list-total-card__row,
.list-total-card__total {
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.list-total-card__labels {
color: rgba(77, 57, 45, 0.72);
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__rows {
display: grid;
gap: 6px;
}
.list-total-card__input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid rgba(92, 70, 55, 0.14);
border-radius: 0;
background: transparent;
color: #473429;
padding: 4px 0;
outline: none;
box-shadow: none;
}
.list-total-card__input::placeholder {
color: rgba(77, 57, 45, 0.42);
}
.list-total-card__value {
font: 700 0.84rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
.list-total-card__name {
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 0.92rem;
line-height: 1.08;
font-weight: 600;
letter-spacing: -0.008em;
}
.list-total-card__status {
min-height: 0.9rem;
color: #8e3023;
font: 700 0.62rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list-total-card__status[data-kind='ok'] {
color: rgba(77, 57, 45, 0.5);
}
.list-total-card__total {
padding-top: 8px;
border-top: 1px solid rgba(92, 70, 55, 0.18);
color: #35271f;
}
.list-total-card__total-label {
font: 700 0.66rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card__total-value {
font: 700 0.98rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
text-align: right;
}
</style>
<div class="list-total-card" data-list-total-card>
<div class="list-total-card__labels">
<div data-list-total-left-label>Value</div>
<div data-list-total-right-label>Item</div>
</div>
<div class="list-total-card__rows" data-list-total-rows></div>
<div class="list-total-card__status" data-list-total-status></div>
<div class="list-total-card__total">
<div class="list-total-card__total-label" data-list-total-total-label>Total</div>
<div class="list-total-card__total-value" data-list-total-total>0</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 cardId = String(root.dataset.cardId || '').trim();
const rowsEl = root.querySelector('[data-list-total-rows]');
const statusEl = root.querySelector('[data-list-total-status]');
const totalEl = root.querySelector('[data-list-total-total]');
const totalLabelEl = root.querySelector('[data-list-total-total-label]');
const leftLabelEl = root.querySelector('[data-list-total-left-label]');
const rightLabelEl = root.querySelector('[data-list-total-right-label]');
if (
!(rowsEl instanceof HTMLElement) ||
!(statusEl instanceof HTMLElement) ||
!(totalEl instanceof HTMLElement) ||
!(totalLabelEl instanceof HTMLElement) ||
!(leftLabelEl instanceof HTMLElement) ||
!(rightLabelEl instanceof HTMLElement)
) {
return;
}
const maxDigits = Math.max(
1,
Math.min(4, Number.isFinite(Number(state.max_digits)) ? Number(state.max_digits) : 4),
);
const totalSuffix = String(state.total_suffix || '').trim();
const leftLabel = String(state.left_label || 'Value').trim() || 'Value';
const rightLabel = String(state.right_label || 'Item').trim() || 'Item';
const totalLabel = String(state.total_label || 'Total').trim() || 'Total';
leftLabelEl.textContent = leftLabel;
rightLabelEl.textContent = rightLabel;
totalLabelEl.textContent = totalLabel;
function sanitizeValue(raw) {
return String(raw || '').replace(/\D+/g, '').slice(0, maxDigits);
}
function sanitizeName(raw) {
return String(raw || '').replace(/\s+/g, ' ').trimStart();
}
function normalizeRows(raw) {
if (!Array.isArray(raw)) return [];
return raw
.filter((row) => row && typeof row === 'object' && !Array.isArray(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function isBlankRow(row) {
return !row || (!String(row.value || '').trim() && !String(row.name || '').trim());
}
function ensureTrailingBlankRow(items) {
const next = items.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
if (!next.length || !isBlankRow(next[next.length - 1])) {
next.push({ value: '', name: '' });
}
return next;
}
function persistedRows() {
return rows
.filter((row) => !isBlankRow(row))
.map((row) => ({
value: sanitizeValue(row.value),
name: sanitizeName(row.name),
}));
}
function computeTotal() {
return persistedRows().reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0);
}
function updateTotal() {
const total = computeTotal();
totalEl.textContent = `${total.toLocaleString()}${totalSuffix ? totalSuffix : ''}`;
window.__nanobotSetCardLiveContent?.(script, {
kind: 'list_total',
item_count: persistedRows().length,
total,
total_suffix: totalSuffix || null,
score: persistedRows().length ? 24 : 16,
});
}
function setStatus(text, kind) {
statusEl.textContent = text || '';
statusEl.dataset.kind = kind || '';
}
let rows = ensureTrailingBlankRow(normalizeRows(state.rows));
let saveTimer = null;
let inFlightSave = null;
async function persistState() {
if (!cardId) return;
const nextState = {
...state,
left_label: leftLabel,
right_label: rightLabel,
total_label: totalLabel,
total_suffix: totalSuffix,
max_digits: maxDigits,
rows: persistedRows(),
};
try {
setStatus('Saving', 'ok');
inFlightSave = fetch(`/cards/${encodeURIComponent(cardId)}/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template_state: nextState }),
});
const response = await inFlightSave;
if (!response.ok) {
let message = `save failed (${response.status})`;
try {
const payload = await response.json();
if (payload && typeof payload.error === 'string' && payload.error) {
message = payload.error;
}
} catch (_) {}
throw new Error(message);
}
setStatus('', '');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'save failed', 'error');
} finally {
inFlightSave = null;
}
}
function schedulePersist() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => {
void persistState();
}, 280);
}
function pruneRows() {
rows = ensureTrailingBlankRow(
rows.filter((row, index) => !isBlankRow(row) || index === rows.length - 1),
);
}
function renderRows() {
rowsEl.innerHTML = '';
rows.forEach((row, index) => {
const rowEl = document.createElement('div');
rowEl.className = 'list-total-card__row';
const valueInput = document.createElement('input');
valueInput.className = 'list-total-card__input list-total-card__value';
valueInput.type = 'text';
valueInput.inputMode = 'numeric';
valueInput.maxLength = maxDigits;
valueInput.placeholder = '0';
valueInput.value = row.value;
const nameInput = document.createElement('input');
nameInput.className = 'list-total-card__input list-total-card__name';
nameInput.type = 'text';
nameInput.placeholder = 'Item';
nameInput.value = row.name;
valueInput.addEventListener('input', () => {
rows[index].value = sanitizeValue(valueInput.value);
valueInput.value = rows[index].value;
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
nameInput.addEventListener('input', () => {
rows[index].name = sanitizeName(nameInput.value);
if (index === rows.length - 1 && !isBlankRow(rows[index])) {
rows = ensureTrailingBlankRow(rows);
renderRows();
schedulePersist();
return;
}
updateTotal();
schedulePersist();
});
const handleBlur = () => {
rows[index].value = sanitizeValue(valueInput.value);
rows[index].name = sanitizeName(nameInput.value);
const nextRows = ensureTrailingBlankRow(
rows.filter((candidate, candidateIndex) => !isBlankRow(candidate) || candidateIndex === rows.length - 1),
);
const changed = JSON.stringify(nextRows) !== JSON.stringify(rows);
rows = nextRows;
if (changed) {
renderRows();
} else {
updateTotal();
}
schedulePersist();
};
valueInput.addEventListener('blur', handleBlur);
nameInput.addEventListener('blur', handleBlur);
rowEl.append(valueInput, nameInput);
rowsEl.appendChild(rowEl);
});
updateTotal();
}
window.__nanobotSetCardRefresh?.(script, () => {
pruneRows();
renderRows();
});
renderRows();
})();
</script>

View file

@ -235,6 +235,8 @@
letter-spacing: 0.005em; letter-spacing: 0.005em;
color: #624d40; color: #624d40;
opacity: 0.95; opacity: 0.95;
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: pointer; cursor: pointer;
touch-action: manipulation; touch-action: manipulation;
} }
@ -324,175 +326,43 @@
text-align: left; text-align: left;
} }
.task-card__editor-overlay { .task-card__title--editing,
display: none; .task-card__body--editing {
position: fixed; cursor: text;
inset: 0;
z-index: 10001;
padding:
max(18px, env(safe-area-inset-top))
max(16px, env(safe-area-inset-right))
max(18px, env(safe-area-inset-bottom))
max(16px, env(safe-area-inset-left));
background: rgba(38, 27, 21, 0.42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-sizing: border-box;
} }
.task-card__editor-sheet { .task-card__inline-editor {
display: block;
width: 100%; width: 100%;
max-width: 100%;
min-width: 0; min-width: 0;
height: 100%;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 14px;
border-radius: 22px;
border: 1px solid rgba(87, 65, 50, 0.16);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.74), transparent 28%),
linear-gradient(160deg, rgba(254, 246, 237, 0.985), rgba(240, 226, 210, 0.985));
color: var(--task-ink);
box-shadow:
0 22px 48px rgba(48, 32, 24, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.7);
padding: 18px 16px 16px;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; margin: 0;
} padding: 0;
.task-card__editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.task-card__editor-kicker {
font-size: 0.66rem;
line-height: 1.1;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 700;
color: var(--task-muted);
}
.task-card__editor-title {
margin-top: 3px;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 1.08rem;
line-height: 1.04;
font-weight: 700;
letter-spacing: -0.012em;
color: var(--task-ink);
min-width: 0;
overflow-wrap: anywhere;
}
.task-card__editor-close {
appearance: none;
border: 0; border: 0;
background: transparent;
color: var(--task-muted);
font: 700 0.86rem/1 'M-1m Code', var(--card-font, 'SF Mono', ui-monospace, Menlo, Consolas, monospace);
letter-spacing: 0.02em;
padding: 6px 4px;
cursor: pointer;
flex: 0 0 auto;
}
.task-card__editor-fields {
min-height: 0;
min-width: 0;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 12px;
}
.task-card__editor-group {
display: grid;
gap: 5px;
min-height: 0;
min-width: 0;
}
.task-card__editor-label {
font-size: 0.66rem;
line-height: 1.1;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 700;
color: var(--task-muted);
}
.task-card__editor-input,
.task-card__editor-textarea {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
border-radius: 16px;
border: 1px solid rgba(87, 65, 50, 0.14);
background: rgba(255, 251, 246, 0.92);
color: var(--task-ink);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
outline: none; outline: none;
}
.task-card__editor-input {
min-height: 52px;
padding: 12px 13px;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif;
font-size: 1.02rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.012em;
}
.task-card__editor-textarea {
min-height: 0;
height: 100%;
max-height: 100%;
padding: 12px 13px;
resize: none; resize: none;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; overflow: hidden;
font-size: 0.94rem; background: transparent;
line-height: 1.36; color: inherit;
font-weight: 400; font: inherit;
letter-spacing: 0.004em; line-height: inherit;
letter-spacing: inherit;
border-radius: 0;
box-shadow: none;
} }
.task-card__editor-input:focus, .task-card__inline-editor::placeholder {
.task-card__editor-textarea:focus { color: rgba(98, 77, 64, 0.6);
border-color: rgba(88, 112, 111, 0.48); opacity: 1;
box-shadow: font-style: italic;
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 0 0 3px rgba(88, 112, 111, 0.12);
} }
.task-card__editor-actions { .task-card__inline-editor--title {
display: flex; min-height: 1.2em;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
} }
.task-card__editor-action-row { .task-card__inline-editor--body {
display: flex; min-height: 1.34em;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.task-card__editor-action-row > .task-card__button {
flex: 0 0 auto;
} }
</style> </style>
@ -759,49 +629,7 @@
} }
}; };
const editorOverlayEl = doc.createElement('div'); let activeInlineEdit = null;
editorOverlayEl.className = 'task-card__editor-overlay';
editorOverlayEl.innerHTML = `
<div class="task-card__editor-sheet" role="dialog" aria-modal="true" aria-labelledby="task-editor-title">
<div class="task-card__editor-head">
<div>
<div class="task-card__editor-kicker">${laneLabels[lane] || 'Task'}</div>
<div id="task-editor-title" class="task-card__editor-title">Edit task</div>
</div>
<button type="button" class="task-card__editor-close" data-task-editor-close>Close</button>
</div>
<form class="task-card__editor-fields" data-task-editor-form>
<div class="task-card__editor-group">
<label class="task-card__editor-label" for="task-editor-title-input">Title</label>
<input id="task-editor-title-input" data-task-editor-title-input class="task-card__editor-input" type="text" maxlength="240" />
</div>
<div class="task-card__editor-group" style="min-height:0;">
<label class="task-card__editor-label" for="task-editor-body-input">Description</label>
<textarea id="task-editor-body-input" data-task-editor-body-input class="task-card__editor-textarea" placeholder="Add notes, context, or next steps"></textarea>
</div>
<div class="task-card__editor-actions">
<div class="task-card__editor-action-row">
<button type="button" class="task-card__button task-card__button--secondary" data-task-editor-cancel>Cancel</button>
<button type="submit" class="task-card__button" data-task-editor-save>Save</button>
</div>
</div>
</form>
</div>
`;
const editorFormEl = editorOverlayEl.querySelector('[data-task-editor-form]');
const editorTitleInputEl = editorOverlayEl.querySelector('[data-task-editor-title-input]');
const editorBodyInputEl = editorOverlayEl.querySelector('[data-task-editor-body-input]');
const editorCloseEl = editorOverlayEl.querySelector('[data-task-editor-close]');
const editorCancelEl = editorOverlayEl.querySelector('[data-task-editor-cancel]');
const editorSaveEl = editorOverlayEl.querySelector('[data-task-editor-save]');
if (
!(editorFormEl instanceof HTMLFormElement) ||
!(editorTitleInputEl instanceof HTMLInputElement) ||
!(editorBodyInputEl instanceof HTMLTextAreaElement) ||
!(editorCloseEl instanceof HTMLButtonElement) ||
!(editorCancelEl instanceof HTMLButtonElement) ||
!(editorSaveEl instanceof HTMLButtonElement)
) return;
const setBusy = (busy) => { const setBusy = (busy) => {
laneToggleEl.disabled = busy || !taskPath; laneToggleEl.disabled = busy || !taskPath;
@ -811,11 +639,11 @@
for (const button of tagsEl.querySelectorAll('button')) { for (const button of tagsEl.querySelectorAll('button')) {
if (button instanceof HTMLButtonElement) button.disabled = busy; if (button instanceof HTMLButtonElement) button.disabled = busy;
} }
editorTitleInputEl.disabled = busy; summaryEl.style.pointerEvents = busy ? 'none' : '';
editorBodyInputEl.disabled = busy; descriptionEl.style.pointerEvents = busy ? 'none' : '';
editorCloseEl.disabled = busy; if (activeInlineEdit?.input instanceof HTMLTextAreaElement) {
editorCancelEl.disabled = busy; activeInlineEdit.input.disabled = busy;
editorSaveEl.disabled = busy; }
}; };
const closeMoveMenu = () => { const closeMoveMenu = () => {
@ -858,26 +686,97 @@
window.dispatchEvent(new Event('nanobot:cards-refresh')); window.dispatchEvent(new Event('nanobot:cards-refresh'));
}; };
const closeEditor = () => { const autosizeInlineEditor = (editor, minHeight = 0) => {
editorOverlayEl.style.display = 'none'; editor.style.height = '0px';
const nextHeight = Math.max(Math.ceil(minHeight), editor.scrollHeight);
editor.style.height = `${Math.max(nextHeight, 20)}px`;
}; };
const openEditor = (focusField = 'title') => { const beginInlineEdit = (field) => {
if (!taskPath) return; if (!taskPath || activeInlineEdit) return;
closeMoveMenu(); closeMoveMenu();
editorTitleInputEl.value = title; const host = field === 'title' ? summaryEl : descriptionEl;
editorBodyInputEl.value = body; const currentValue = field === 'title' ? title : body;
if (editorOverlayEl.parentElement !== doc.body) { const editor = document.createElement('textarea');
doc.body.appendChild(editorOverlayEl); const minHeight = host.getBoundingClientRect().height;
editor.className = `task-card__inline-editor ${
field === 'title' ? 'task-card__inline-editor--title' : 'task-card__inline-editor--body'
}`;
editor.rows = 1;
editor.value = currentValue;
editor.placeholder = field === 'description' ? 'Add description' : '';
editor.setAttribute('aria-label', field === 'title' ? 'Edit task title' : 'Edit task description');
host.textContent = '';
host.classList.remove('task-card__body--placeholder');
host.classList.add(field === 'title' ? 'task-card__title--editing' : 'task-card__body--editing');
host.appendChild(editor);
autosizeInlineEditor(editor, minHeight);
const cancel = () => {
if (activeInlineEdit?.input !== editor) return;
activeInlineEdit = null;
render();
};
const commit = async () => {
if (activeInlineEdit?.input !== editor) return;
const nextValue = editor.value.trim();
if (field === 'title' && !nextValue) {
editor.focus();
return;
} }
editorOverlayEl.style.display = 'block'; activeInlineEdit = null;
if (nextValue === currentValue) {
render();
return;
}
const ok = await runTaskEdit(field === 'title' ? { title: nextValue } : { description: nextValue });
if (!ok) render();
};
activeInlineEdit = {
field,
input: editor,
cancel,
commit,
};
editor.addEventListener('input', () => {
autosizeInlineEditor(editor, minHeight);
});
editor.addEventListener('click', (event) => {
event.stopPropagation();
});
editor.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancel();
return;
}
if (field === 'title' && event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
editor.blur();
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
editor.blur();
}
});
editor.addEventListener('blur', () => {
if (activeInlineEdit?.input !== editor) return;
void commit();
});
view.requestAnimationFrame(() => { view.requestAnimationFrame(() => {
const target = focusField === 'description' ? editorBodyInputEl : editorTitleInputEl; editor.focus();
target.focus(); const end = editor.value.length;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { editor.setSelectionRange(end, end);
const end = target.value.length;
target.setSelectionRange(end, end);
}
}); });
}; };
@ -958,13 +857,14 @@
if (payload && typeof payload === 'object' && payload.error) { if (payload && typeof payload === 'object' && payload.error) {
throw new Error(String(payload.error)); throw new Error(String(payload.error));
} }
closeEditor();
refreshCards(); refreshCards();
return true;
} catch (error) { } catch (error) {
console.error('Task edit failed', error); console.error('Task edit failed', error);
setBusy(false); setBusy(false);
setStatus('Unavailable', '#8e3023', '#f3d3cc'); setStatus('Unavailable', '#8e3023', '#f3d3cc');
publishLiveContent(lane, true, String(error)); publishLiveContent(lane, true, String(error));
return false;
} }
}; };
@ -1094,48 +994,16 @@
doc.body.appendChild(moveMenuEl); doc.body.appendChild(moveMenuEl);
} }
editorCloseEl.addEventListener('click', () => {
closeEditor();
});
editorCancelEl.addEventListener('click', () => {
closeEditor();
});
editorOverlayEl.addEventListener('pointerdown', (event) => {
if (event.target === editorOverlayEl) {
closeEditor();
}
});
editorFormEl.addEventListener('submit', (event) => {
event.preventDefault();
const nextTitle = editorTitleInputEl.value.trim();
const nextDescription = editorBodyInputEl.value.trim();
if (!nextTitle) {
editorTitleInputEl.focus();
return;
}
if (nextTitle === title && nextDescription === body) {
closeEditor();
return;
}
void runTaskEdit({
title: nextTitle,
description: nextDescription,
});
});
summaryEl.addEventListener('click', (event) => { summaryEl.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openEditor('title'); beginInlineEdit('title');
}); });
descriptionEl.addEventListener('click', (event) => { descriptionEl.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openEditor('description'); beginInlineEdit('description');
}); });
const render = () => { const render = () => {

View file

@ -3,12 +3,14 @@
"title": "Live Weather", "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.", "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": { "example_state": {
"subtitle": "OpenWeatherMap live context", "subtitle": "Weather",
"tool_name": "mcp_home_assistant_GetLiveContext", "tool_name": "mcp_home_assistant_GetLiveContext",
"forecast_tool_name": "exec",
"forecast_command": "python3 /home/kacper/nanobot/scripts/card_upcoming_conditions.py --nws-entity weather.korh --uv-entity weather.openweathermap_2 --forecast-type hourly --limit 4",
"provider_prefix": "OpenWeatherMap", "provider_prefix": "OpenWeatherMap",
"temperature_name": "OpenWeatherMap Temperature", "temperature_name": "OpenWeatherMap Temperature",
"humidity_name": "OpenWeatherMap Humidity", "humidity_name": "OpenWeatherMap Humidity",
"condition_label": "OpenWeatherMap live context", "condition_label": "Weather",
"refresh_ms": 86400000 "refresh_ms": 86400000
}, },
"created_at": "2026-03-11T04:12:48.601255+00:00", "created_at": "2026-03-11T04:12:48.601255+00:00",

View file

@ -1,4 +1,13 @@
<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 data-weather-card style="font-family: var(--card-font, 'Iosevka', 'SF Mono', ui-monospace, Menlo, Consolas, monospace); background:#ffffff; color:#111827; padding:14px 16px;">
<style>
@font-face {
font-family: 'BlexMono Nerd Font Mono';
src: url('/card-templates/upcoming-conditions-live/assets/BlexMonoNerdFontMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:8px;"> <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> <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> <span data-weather-status style="font-size:0.8rem; line-height:1.2; font-weight:700; color:#6b7280; white-space:nowrap;">Loading…</span>
@ -9,24 +18,22 @@
<span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span> <span data-weather-unit style="font-size:1.05rem; font-weight:700; color:#4b5563; padding-bottom:0.28rem;">°F</span>
</div> </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 style="display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px 12px;">
<div> <div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Humidity</div> <div title="Humidity" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;">󰖎</div>
<div data-weather-humidity style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</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> <div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Wind</div> <div title="Wind" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-wind style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</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> <div>
<div style="font-size:0.74rem; line-height:1.2; text-transform:uppercase; letter-spacing:0.04em; color:#6b7280;">Pressure</div> <div title="Rain" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-pressure style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div> <div data-weather-rain style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
</div> </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 title="UV" style="font-family:'BlexMono Nerd Font Mono', monospace; font-size:0.86rem; line-height:1.2; color:#6b7280;"></div>
<div data-weather-updated style="margin-top:2px; font-size:0.94rem; line-height:1.25; font-weight:700; color:#374151;">--</div> <div data-weather-uv style="margin-top:2px; font-size:1rem; line-height:1.25; font-weight:700; color:#111827;">--</div>
</div> </div>
</div> </div>
</div> </div>
@ -40,22 +47,20 @@
const subtitleEl = root.querySelector('[data-weather-subtitle]'); const subtitleEl = root.querySelector('[data-weather-subtitle]');
const tempEl = root.querySelector('[data-weather-temp]'); const tempEl = root.querySelector('[data-weather-temp]');
const unitEl = root.querySelector('[data-weather-unit]'); const unitEl = root.querySelector('[data-weather-unit]');
const condEl = root.querySelector('[data-weather-condition]');
const humidityEl = root.querySelector('[data-weather-humidity]'); const humidityEl = root.querySelector('[data-weather-humidity]');
const windEl = root.querySelector('[data-weather-wind]'); const windEl = root.querySelector('[data-weather-wind]');
const pressureEl = root.querySelector('[data-weather-pressure]'); const rainEl = root.querySelector('[data-weather-rain]');
const updatedEl = root.querySelector('[data-weather-updated]'); const uvEl = root.querySelector('[data-weather-uv]');
const statusEl = root.querySelector('[data-weather-status]'); 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; if (!(subtitleEl instanceof HTMLElement) || !(tempEl instanceof HTMLElement) || !(unitEl instanceof HTMLElement) || !(humidityEl instanceof HTMLElement) || !(windEl instanceof HTMLElement) || !(rainEl instanceof HTMLElement) || !(uvEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement)) return;
const subtitle = typeof state.subtitle === 'string' ? state.subtitle : ''; const subtitle = typeof state.subtitle === 'string' ? state.subtitle : '';
const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : ''; const configuredToolName = typeof state.tool_name === 'string' ? state.tool_name.trim() : '';
const configuredForecastToolName = typeof state.forecast_tool_name === 'string' ? state.forecast_tool_name.trim() : 'exec';
const forecastCommand = typeof state.forecast_command === 'string' ? state.forecast_command.trim() : '';
const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : ''; const providerPrefix = typeof state.provider_prefix === 'string' ? state.provider_prefix.trim() : '';
const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : ''; const temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : '';
const humidityName = typeof state.humidity_name === 'string' ? state.humidity_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 refreshMsRaw = Number(state.refresh_ms);
const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000; const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000;
@ -162,6 +167,18 @@
return ''; return '';
}; };
const stripExecFooter = (value) => String(value || '').replace(/\n+\s*Exit code:\s*\d+\s*$/i, '').trim();
const extractExecJson = (toolResult) => {
const parsedText = stripExecFooter(toolResult?.content);
if (!parsedText) return null;
try {
return JSON.parse(parsedText);
} catch {
return null;
}
};
const resolveToolName = async () => { const resolveToolName = async () => {
if (configuredToolName) return configuredToolName; if (configuredToolName) return configuredToolName;
if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext'; if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext';
@ -187,24 +204,41 @@
}) || null; }) || null;
}; };
const resolveForecastBundle = async () => {
if (!forecastCommand) return null;
const toolResult = await window.__nanobotCallTool?.(configuredForecastToolName || 'exec', {
command: forecastCommand,
max_output_chars: 200000,
});
const payload = extractExecJson(toolResult);
return payload && typeof payload === 'object' ? payload : null;
};
const firstForecastEntry = (bundle, key, metricKey = '') => {
const source = bundle && typeof bundle === 'object' ? bundle[key] : null;
const forecast = source && typeof source === 'object' && Array.isArray(source.forecast) ? source.forecast : [];
if (!metricKey) {
return forecast.length > 0 && forecast[0] && typeof forecast[0] === 'object' ? forecast[0] : null;
}
return forecast.find((entry) => entry && typeof entry === 'object' && entry[metricKey] !== null && entry[metricKey] !== undefined) || null;
};
const refresh = async () => { const refresh = async () => {
const resolvedToolName = await resolveToolName(); const resolvedToolName = await resolveToolName();
if (!resolvedToolName) { if (!resolvedToolName) {
const errorText = 'Missing tool_name'; const errorText = 'Missing tool_name';
setStatus('No tool', '#b91c1c'); setStatus('No tool', '#b91c1c');
updatedEl.textContent = errorText;
updateLiveContent({ updateLiveContent({
kind: 'weather', kind: 'weather',
subtitle: subtitleEl.textContent || null, subtitle: subtitleEl.textContent || null,
tool_name: null, tool_name: null,
temperature: null, temperature: null,
temperature_unit: String(state.unit || '°F'), temperature_unit: String(state.unit || '°F'),
condition: null,
humidity: null, humidity: null,
wind: null, wind: null,
pressure: null, rain: null,
uv: null,
status: 'No tool', status: 'No tool',
updated_at: errorText,
error: errorText, error: errorText,
}); });
return; return;
@ -212,7 +246,10 @@
setStatus('Refreshing', '#6b7280'); setStatus('Refreshing', '#6b7280');
try { try {
const toolResult = await window.__nanobotCallTool?.(resolvedToolName, {}); const [toolResult, forecastBundle] = await Promise.all([
window.__nanobotCallTool?.(resolvedToolName, {}),
resolveForecastBundle(),
]);
const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor'); const entries = parseLiveContextEntries(extractLiveContextText(toolResult)).filter((entry) => normalizeText(entry.domain) === 'sensor');
const prefix = providerPrefix || 'OpenWeatherMap'; const prefix = providerPrefix || 'OpenWeatherMap';
const temperatureEntry = findEntry(entries, [ const temperatureEntry = findEntry(entries, [
@ -223,32 +260,29 @@
humidityName, humidityName,
`${prefix} Humidity`, `${prefix} Humidity`,
]); ]);
const pressureEntry = findEntry(entries, [
pressureName,
`${prefix} Pressure`,
]);
const windEntry = findEntry(entries, [
windName,
`${prefix} Wind speed`,
`${prefix} Wind`,
]);
const temperature = Number(temperatureEntry?.state); const temperature = Number(temperatureEntry?.state);
tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--'; tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--';
unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'); unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F');
condEl.textContent = conditionLabel || `${prefix || 'Weather'} live context`;
const humidity = Number(humidityEntry?.state); const humidity = Number(humidityEntry?.state);
humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--'; humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--';
const windSpeed = Number(windEntry?.state);
const windUnit = String(windEntry?.attributes?.unit_of_measurement || 'mph'); const nwsEntry = firstForecastEntry(forecastBundle, 'nws');
windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : '--'; const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index');
const pressure = Number(pressureEntry?.state); const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null;
pressureEl.textContent = Number.isFinite(pressure)
? `${pressure} ${String(pressureEntry?.attributes?.unit_of_measurement || '').trim()}`.trim() const windSpeed = Number(nwsEntry?.wind_speed);
: '--'; const windUnit = String(nwsSource?.wind_speed_unit || 'mph');
const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--';
updatedEl.textContent = updatedText;
subtitleEl.textContent = subtitle || prefix || 'Home Assistant live context'; const rainChance = Number(nwsEntry?.precipitation_probability);
rainEl.textContent = Number.isFinite(rainChance) ? `${Math.round(rainChance)}%` : '--';
const uvValue = Number(uvEntry?.uv_index);
uvEl.textContent = Number.isFinite(uvValue) ? String(Math.round(uvValue)) : '--';
subtitleEl.textContent = subtitle || prefix || 'Weather';
setStatus('Live', '#047857'); setStatus('Live', '#047857');
updateLiveContent({ updateLiveContent({
kind: 'weather', kind: 'weather',
@ -256,29 +290,32 @@
tool_name: resolvedToolName, tool_name: resolvedToolName,
temperature: Number.isFinite(temperature) ? Math.round(temperature) : null, temperature: Number.isFinite(temperature) ? Math.round(temperature) : null,
temperature_unit: unitEl.textContent || null, temperature_unit: unitEl.textContent || null,
condition: condEl.textContent || null,
humidity: Number.isFinite(humidity) ? Math.round(humidity) : null, humidity: Number.isFinite(humidity) ? Math.round(humidity) : null,
wind: windEl.textContent || null, wind: windEl.textContent || null,
pressure: pressureEl.textContent || null, rain: rainEl.textContent || null,
uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null,
status: 'Live', status: 'Live',
updated_at: updatedText,
}); });
} catch (error) { } catch (error) {
const errorText = String(error); const errorText = String(error);
setStatus('Unavailable', '#b91c1c'); setStatus('Unavailable', '#b91c1c');
updatedEl.textContent = errorText; tempEl.textContent = '--';
unitEl.textContent = String(state.unit || '°F');
humidityEl.textContent = '--';
windEl.textContent = '--';
rainEl.textContent = '--';
uvEl.textContent = '--';
updateLiveContent({ updateLiveContent({
kind: 'weather', kind: 'weather',
subtitle: subtitleEl.textContent || null, subtitle: subtitleEl.textContent || null,
tool_name: resolvedToolName, tool_name: resolvedToolName,
temperature: null, temperature: null,
temperature_unit: unitEl.textContent || null, temperature_unit: unitEl.textContent || null,
condition: null,
humidity: null, humidity: null,
wind: null, wind: null,
pressure: null, rain: null,
uv: null,
status: 'Unavailable', status: 'Unavailable',
updated_at: errorText,
error: errorText, error: errorText,
}); });
} }

View file

@ -438,9 +438,11 @@ export function App() {
const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({ const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({
connected: rtc.connected && !rtc.textOnly, connected: rtc.connected && !rtc.textOnly,
currentAgentState: rtc.agentState,
onSendPtt: (pressed) => onSendPtt: (pressed) =>
rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }), rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }),
onBootstrap: rtc.connect, onBootstrap: rtc.connect,
onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }),
}); });
const effectiveAgentState = agentStateOverride ?? rtc.agentState; const effectiveAgentState = agentStateOverride ?? rtc.agentState;

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,10 @@ const MOVE_CANCEL_PX = 16;
interface UsePTTOptions { interface UsePTTOptions {
connected: boolean; connected: boolean;
currentAgentState: AgentState;
onSendPtt(pressed: boolean): void; onSendPtt(pressed: boolean): void;
onBootstrap(): Promise<void>; onBootstrap(): Promise<void>;
onInterrupt?(): void;
onTap?(): void; // called on a short press (< HOLD_MS) that didn't activate PTT onTap?(): void; // called on a short press (< HOLD_MS) that didn't activate PTT
} }
@ -23,23 +25,31 @@ function dispatchMicEnable(enabled: boolean): void {
} }
/** Manages push-to-talk pointer events and mic enable/disable. */ /** Manages push-to-talk pointer events and mic enable/disable. */
export function usePTT({ connected, onSendPtt, onBootstrap, onTap }: UsePTTOptions): PTTState { export function usePTT({
connected,
currentAgentState,
onSendPtt,
onBootstrap,
onInterrupt,
onTap,
}: UsePTTOptions): PTTState {
const [agentStateOverride, setAgentStateOverride] = useState<AgentState | null>(null); const [agentStateOverride, setAgentStateOverride] = useState<AgentState | null>(null);
const activePointers = useRef(new Set<number>()); const activePointers = useRef(new Set<number>());
const appStartedRef = useRef(false); const appStartedRef = useRef(false);
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pttFiredRef = useRef(false); const pttFiredRef = useRef(false);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null); const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
const beginPTT = useCallback(() => { const beginPTT = useCallback(() => {
if (!connected) return; if (!connected) return;
if (agentStateOverride === "listening") return; if (agentStateOverride === "listening") return;
if (currentAgentState === "thinking" || currentAgentState === "speaking") {
onInterrupt?.();
}
pttFiredRef.current = true; pttFiredRef.current = true;
setAgentStateOverride("listening"); setAgentStateOverride("listening");
dispatchMicEnable(true); dispatchMicEnable(true);
onSendPtt(true); onSendPtt(true);
}, [connected, agentStateOverride, onSendPtt]); }, [connected, agentStateOverride, currentAgentState, onInterrupt, onSendPtt]);
const endPTT = useCallback(() => { const endPTT = useCallback(() => {
if (agentStateOverride !== "listening") return; if (agentStateOverride !== "listening") return;
setAgentStateOverride(null); setAgentStateOverride(null);
@ -62,11 +72,8 @@ export function usePTT({ connected, onSendPtt, onBootstrap, onTap }: UsePTTOptio
await onBootstrap(); await onBootstrap();
} }
if (activePointers.current.size !== 1) return; if (activePointers.current.size !== 1) return;
pttFiredRef.current = false; pttFiredRef.current = false;
pointerStartRef.current = { x: pe.clientX, y: pe.clientY }; pointerStartRef.current = { x: pe.clientX, y: pe.clientY };
// Delay activation slightly so horizontal swipe gestures can cancel.
holdTimerRef.current = setTimeout(beginPTT, HOLD_MS); holdTimerRef.current = setTimeout(beginPTT, HOLD_MS);
}, },
[onBootstrap, beginPTT], [onBootstrap, beginPTT],
@ -95,18 +102,14 @@ export function usePTT({ connected, onSendPtt, onBootstrap, onTap }: UsePTTOptio
if (!activePointers.current.has(pe.pointerId)) return; if (!activePointers.current.has(pe.pointerId)) return;
activePointers.current.delete(pe.pointerId); activePointers.current.delete(pe.pointerId);
if (activePointers.current.size !== 0) return; if (activePointers.current.size !== 0) return;
// Cancel hold timer if it hasn't fired yet
if (holdTimerRef.current !== null) { if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current); clearTimeout(holdTimerRef.current);
holdTimerRef.current = null; holdTimerRef.current = null;
} }
pointerStartRef.current = null; pointerStartRef.current = null;
if (pttFiredRef.current) { if (pttFiredRef.current) {
endPTT(); endPTT();
} else { } else {
// PTT never fired → short tap.
onTap?.(); onTap?.();
} }
}, },

View file

@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type { import type {
AgentState, AgentState,
CardItem, CardItem,
CardLane,
CardMessageMetadata, CardMessageMetadata,
CardState, CardState,
ClientMessage, ClientMessage,
@ -18,13 +17,6 @@ const CARD_LIVE_CONTENT_EVENT = "nanobot:card-live-content-change";
let cardIdCounter = 0; let cardIdCounter = 0;
let logIdCounter = 0; let logIdCounter = 0;
const LANE_RANK: Record<CardLane, number> = {
attention: 0,
work: 1,
context: 2,
history: 3,
};
const STATE_RANK: Record<CardState, number> = { const STATE_RANK: Record<CardState, number> = {
active: 0, active: 0,
stale: 1, stale: 1,
@ -96,8 +88,6 @@ function compareCards(a: CardItem, b: CardItem): number {
if (stateDiff !== 0) return stateDiff; if (stateDiff !== 0) return stateDiff;
const scoreDiff = readCardScore(b) - readCardScore(a); const scoreDiff = readCardScore(b) - readCardScore(a);
if (scoreDiff !== 0) return scoreDiff; if (scoreDiff !== 0) return scoreDiff;
const laneDiff = LANE_RANK[a.lane] - LANE_RANK[b.lane];
if (laneDiff !== 0) return laneDiff;
if (a.priority !== b.priority) return b.priority - a.priority; if (a.priority !== b.priority) return b.priority - a.priority;
const updatedDiff = b.updatedAt.localeCompare(a.updatedAt); const updatedDiff = b.updatedAt.localeCompare(a.updatedAt);
if (updatedDiff !== 0) return updatedDiff; if (updatedDiff !== 0) return updatedDiff;
@ -131,6 +121,8 @@ function toCardItem(msg: Extract<ServerMessage, { type: "card" }>): Omit<CardIte
priority: msg.priority, priority: msg.priority,
state: msg.state, state: msg.state,
templateKey: msg.template_key || undefined, templateKey: msg.template_key || undefined,
templateState:
msg.template_state && typeof msg.template_state === "object" ? msg.template_state : undefined,
contextSummary: msg.context_summary || undefined, contextSummary: msg.context_summary || undefined,
createdAt: msg.created_at || new Date().toISOString(), createdAt: msg.created_at || new Date().toISOString(),
updatedAt: msg.updated_at || new Date().toISOString(), updatedAt: msg.updated_at || new Date().toISOString(),

View file

@ -4,8 +4,57 @@
-webkit-user-select: none; -webkit-user-select: none;
} }
@font-face {
font-family: "M-1m Code";
src: url("/card-templates/todo-item-live/assets/mplus-1m-regular-sub.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "M-1m Code";
src: url("/card-templates/todo-item-live/assets/mplus-1m-bold-sub.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-400.ttf")
format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-600.ttf")
format("truetype");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("/card-templates/todo-item-live/assets/ibm-plex-sans-condensed-700.ttf")
format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root { :root {
--card-font: "Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace; --card-font: "Iosevka", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
--feed-surface: #e7ddd0;
--card-surface: linear-gradient(180deg, #b56c3d 0%, #8f4f27 100%);
--card-border: rgba(255, 220, 188, 0.24);
--card-shadow: 0 10px 28px rgba(68, 34, 15, 0.22);
--card-text: rgba(255, 245, 235, 0.9);
--card-muted: rgba(255, 233, 214, 0.72);
} }
html { html {
@ -638,8 +687,8 @@ body {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(255, 200, 140, 0.25) transparent; scrollbar-color: rgba(255, 200, 140, 0.25) transparent;
background: #ece8e1; background: var(--feed-surface);
box-shadow: inset 0 1px 0 rgba(52, 40, 31, 0.24); box-shadow: inset 0 1px 0 rgba(86, 53, 31, 0.16);
} }
#card-feed::-webkit-scrollbar { #card-feed::-webkit-scrollbar {
width: 4px; width: 4px;
@ -663,7 +712,7 @@ body {
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: rgba(150, 110, 68, 0.82); color: rgba(128, 78, 44, 0.82);
} }
.card-group-list { .card-group-list {
display: flex; display: flex;
@ -672,25 +721,25 @@ body {
} }
.card { .card {
pointer-events: auto; pointer-events: auto;
background: rgba(28, 22, 16, 0.92); background: var(--card-surface);
border: 1px solid rgba(255, 200, 140, 0.18); border: 1px solid var(--card-border);
border-radius: 12px; border-radius: 12px;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45); box-shadow: var(--card-shadow);
animation: card-in 0.22s cubic-bezier(0.34, 1.4, 0.64, 1) both; animation: card-in 0.22s cubic-bezier(0.34, 1.4, 0.64, 1) both;
position: relative; position: relative;
max-width: 100%; max-width: 100%;
flex-shrink: 0; flex-shrink: 0;
} }
.card.kind-text { .card.kind-text {
background: transparent; background: var(--card-surface);
border: none; border: 1px solid var(--card-border);
border-radius: 0; border-radius: 0;
padding: 0; padding: 0;
box-shadow: none; box-shadow: var(--card-shadow);
} }
.card.dismissing { .card.dismissing {
animation: card-out 0.18s ease-in both; animation: card-out 0.18s ease-in both;
@ -757,7 +806,7 @@ body {
font-size: 0.6875rem; font-size: 0.6875rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.07em; letter-spacing: 0.07em;
color: rgba(255, 200, 140, 0.92); color: rgba(255, 230, 208, 0.94);
text-transform: uppercase; text-transform: uppercase;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -778,8 +827,8 @@ body {
text-transform: uppercase; text-transform: uppercase;
} }
.card-state { .card-state {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 241, 229, 0.14);
color: rgba(255, 245, 235, 0.66); color: var(--card-muted);
} }
.card-menu-wrap { .card-menu-wrap {
position: relative; position: relative;
@ -788,10 +837,10 @@ body {
.card-menu-trigger { .card-menu-trigger {
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 1px solid rgba(255, 200, 140, 0.18); border: 1px solid rgba(255, 223, 198, 0.2);
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 241, 229, 0.08);
color: rgba(255, 245, 235, 0.58); color: rgba(255, 241, 229, 0.7);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -802,10 +851,10 @@ body {
border-color 0.15s ease; border-color 0.15s ease;
} }
.card.kind-text .card-menu-trigger { .card.kind-text .card-menu-trigger {
border-color: rgba(40, 26, 16, 0.1); border-color: rgba(255, 223, 198, 0.2);
background: rgba(255, 255, 255, 0.82); background: rgba(255, 241, 229, 0.12);
color: rgba(40, 26, 16, 0.72); color: rgba(255, 245, 235, 0.82);
box-shadow: 0 4px 12px rgba(24, 16, 10, 0.12); box-shadow: 0 4px 12px rgba(59, 31, 15, 0.18);
} }
.card-menu-trigger svg { .card-menu-trigger svg {
width: 14px; width: 14px;
@ -814,15 +863,15 @@ body {
} }
.card-menu-trigger:hover, .card-menu-trigger:hover,
.card-menu-trigger.open { .card-menu-trigger.open {
color: rgba(255, 245, 235, 0.9); color: rgba(255, 247, 239, 0.96);
background: rgba(255, 200, 140, 0.12); background: rgba(255, 223, 198, 0.16);
border-color: rgba(255, 200, 140, 0.38); border-color: rgba(255, 223, 198, 0.36);
} }
.card.kind-text .card-menu-trigger:hover, .card.kind-text .card-menu-trigger:hover,
.card.kind-text .card-menu-trigger.open { .card.kind-text .card-menu-trigger.open {
color: rgba(20, 10, 0, 0.92); color: rgba(255, 247, 239, 0.96);
background: rgba(255, 255, 255, 0.96); background: rgba(255, 223, 198, 0.18);
border-color: rgba(40, 26, 16, 0.18); border-color: rgba(255, 223, 198, 0.36);
} }
.card-menu { .card-menu {
position: absolute; position: absolute;
@ -834,8 +883,8 @@ body {
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
border-radius: 12px; border-radius: 12px;
background: rgba(20, 14, 10, 0.98); background: rgba(86, 47, 23, 0.98);
border: 1px solid rgba(255, 200, 140, 0.16); border: 1px solid rgba(255, 223, 198, 0.18);
box-shadow: box-shadow:
0 18px 36px rgba(0, 0, 0, 0.42), 0 18px 36px rgba(0, 0, 0, 0.42),
0 4px 10px rgba(0, 0, 0, 0.24); 0 4px 10px rgba(0, 0, 0, 0.24);
@ -844,7 +893,7 @@ body {
.card-menu-item { .card-menu-item {
border: none; border: none;
background: transparent; background: transparent;
color: rgba(255, 245, 235, 0.86); color: var(--card-text);
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; padding: 8px 10px;
text-align: left; text-align: left;
@ -854,7 +903,7 @@ body {
cursor: pointer; cursor: pointer;
} }
.card-menu-item:hover { .card-menu-item:hover {
background: rgba(255, 200, 140, 0.12); background: rgba(255, 223, 198, 0.12);
} }
.card-menu-item.danger { .card-menu-item.danger {
color: rgba(255, 177, 161, 0.92); color: rgba(255, 177, 161, 0.92);
@ -869,7 +918,7 @@ body {
font-family: var(--card-font); font-family: var(--card-font);
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.65; line-height: 1.65;
color: rgba(255, 245, 235, 0.82); color: var(--card-text);
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
user-select: text; user-select: text;
@ -878,17 +927,416 @@ body {
.card.kind-text .card-body { .card.kind-text .card-body {
color: inherit; color: inherit;
} }
.card.kind-text .card-body > [data-nanobot-card-root] {
display: block;
background: transparent;
color: inherit;
}
.card.kind-text .card-body > [data-nanobot-card-root] > :not(script) { .card.kind-text .card-body > [data-nanobot-card-root] > :not(script) {
border-radius: 0; border-radius: 0;
overflow: hidden; overflow: hidden;
box-shadow: none; box-shadow: none;
} }
.task-card-ui {
--task-accent: #58706f;
--task-accent-soft: rgba(88, 112, 111, 0.12);
--task-ink: #2f241e;
--task-muted: #7e6659;
--task-surface: rgba(255, 248, 239, 0.92);
--task-border: rgba(87, 65, 50, 0.14);
--task-button-ink: #214240;
position: relative;
overflow: visible;
border-radius: 0;
border: 1px solid var(--task-border);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 32%),
linear-gradient(145deg, rgba(253, 245, 235, 0.98), rgba(242, 227, 211, 0.97));
color: var(--task-ink);
font-family: "M-1m Code", var(--card-font);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.68),
0 18px 36px rgba(79, 56, 43, 0.12);
}
.task-card-ui::before {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(
135deg,
rgba(122, 97, 78, 0.035) 0,
rgba(122, 97, 78, 0.035) 2px,
transparent 2px,
transparent 10px
);
pointer-events: none;
opacity: 0.55;
}
.task-card-ui__inner {
position: relative;
display: grid;
gap: 10px;
padding: 15px 44px 13px 14px;
}
.task-card-ui__topline {
position: relative;
z-index: 6;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.task-card-ui__lane-wrap {
position: relative;
min-width: 0;
}
.task-card-ui__lane-button {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.task-card-ui__lane-button:disabled {
cursor: default;
opacity: 0.6;
}
.task-card-ui__lane {
min-width: 0;
font-size: 0.64rem;
line-height: 1.1;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--task-muted);
font-weight: 700;
white-space: nowrap;
}
.task-card-ui__lane-caret {
flex: 0 0 auto;
font-size: 0.66rem;
line-height: 1;
color: var(--task-muted);
transform: translateY(-1px);
transition: transform 0.18s ease;
}
.task-card-ui__lane-caret.open {
transform: translateY(-1px) rotate(180deg);
}
.task-card-ui__lane-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 12;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 150px;
padding: 6px;
border-radius: 14px;
background: rgba(255, 248, 239, 0.96);
box-shadow:
0 10px 24px rgba(79, 56, 43, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.task-card-ui__lane-menu-item {
appearance: none;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 10px;
padding: 8px 10px;
background: rgba(255, 248, 239, 0.78);
color: var(--task-button-ink);
font:
700 0.7rem / 1 "M-1m Code",
var(--card-font);
cursor: pointer;
text-align: left;
}
.task-card-ui__lane-menu-item:disabled {
cursor: default;
opacity: 0.6;
}
.task-card-ui__status {
display: inline-flex;
align-items: center;
justify-content: flex-end;
white-space: nowrap;
padding: 0;
font-size: 0.72rem;
line-height: 1;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--task-muted);
}
.task-card-ui__status.is-error {
color: #8e3023;
}
.task-card-ui__text-button {
appearance: none;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
font: inherit;
text-align: left;
color: inherit;
cursor: pointer;
}
.task-card-ui__text-button:disabled {
cursor: default;
}
.task-card-ui__title {
font-family: "IBM Plex Sans Condensed", "Arial Narrow", sans-serif;
font-size: 0.96rem;
line-height: 1.06;
font-weight: 700;
letter-spacing: -0.008em;
color: var(--task-ink);
text-wrap: balance;
word-break: break-word;
}
.task-card-ui__tags {
display: flex;
flex-wrap: nowrap;
gap: 6px;
position: relative;
z-index: 1;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
}
.task-card-ui__tags::-webkit-scrollbar {
display: none;
}
.task-card-ui__tag {
appearance: none;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
min-height: 24px;
border-radius: 999px;
padding: 4px 9px;
background: var(--task-accent-soft);
color: var(--task-button-ink);
font-family: "M-1m Code", var(--card-font);
font-size: 0.71rem;
line-height: 1;
font-weight: 700;
border: 1px solid rgba(0, 0, 0, 0.035);
white-space: nowrap;
cursor: default;
}
.task-card-ui__tag:disabled {
cursor: default;
opacity: 0.6;
}
.task-card-ui__tag.is-holding {
background: rgba(165, 95, 75, 0.18);
color: #7b2f20;
}
.task-card-ui__tag--action {
border-style: dashed;
background: rgba(255, 248, 239, 0.74);
cursor: pointer;
}
.task-card-ui__body {
font-family: "IBM Plex Sans Condensed", "Arial Narrow", sans-serif;
font-size: 0.86rem;
line-height: 1.34;
font-weight: 400;
letter-spacing: 0.005em;
color: #624d40;
opacity: 0.95;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.task-card-ui__body.is-placeholder {
opacity: 0.62;
font-style: italic;
}
.task-card-ui__body-markdown {
display: block;
}
.task-card-ui__body-markdown-inner {
display: block;
}
.task-card-ui__md-line {
display: block;
}
.task-card-ui__md-line + .task-card-ui__md-line {
margin-top: 0.06rem;
}
.task-card-ui__md-line--heading {
font-weight: 700;
color: #503d31;
}
.task-card-ui__md-line--quote {
padding-left: 0.55rem;
border-left: 2px solid rgba(95, 120, 132, 0.3);
}
.task-card-ui__md-prefix {
color: var(--task-muted);
}
.task-card-ui__md-break {
display: block;
height: 0.18rem;
}
.task-card-ui__body-markdown code {
font-family: "M-1m Code", var(--card-font);
font-size: 0.78em;
}
.task-card-ui__body-markdown a {
color: inherit;
}
.task-card-ui__editor {
display: block;
width: 100%;
min-width: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
outline: none;
resize: none;
overflow: hidden;
background: transparent;
color: inherit;
border-radius: 0;
box-shadow: none;
}
.task-card-ui__editor::placeholder {
color: rgba(98, 77, 64, 0.6);
opacity: 1;
font-style: italic;
}
.task-card-ui__meta {
display: flex;
flex-wrap: wrap;
gap: 0;
width: 100%;
}
.task-card-ui__chip {
display: flex;
align-items: center;
width: 100%;
min-height: 0;
border-radius: 0;
padding: 0;
background: transparent;
color: #4c3b30;
font-family: "M-1m Code", var(--card-font);
font-size: 0.63rem;
line-height: 1;
font-weight: 700;
}
.list-total-card-ui {
display: grid;
gap: 10px;
padding: 15px 44px 13px 14px;
background:
radial-gradient(circle at top right, rgba(255, 252, 233, 0.68), transparent 34%),
linear-gradient(145deg, rgba(244, 226, 187, 0.98), rgba(226, 198, 145, 0.97));
box-shadow: inset 0 1px 0 rgba(255, 250, 224, 0.62);
color: #4d392d;
}
.list-total-card-ui__labels,
.list-total-card-ui__row,
.list-total-card-ui__total {
display: grid;
grid-template-columns: 68px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.list-total-card-ui__labels {
color: rgba(77, 57, 45, 0.72);
font:
700 0.62rem / 1 "M-1m Code",
var(--card-font);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card-ui__rows {
display: grid;
gap: 6px;
}
.list-total-card-ui__input {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 0;
border-bottom: 1px solid rgba(92, 70, 55, 0.14);
border-radius: 0;
background: transparent;
color: #473429;
padding: 5px 0 4px;
outline: none;
box-shadow: none;
}
.list-total-card-ui__input:focus {
border-bottom-color: rgba(92, 70, 55, 0.34);
}
.list-total-card-ui__input::placeholder {
color: rgba(77, 57, 45, 0.42);
}
.list-total-card-ui__value {
font:
700 0.84rem / 1 "M-1m Code",
var(--card-font);
text-align: right;
}
.list-total-card-ui__name {
font-family: "IBM Plex Sans Condensed", "Arial Narrow", sans-serif;
font-size: 0.92rem;
line-height: 1.08;
font-weight: 600;
letter-spacing: -0.008em;
}
.list-total-card-ui__status {
min-height: 0.9rem;
color: rgba(77, 57, 45, 0.5);
font:
700 0.62rem / 1 "M-1m Code",
var(--card-font);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.list-total-card-ui__status.is-error {
color: #8e3023;
}
.list-total-card-ui__total {
padding-top: 10px;
border-top: 1px solid rgba(92, 70, 55, 0.18);
color: #35271f;
}
.list-total-card-ui__total-label {
font:
700 0.66rem / 1 "M-1m Code",
var(--card-font);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.list-total-card-ui__total-value {
font:
700 0.98rem / 1 "M-1m Code",
var(--card-font);
text-align: right;
}
.card-question { .card-question {
color: rgba(255, 245, 235, 0.95); color: rgba(255, 245, 235, 0.95);
} }
.card-response, .card-response,
.card-footer { .card-footer {
color: rgba(255, 245, 235, 0.62); color: var(--card-muted);
} }
.card-body p { .card-body p {
margin: 0 0 6px; margin: 0 0 6px;
@ -904,7 +1352,7 @@ body {
.card-body h6 { .card-body h6 {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 700; font-weight: 700;
color: rgba(255, 200, 140, 0.95); color: rgba(255, 233, 214, 0.96);
margin: 8px 0 4px; margin: 8px 0 4px;
} }
.card-body ul, .card-body ul,
@ -916,13 +1364,13 @@ body {
margin-bottom: 2px; margin-bottom: 2px;
} }
.card-body code { .card-body code {
background: rgba(255, 255, 255, 0.07); background: rgba(255, 241, 229, 0.1);
border-radius: 4px; border-radius: 4px;
padding: 1px 5px; padding: 1px 5px;
font-size: 0.6875rem; font-size: 0.6875rem;
} }
.card-body pre { .card-body pre {
background: rgba(0, 0, 0, 0.35); background: rgba(74, 39, 18, 0.42);
border-radius: 6px; border-radius: 6px;
padding: 8px 10px; padding: 8px 10px;
overflow-x: auto; overflow-x: auto;
@ -946,23 +1394,23 @@ body {
text-align: left; text-align: left;
} }
.card-body th { .card-body th {
background: rgba(255, 200, 140, 0.08); background: rgba(255, 223, 198, 0.1);
color: rgba(255, 200, 140, 0.9); color: rgba(255, 233, 214, 0.94);
font-weight: 600; font-weight: 600;
} }
.card-body a { .card-body a {
color: rgba(255, 200, 140, 0.85); color: rgba(255, 233, 214, 0.9);
text-decoration: underline; text-decoration: underline;
} }
.card-body blockquote { .card-body blockquote {
border-left: 3px solid rgba(255, 200, 140, 0.3); border-left: 3px solid rgba(255, 223, 198, 0.28);
margin: 6px 0; margin: 6px 0;
padding-left: 10px; padding-left: 10px;
color: rgba(255, 245, 235, 0.55); color: rgba(255, 245, 235, 0.55);
} }
.card-body hr { .card-body hr {
border: none; border: none;
border-top: 1px solid rgba(255, 200, 140, 0.15); border-top: 1px solid rgba(255, 223, 198, 0.16);
margin: 8px 0; margin: 8px 0;
} }
.card-body img { .card-body img {
@ -979,10 +1427,10 @@ body {
margin-top: 4px; margin-top: 4px;
} }
.card-choice-btn { .card-choice-btn {
background: rgba(255, 200, 140, 0.12); background: rgba(255, 223, 198, 0.12);
border: 1px solid rgba(255, 200, 140, 0.35); border: 1px solid rgba(255, 223, 198, 0.34);
border-radius: 8px; border-radius: 8px;
color: rgba(255, 245, 235, 0.9); color: var(--card-text);
font-family: var(--card-font); font-family: var(--card-font);
font-size: 0.75rem; font-size: 0.75rem;
padding: 6px 14px; padding: 6px 14px;
@ -994,11 +1442,11 @@ body {
text-align: center; text-align: center;
} }
.card-choice-btn:hover { .card-choice-btn:hover {
background: rgba(255, 200, 140, 0.25); background: rgba(255, 223, 198, 0.22);
border-color: rgba(255, 200, 140, 0.65); border-color: rgba(255, 223, 198, 0.56);
} }
.card-choice-btn:active { .card-choice-btn:active {
background: rgba(255, 200, 140, 0.38); background: rgba(255, 223, 198, 0.32);
} }
.card-choice-btn:disabled { .card-choice-btn:disabled {
opacity: 0.4; opacity: 0.4;

View file

@ -60,6 +60,7 @@ export type ServerMessage =
priority: number; priority: number;
state: CardState; state: CardState;
template_key: string; template_key: string;
template_state: Record<string, JsonValue>;
context_summary: string; context_summary: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -94,6 +95,7 @@ export interface CardItem {
priority: number; priority: number;
state: CardState; state: CardState;
templateKey?: string; templateKey?: string;
templateState?: Record<string, JsonValue>;
contextSummary?: string; contextSummary?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;

View file

@ -5,3 +5,4 @@ av>=14.0.0,<17.0.0
numpy>=1.21.0,<2.0.0 numpy>=1.21.0,<2.0.0
supertonic>=1.1.2,<2.0.0 supertonic>=1.1.2,<2.0.0
faster-whisper>=1.1.0,<2.0.0 faster-whisper>=1.1.0,<2.0.0
MeloTTS @ git+https://github.com/myshell-ai/MeloTTS.git

View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_PYTHON="${ROOT_DIR}/.venv/bin/python"
if [[ ! -x "${VENV_PYTHON}" ]]; then
echo "error: ${VENV_PYTHON} does not exist. Create the web UI virtualenv first." >&2
exit 1
fi
"${VENV_PYTHON}" -m pip install \
--index-url https://download.pytorch.org/whl/cpu \
"torch==2.7.1+cpu" \
"torchaudio==2.7.1+cpu"
"${VENV_PYTHON}" -m pip install "setuptools<81"
"${VENV_PYTHON}" -m pip install \
txtsplit \
cached_path \
"transformers==4.46.3" \
"num2words==0.5.12" \
"unidic_lite==1.0.8" \
"mecab-python3==1.0.9" \
fugashi \
"pykakasi==2.2.1" \
"g2p_en==2.1.0" \
"anyascii==0.3.2" \
"jamo==0.4.1" \
"gruut[de,es,fr]==2.2.3" \
"librosa==0.9.1" \
"pydub==0.25.1" \
"eng_to_ipa==0.0.2" \
"inflect==7.0.0" \
"unidecode==1.3.7" \
"pypinyin==0.50.0" \
"cn2an==0.5.22" \
"jieba==0.42.1" \
soundfile \
tqdm
"${VENV_PYTHON}" -m pip install --no-deps "git+https://github.com/myshell-ai/MeloTTS.git"
"${VENV_PYTHON}" - <<'PY'
import os
import nltk
download_dir = os.path.expanduser("~/nltk_data")
for package in ("averaged_perceptron_tagger", "averaged_perceptron_tagger_eng", "cmudict"):
nltk.download(package, download_dir=download_dir)
PY

252
scripts/melotts_server.py Normal file
View file

@ -0,0 +1,252 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import contextlib
import json
import os
import signal
import socket
import sys
from pathlib import Path
from typing import Any
try:
import numpy as np
except Exception as exc: # pragma: no cover - runtime fallback when dependency is missing
np = None # type: ignore[assignment]
NUMPY_IMPORT_ERROR = exc
else:
NUMPY_IMPORT_ERROR = None
ROOT_DIR = Path(__file__).resolve().parents[1]
WORKSPACE_DIR = Path(os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))).expanduser()
SOCKET_PATH = Path(os.getenv("MELO_TTS_SOCKET", str(WORKSPACE_DIR / "melotts.sock"))).expanduser()
try:
from melo.api import TTS
MELO_TTS_AVAILABLE = True
except Exception as exc: # pragma: no cover - runtime fallback when dependency is missing
TTS = None # type: ignore[assignment]
MELO_TTS_AVAILABLE = False
IMPORT_ERROR = exc
else:
IMPORT_ERROR = None
class MeloTTSServer:
def __init__(self) -> None:
if not MELO_TTS_AVAILABLE or TTS is None:
raise RuntimeError(f"MeloTTS import failed: {IMPORT_ERROR}")
if np is None:
raise RuntimeError(f"numpy import failed: {NUMPY_IMPORT_ERROR}")
self._language = os.getenv("MELO_TTS_LANGUAGE", "EN").strip() or "EN"
self._device = os.getenv("MELO_TTS_DEVICE", "cpu").strip() or "cpu"
self._speed = float(os.getenv("MELO_TTS_SPEED", "1.0"))
self._speaker_name = os.getenv("MELO_TTS_SPEAKER", "EN-US").strip() or "EN-US"
self._warmup_text = os.getenv("MELO_TTS_WARMUP_TEXT", "Nanobot is ready.").strip()
self._model = TTS(language=self._language, device=self._device)
self._speaker_ids = dict(getattr(self._model.hps.data, "spk2id", {}))
if self._speaker_name not in self._speaker_ids:
available = ", ".join(sorted(self._speaker_ids))
raise RuntimeError(
f"speaker '{self._speaker_name}' is not available for language {self._language}. "
f"Available speakers: {available}"
)
self._speaker_id = self._speaker_ids[self._speaker_name]
if self._warmup_text:
self._warmup()
def ping(self) -> dict[str, Any]:
return {
"ok": True,
"language": self._language,
"device": self._device,
"speaker": self._speaker_name,
"speakers": sorted(self._speaker_ids),
}
def synthesize_pcm(self, text: str) -> dict[str, Any]:
clean_text = " ".join(text.split())
if not clean_text:
raise RuntimeError("text is empty")
pcm, sample_rate, channels = self._synthesize_pcm(clean_text)
return {
"ok": True,
"encoding": "pcm_s16le_base64",
"pcm": base64.b64encode(pcm).decode("ascii"),
"sample_rate": sample_rate,
"channels": channels,
"language": self._language,
"speaker": self._speaker_name,
}
def synthesize_to_file(self, text: str, output_wav: str) -> dict[str, Any]:
clean_text = " ".join(text.split())
if not clean_text:
raise RuntimeError("text is empty")
output_path = Path(output_wav)
output_path.parent.mkdir(parents=True, exist_ok=True)
self._model.tts_to_file(
clean_text,
self._speaker_id,
str(output_path),
speed=self._speed,
quiet=True,
)
return {
"ok": True,
"output_wav": str(output_path),
"language": self._language,
"speaker": self._speaker_name,
}
def _warmup(self) -> None:
self._synthesize_pcm(self._warmup_text)
def _synthesize_pcm(self, text: str) -> tuple[bytes, int, int]:
wav = self._model.tts_to_file(
text,
self._speaker_id,
None,
speed=self._speed,
quiet=True,
)
if np is None:
raise RuntimeError("numpy is unavailable")
samples = np.asarray(wav)
if samples.size == 0:
raise RuntimeError("MeloTTS produced empty audio")
channels = 1
if samples.ndim == 0:
samples = samples.reshape(1)
elif samples.ndim == 1:
channels = 1
elif samples.ndim == 2:
dim0, dim1 = int(samples.shape[0]), int(samples.shape[1])
if dim0 <= 2 and dim1 > dim0:
channels = dim0
samples = samples.T
elif dim1 <= 2 and dim0 > dim1:
channels = dim1
else:
channels = 1
samples = samples.reshape(-1)
else:
channels = 1
samples = samples.reshape(-1)
if np.issubdtype(samples.dtype, np.floating):
samples = np.clip(samples, -1.0, 1.0)
samples = (samples * 32767.0).astype(np.int16)
elif samples.dtype != np.int16:
samples = samples.astype(np.int16)
sample_rate = int(getattr(self._model.hps.data, "sampling_rate", 44100))
return samples.tobytes(), sample_rate, max(1, channels)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Persistent MeloTTS sidecar for Nanobot voice.")
parser.add_argument("--socket-path", default=str(SOCKET_PATH))
return parser
def _receive_json(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
data = conn.recv(8192)
if not data:
break
chunks.append(data)
if b"\n" in data:
break
payload = b"".join(chunks).decode("utf-8", errors="replace").strip()
if not payload:
return {}
return json.loads(payload)
def _send_json(conn: socket.socket, payload: dict[str, Any]) -> None:
conn.sendall((json.dumps(payload) + "\n").encode("utf-8"))
def main() -> int:
args = _build_parser().parse_args()
socket_path = Path(args.socket_path).expanduser()
socket_path.parent.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(FileNotFoundError):
socket_path.unlink()
stop_requested = False
def request_stop(_signum: int, _frame: object) -> None:
nonlocal stop_requested
stop_requested = True
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
try:
server = MeloTTSServer()
except Exception as exc:
print(f"melotts server initialization failed: {exc}", file=sys.stderr, flush=True)
return 1
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(str(socket_path))
listener.listen(8)
listener.settimeout(1.0)
try:
while not stop_requested:
try:
conn, _addr = listener.accept()
except TimeoutError:
continue
except OSError:
if stop_requested:
break
raise
with conn:
try:
request = _receive_json(conn)
action = str(request.get("action", "")).strip().lower()
if action == "ping":
_send_json(conn, server.ping())
continue
if action == "synthesize_pcm":
text = str(request.get("text", ""))
response = server.synthesize_pcm(text)
_send_json(conn, response)
continue
if action == "synthesize":
text = str(request.get("text", ""))
output_wav = str(request.get("output_wav", ""))
if not output_wav:
raise RuntimeError("output_wav is required")
response = server.synthesize_to_file(text, output_wav)
_send_json(conn, response)
continue
raise RuntimeError(f"unsupported action: {action or 'missing'}")
except Exception as exc:
_send_json(conn, {"ok": False, "error": str(exc)})
finally:
listener.close()
with contextlib.suppress(FileNotFoundError):
socket_path.unlink()
return 0
if __name__ == "__main__":
raise SystemExit(main())

106
scripts/melotts_tts.py Normal file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import contextlib
import json
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
ROOT_DIR = Path(__file__).resolve().parents[1]
WORKSPACE_DIR = Path(os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))).expanduser()
LOG_DIR = WORKSPACE_DIR / "logs"
SOCKET_PATH = Path(os.getenv("MELO_TTS_SOCKET", str(WORKSPACE_DIR / "melotts.sock"))).expanduser()
SERVER_SCRIPT = ROOT_DIR / "scripts" / "melotts_server.py"
SERVER_LOG_PATH = LOG_DIR / "melotts-server.log"
DEFAULT_STARTUP_TIMEOUT_S = float(os.getenv("MELO_TTS_SERVER_STARTUP_TIMEOUT_S", "120"))
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Nanobot MeloTTS command adapter.")
parser.add_argument("--text", required=True)
parser.add_argument("--output-wav", required=True)
parser.add_argument("--socket-path", default=str(SOCKET_PATH))
return parser
def _rpc(socket_path: Path, payload: dict[str, Any], timeout_s: float = 10.0) -> dict[str, Any]:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout_s)
try:
sock.connect(str(socket_path))
sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
response = sock.recv(8192).decode("utf-8", errors="replace").strip()
finally:
sock.close()
if not response:
raise RuntimeError("empty response from MeloTTS server")
return json.loads(response)
def _ping(socket_path: Path) -> bool:
try:
response = _rpc(socket_path, {"action": "ping"}, timeout_s=2.0)
except Exception:
return False
return bool(response.get("ok"))
def _ensure_server(socket_path: Path) -> None:
if _ping(socket_path):
return
with contextlib.suppress(FileNotFoundError):
socket_path.unlink()
LOG_DIR.mkdir(parents=True, exist_ok=True)
with SERVER_LOG_PATH.open("a", encoding="utf-8") as log_handle:
proc = subprocess.Popen(
[sys.executable, str(SERVER_SCRIPT), "--socket-path", str(socket_path)],
cwd=str(ROOT_DIR),
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
start_new_session=True,
)
deadline = time.time() + DEFAULT_STARTUP_TIMEOUT_S
while time.time() < deadline:
if _ping(socket_path):
return
exit_code = proc.poll()
if exit_code is not None:
raise RuntimeError(
f"MeloTTS server exited during startup with code {exit_code}. "
f"See {SERVER_LOG_PATH}"
)
time.sleep(0.5)
raise RuntimeError(f"MeloTTS server did not become ready within {DEFAULT_STARTUP_TIMEOUT_S:.0f}s")
def main() -> int:
args = _build_parser().parse_args()
socket_path = Path(args.socket_path).expanduser()
_ensure_server(socket_path)
response = _rpc(
socket_path,
{
"action": "synthesize",
"text": args.text,
"output_wav": args.output_wav,
},
timeout_s=max(30.0, DEFAULT_STARTUP_TIMEOUT_S),
)
if not response.get("ok"):
raise RuntimeError(str(response.get("error", "MeloTTS synthesis failed")))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -28,10 +28,11 @@ nanobot -> client notifications::
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import contextlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Awaitable, Callable
from wisper import WisperBus, WisperEvent from wisper import WisperBus, WisperEvent
@ -56,13 +57,21 @@ def _jsonrpc_notification(method: str, params: dict[str, Any] | None = None) ->
class NanobotApiProcess: class NanobotApiProcess:
"""Connects to the running nanobot process via its Unix domain socket.""" """Connects to the running nanobot process via its Unix domain socket."""
def __init__(self, bus: WisperBus, socket_path: Path) -> None: def __init__(
self,
bus: WisperBus,
socket_path: Path,
on_disconnect: Callable[[], Awaitable[None]] | None = None,
) -> None:
self._bus = bus self._bus = bus
self._socket_path = socket_path self._socket_path = socket_path
self._on_disconnect = on_disconnect
self._reader: asyncio.StreamReader | None = None self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
self._read_task: asyncio.Task | None = None self._read_task: asyncio.Task | None = None
self._socket_inode: int | None = None self._socket_inode: int | None = None
self._streaming_partial_response = False
self._closing = False
@property @property
def running(self) -> bool: def running(self) -> bool:
@ -88,6 +97,8 @@ class NanobotApiProcess:
await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
return return
self._closing = False
self._streaming_partial_response = False
if not self._socket_path.exists(): if not self._socket_path.exists():
await self._bus.publish( await self._bus.publish(
WisperEvent( WisperEvent(
@ -172,6 +183,7 @@ class NanobotApiProcess:
await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot.")) await self._bus.publish(WisperEvent(role="system", text="Disconnected from nanobot."))
async def _cleanup(self) -> None: async def _cleanup(self) -> None:
self._closing = True
if self._read_task and not self._read_task.done(): if self._read_task and not self._read_task.done():
self._read_task.cancel() self._read_task.cancel()
try: try:
@ -189,6 +201,7 @@ class NanobotApiProcess:
self._writer = None self._writer = None
self._reader = None self._reader = None
self._socket_inode = None self._socket_inode = None
self._streaming_partial_response = False
async def _send_notification(self, method: str, params: dict[str, Any]) -> None: async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
assert self._writer is not None assert self._writer is not None
@ -207,9 +220,19 @@ class NanobotApiProcess:
break break
await self._handle_line(line) await self._handle_line(line)
finally: finally:
await self._bus.publish(WisperEvent(role="system", text="Nanobot closed the connection.")) should_notify_disconnect = not self._closing
self._streaming_partial_response = False
self._writer = None self._writer = None
self._reader = None self._reader = None
if should_notify_disconnect:
await self._bus.publish(
WisperEvent(role="system", text="Nanobot closed the connection.")
)
if self._on_disconnect is not None:
asyncio.create_task(
self._on_disconnect(),
name="nanobot-api-reconnect-trigger",
)
async def _handle_line(self, line: bytes) -> None: async def _handle_line(self, line: bytes) -> None:
raw = line.decode(errors="replace").strip() raw = line.decode(errors="replace").strip()
@ -245,11 +268,20 @@ class NanobotApiProcess:
content = str(params.get("content", "")) content = str(params.get("content", ""))
is_progress = bool(params.get("is_progress", False)) is_progress = bool(params.get("is_progress", False))
is_tool_hint = bool(params.get("is_tool_hint", False)) is_tool_hint = bool(params.get("is_tool_hint", False))
is_partial = bool(params.get("is_partial", False))
if is_progress: if is_progress:
if is_partial:
self._streaming_partial_response = True
await self._bus.publish(WisperEvent(role="nanobot-tts-partial", text=content))
return
role = "nanobot-tool" if is_tool_hint else "nanobot-progress" role = "nanobot-tool" if is_tool_hint else "nanobot-progress"
await self._bus.publish(WisperEvent(role=role, text=content)) await self._bus.publish(WisperEvent(role=role, text=content))
else: else:
await self._bus.publish(WisperEvent(role="nanobot", text=content)) await self._bus.publish(WisperEvent(role="nanobot", text=content))
if self._streaming_partial_response:
self._streaming_partial_response = False
await self._bus.publish(WisperEvent(role="nanobot-tts-flush", text=""))
else:
await self._bus.publish(WisperEvent(role="nanobot-tts", text=content)) await self._bus.publish(WisperEvent(role="nanobot-tts", text=content))
elif method == "agent_state": elif method == "agent_state":
state = str(params.get("state", "")) state = str(params.get("state", ""))
@ -263,9 +295,52 @@ class SuperTonicGateway:
self.bus = WisperBus() self.bus = WisperBus()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._process: NanobotApiProcess | None = None self._process: NanobotApiProcess | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._shutdown = False
socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser() socket_path = Path(os.getenv("NANOBOT_API_SOCKET", str(DEFAULT_SOCKET_PATH))).expanduser()
self._socket_path = socket_path self._socket_path = socket_path
def _new_process(self) -> NanobotApiProcess:
return NanobotApiProcess(
bus=self.bus,
socket_path=self._socket_path,
on_disconnect=self._schedule_reconnect,
)
async def _schedule_reconnect(self) -> None:
async with self._lock:
if self._shutdown:
return
if self._process and self._process.running:
return
if self._reconnect_task and not self._reconnect_task.done():
return
self._reconnect_task = asyncio.create_task(
self._reconnect_loop(),
name="nanobot-api-reconnect",
)
async def _reconnect_loop(self) -> None:
delay_s = 0.5
try:
while not self._shutdown:
async with self._lock:
if self._process and self._process.running:
return
self._process = self._new_process()
await self._process.start()
if self._process.running:
return
await asyncio.sleep(delay_s)
delay_s = min(delay_s * 2.0, 5.0)
except asyncio.CancelledError:
raise
finally:
async with self._lock:
current_task = asyncio.current_task()
if self._reconnect_task is current_task:
self._reconnect_task = None
async def subscribe(self) -> asyncio.Queue[WisperEvent]: async def subscribe(self) -> asyncio.Queue[WisperEvent]:
return await self.bus.subscribe() return await self.bus.subscribe()
@ -274,10 +349,16 @@ class SuperTonicGateway:
async def connect_nanobot(self) -> None: async def connect_nanobot(self) -> None:
async with self._lock: async with self._lock:
self._shutdown = False
if self._process and self._process.running: if self._process and self._process.running:
await self.bus.publish(WisperEvent(role="system", text="Already connected to nanobot.")) await self.bus.publish(WisperEvent(role="system", text="Already connected to nanobot."))
return return
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path) if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._reconnect_task
self._reconnect_task = None
self._process = self._new_process()
await self._process.start() await self._process.start()
async def _ensure_connected_process(self) -> NanobotApiProcess: async def _ensure_connected_process(self) -> NanobotApiProcess:
@ -285,7 +366,12 @@ class SuperTonicGateway:
return self._process return self._process
if self._process: if self._process:
await self._process.stop() await self._process.stop()
self._process = NanobotApiProcess(bus=self.bus, socket_path=self._socket_path) if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._reconnect_task
self._reconnect_task = None
self._process = self._new_process()
await self._process.start() await self._process.start()
if not self._process.running or not self._process.matches_current_socket(): if not self._process.running or not self._process.matches_current_socket():
raise RuntimeError("Not connected to nanobot.") raise RuntimeError("Not connected to nanobot.")
@ -312,6 +398,12 @@ class SuperTonicGateway:
async def disconnect_nanobot(self) -> None: async def disconnect_nanobot(self) -> None:
async with self._lock: async with self._lock:
self._shutdown = True
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._reconnect_task
self._reconnect_task = None
if self._process: if self._process:
await self._process.stop() await self._process.stop()
self._process = None self._process = None

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import audioop import audioop
import base64
import contextlib import contextlib
import io import io
import json import json
@ -7,11 +8,15 @@ import os
import re import re
import shlex import shlex
import shutil import shutil
import socket
import subprocess import subprocess
import sys
import tempfile import tempfile
import time
import wave import wave
from dataclasses import dataclass from dataclasses import dataclass
from fractions import Fraction from fractions import Fraction
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable
from wisper import WisperEvent from wisper import WisperEvent
@ -83,6 +88,9 @@ BRAILLE_SPINNER_RE = re.compile(r"[\u2800-\u28ff]")
TTS_ALLOWED_ASCII = set( TTS_ALLOWED_ASCII = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?;:'\"()[]{}@#%&*+-_/<>|" "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?;:'\"()[]{}@#%&*+-_/<>|"
) )
TTS_WORD_RE = re.compile(r"[A-Za-z0-9]")
TTS_RETRY_BREAK_RE = re.compile(r"(?<=[.!?,;:])\s+")
TTS_PARTIAL_COMMIT_RE = re.compile(r"[.!?]\s*$|[,;:]\s+$")
LOCAL_ICE_GATHER_TIMEOUT_S = 0.35 LOCAL_ICE_GATHER_TIMEOUT_S = 0.35
@ -94,9 +102,39 @@ def _sanitize_tts_text(text: str) -> str:
cleaned = CONTROL_CHAR_RE.sub(" ", cleaned) cleaned = CONTROL_CHAR_RE.sub(" ", cleaned)
cleaned = "".join(ch if (ch in TTS_ALLOWED_ASCII or ch.isspace()) else " " for ch in cleaned) cleaned = "".join(ch if (ch in TTS_ALLOWED_ASCII or ch.isspace()) else " " for ch in cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip() cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not TTS_WORD_RE.search(cleaned):
return ""
return cleaned return cleaned
def _split_tts_retry_segments(text: str, max_chars: int = 120) -> list[str]:
clean = _sanitize_tts_text(text)
if not clean:
return []
parts = [part.strip() for part in TTS_RETRY_BREAK_RE.split(clean) if part.strip()]
if len(parts) <= 1:
words = clean.split()
if len(words) <= 1:
return []
parts = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if len(candidate) <= max_chars:
current = candidate
continue
parts.append(current)
current = word
parts.append(current)
compact_parts = [_sanitize_tts_text(part) for part in parts]
compact_parts = [part for part in compact_parts if part]
if len(compact_parts) <= 1:
return []
return compact_parts
def _coerce_message_metadata(raw: Any) -> dict[str, Any]: def _coerce_message_metadata(raw: Any) -> dict[str, Any]:
def _coerce_jsonish(value: Any, depth: int = 0) -> Any: def _coerce_jsonish(value: Any, depth: int = 0) -> Any:
if depth > 6: if depth > 6:
@ -143,6 +181,12 @@ class PCMChunk:
channels: int = 1 channels: int = 1
@dataclass(slots=True)
class STTSegment:
pcm: bytes
metadata: dict[str, Any]
if AIORTC_AVAILABLE: if AIORTC_AVAILABLE:
class QueueAudioTrack(MediaStreamTrack): class QueueAudioTrack(MediaStreamTrack):
@ -275,6 +319,19 @@ if AIORTC_AVAILABLE:
self._closed = True self._closed = True
super().stop() super().stop()
def clear(self) -> None:
while True:
try:
self._queue.get_nowait()
except asyncio.QueueEmpty:
break
self._last_enqueue_at = 0.0
self._idle_frames = 0
if self._playing:
self._playing = False
if self._on_playing_changed:
self._on_playing_changed(False)
else: else:
class QueueAudioTrack: # pragma: no cover - used only when aiortc is unavailable class QueueAudioTrack: # pragma: no cover - used only when aiortc is unavailable
@ -286,6 +343,9 @@ else:
def stop(self) -> None: def stop(self) -> None:
return return
def clear(self) -> None:
return
def _write_temp_wav(pcm: bytes, sample_rate: int, channels: int) -> str: def _write_temp_wav(pcm: bytes, sample_rate: int, channels: int) -> str:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file: with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
@ -706,13 +766,138 @@ class SupertonicTextToSpeech:
self._init_error = None self._init_error = None
class MeloTTSTextToSpeech:
def __init__(self) -> None:
self._root_dir = Path(__file__).resolve().parent
self._workspace_dir = Path(
os.getenv("NANOBOT_WORKSPACE", str(Path.home() / ".nanobot"))
).expanduser()
self._socket_path = Path(
os.getenv("MELO_TTS_SOCKET", str(self._workspace_dir / "melotts.sock"))
).expanduser()
self._server_script = self._root_dir / "scripts" / "melotts_server.py"
self._server_log_path = self._workspace_dir / "logs" / "melotts-server.log"
self._startup_timeout_s = max(
5.0, float(os.getenv("MELO_TTS_SERVER_STARTUP_TIMEOUT_S", "120"))
)
self._init_error: str | None = None
self._lock = asyncio.Lock()
@property
def enabled(self) -> bool:
return self._server_script.exists()
@property
def init_error(self) -> str | None:
return self._init_error
async def synthesize(self, text: str) -> PCMChunk | None:
if not self.enabled:
return None
clean_text = " ".join(text.split())
if not clean_text:
return None
async with self._lock:
return await asyncio.to_thread(self._synthesize_blocking, clean_text)
def _synthesize_blocking(self, text: str) -> PCMChunk | None:
self._ensure_server_blocking()
response = self._rpc(
{
"action": "synthesize_pcm",
"text": text,
},
timeout_s=max(30.0, self._startup_timeout_s),
)
if not response.get("ok"):
raise RuntimeError(str(response.get("error", "MeloTTS synthesis failed")))
encoded_pcm = str(response.get("pcm", "")).strip()
if not encoded_pcm:
return None
pcm = base64.b64decode(encoded_pcm)
sample_rate = max(1, int(response.get("sample_rate", 44100)))
channels = max(1, int(response.get("channels", 1)))
return PCMChunk(pcm=pcm, sample_rate=sample_rate, channels=channels)
def _ensure_server_blocking(self) -> None:
if self._ping():
self._init_error = None
return
with contextlib.suppress(FileNotFoundError):
self._socket_path.unlink()
self._server_log_path.parent.mkdir(parents=True, exist_ok=True)
with self._server_log_path.open("a", encoding="utf-8") as log_handle:
proc = subprocess.Popen(
[sys.executable, str(self._server_script), "--socket-path", str(self._socket_path)],
cwd=str(self._root_dir),
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
start_new_session=True,
)
deadline = time.time() + self._startup_timeout_s
while time.time() < deadline:
if self._ping():
self._init_error = None
return
exit_code = proc.poll()
if exit_code is not None:
self._init_error = (
f"MeloTTS server exited during startup with code {exit_code}. "
f"See {self._server_log_path}"
)
raise RuntimeError(self._init_error)
time.sleep(0.25)
self._init_error = (
f"MeloTTS server did not become ready within {self._startup_timeout_s:.0f}s."
)
raise RuntimeError(self._init_error)
def _ping(self) -> bool:
try:
response = self._rpc({"action": "ping"}, timeout_s=2.0)
except Exception:
return False
return bool(response.get("ok"))
def _rpc(self, payload: dict[str, Any], timeout_s: float) -> dict[str, Any]:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout_s)
try:
sock.connect(str(self._socket_path))
sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
chunks: list[bytes] = []
while True:
data = sock.recv(8192)
if not data:
break
chunks.append(data)
if b"\n" in data:
break
finally:
sock.close()
response = b"".join(chunks).decode("utf-8", errors="replace").strip()
if not response:
raise RuntimeError("empty response from MeloTTS server")
return json.loads(response)
class HostTextToSpeech: class HostTextToSpeech:
def __init__(self) -> None: def __init__(self) -> None:
provider = (os.getenv("HOST_TTS_PROVIDER", "supertonic").strip() or "supertonic").lower() provider = (os.getenv("HOST_TTS_PROVIDER", "supertonic").strip() or "supertonic").lower()
if provider not in {"supertonic", "command", "espeak", "auto"}: if provider not in {"supertonic", "melotts", "command", "espeak", "auto"}:
provider = "auto" provider = "auto"
self._provider = provider self._provider = provider
self._supertonic = SupertonicTextToSpeech() self._supertonic = SupertonicTextToSpeech()
self._melotts = MeloTTSTextToSpeech()
self._command_template = os.getenv("HOST_TTS_COMMAND", "").strip() self._command_template = os.getenv("HOST_TTS_COMMAND", "").strip()
self._espeak = shutil.which("espeak") self._espeak = shutil.which("espeak")
@ -720,11 +905,17 @@ class HostTextToSpeech:
def enabled(self) -> bool: def enabled(self) -> bool:
if self._provider == "supertonic": if self._provider == "supertonic":
return self._supertonic.enabled return self._supertonic.enabled
if self._provider == "melotts":
return self._melotts.enabled
if self._provider == "command": if self._provider == "command":
return bool(self._command_template) return bool(self._command_template)
if self._provider == "espeak": if self._provider == "espeak":
return bool(self._espeak) return bool(self._espeak)
return self._supertonic.enabled or bool(self._command_template or self._espeak) return (
self._supertonic.enabled
or self._melotts.enabled
or bool(self._command_template or self._espeak)
)
async def synthesize(self, text: str) -> PCMChunk | None: async def synthesize(self, text: str) -> PCMChunk | None:
clean_text = " ".join(text.split()) clean_text = " ".join(text.split())
@ -738,6 +929,13 @@ class HostTextToSpeech:
if self._provider == "supertonic": if self._provider == "supertonic":
return None return None
if self._provider in {"melotts", "auto"}:
audio = await self._melotts.synthesize(clean_text)
if audio:
return audio
if self._provider == "melotts":
return None
if self._provider in {"command", "auto"} and self._command_template: if self._provider in {"command", "auto"} and self._command_template:
return await asyncio.to_thread(self._synthesize_with_command, clean_text) return await asyncio.to_thread(self._synthesize_with_command, clean_text)
if self._provider == "command": if self._provider == "command":
@ -755,6 +953,12 @@ class HostTextToSpeech:
if self._supertonic.init_error: if self._supertonic.init_error:
return f"supertonic initialization failed: {self._supertonic.init_error}" return f"supertonic initialization failed: {self._supertonic.init_error}"
return "supertonic did not return audio." return "supertonic did not return audio."
if self._provider == "melotts":
if not self._melotts.enabled:
return "MeloTTS server script is not available."
if self._melotts.init_error:
return f"MeloTTS initialization failed: {self._melotts.init_error}"
return "MeloTTS did not return audio."
if self._provider == "command": if self._provider == "command":
return "HOST_TTS_COMMAND is not configured." return "HOST_TTS_COMMAND is not configured."
if self._provider == "espeak": if self._provider == "espeak":
@ -762,6 +966,8 @@ class HostTextToSpeech:
if self._supertonic.init_error: if self._supertonic.init_error:
return f"supertonic initialization failed: {self._supertonic.init_error}" return f"supertonic initialization failed: {self._supertonic.init_error}"
if self._melotts.init_error:
return f"MeloTTS initialization failed: {self._melotts.init_error}"
if self._command_template: if self._command_template:
return "HOST_TTS_COMMAND failed to produce audio." return "HOST_TTS_COMMAND failed to produce audio."
if self._espeak: if self._espeak:
@ -862,11 +1068,12 @@ class WebRTCVoiceSession:
self._stt = HostSpeechToText() self._stt = HostSpeechToText()
self._tts = HostTextToSpeech() self._tts = HostTextToSpeech()
self._stt_segment_queue_size = max(1, int(os.getenv("HOST_STT_SEGMENT_QUEUE_SIZE", "2"))) self._stt_segment_queue_size = max(1, int(os.getenv("HOST_STT_SEGMENT_QUEUE_SIZE", "2")))
self._stt_segments: asyncio.Queue[bytes] = asyncio.Queue( self._stt_segments: asyncio.Queue[STTSegment] = asyncio.Queue(
maxsize=self._stt_segment_queue_size maxsize=self._stt_segment_queue_size
) )
self._tts_chunks: list[str] = [] self._tts_chunks: list[str] = []
self._tts_partial_buffer = ""
self._tts_flush_handle: asyncio.TimerHandle | None = None self._tts_flush_handle: asyncio.TimerHandle | None = None
self._tts_flush_lock = asyncio.Lock() self._tts_flush_lock = asyncio.Lock()
self._tts_buffer_lock = asyncio.Lock() self._tts_buffer_lock = asyncio.Lock()
@ -875,8 +1082,18 @@ class WebRTCVoiceSession:
self._tts_response_end_delay_s = max( self._tts_response_end_delay_s = max(
0.1, float(os.getenv("HOST_TTS_RESPONSE_END_DELAY_S", "0.5")) 0.1, float(os.getenv("HOST_TTS_RESPONSE_END_DELAY_S", "0.5"))
) )
self._tts_partial_commit_chars = max(
24, int(os.getenv("HOST_TTS_PARTIAL_COMMIT_CHARS", "72"))
)
self._closed = False self._closed = False
self._audio_debug = os.getenv("HOST_AUDIO_DEBUG", "0").strip() not in {
"0",
"false",
"False",
"no",
"off",
}
self._stt_unavailable_notice_sent = False self._stt_unavailable_notice_sent = False
self._tts_unavailable_notice_sent = False self._tts_unavailable_notice_sent = False
self._audio_seen_notice_sent = False self._audio_seen_notice_sent = False
@ -925,20 +1142,65 @@ class WebRTCVoiceSession:
except Exception: except Exception:
pass pass
async def queue_output_text(self, chunk: str) -> None: def _should_commit_partial_buffer(self) -> bool:
normalized_chunk = chunk.strip() stripped = self._tts_partial_buffer.strip()
if not normalized_chunk: if not stripped:
return False
if len(stripped) >= self._tts_partial_commit_chars:
return True
return bool(TTS_PARTIAL_COMMIT_RE.search(self._tts_partial_buffer))
def _commit_partial_buffer_locked(self) -> None:
partial = self._tts_partial_buffer.strip()
self._tts_partial_buffer = ""
if partial:
self._tts_chunks.append(partial)
async def queue_output_text(self, chunk: str, *, partial: bool = False) -> None:
if not chunk:
return return
async with self._tts_buffer_lock: async with self._tts_buffer_lock:
if not self._pc or not self._outbound_track: if not self._pc or not self._outbound_track:
return return
if partial:
self._tts_partial_buffer += chunk
if self._should_commit_partial_buffer():
self._commit_partial_buffer_locked()
self._schedule_tts_flush_after(0.05, reset=True)
else:
self._schedule_tts_flush_after(self._tts_response_end_delay_s, reset=True)
return
normalized_chunk = chunk.strip()
if not normalized_chunk:
return
if self._tts_partial_buffer.strip():
self._commit_partial_buffer_locked()
# Keep line boundaries between streamed chunks so line-based filters # Keep line boundaries between streamed chunks so line-based filters
# stay accurate while avoiding repeated full-string copies. # stay accurate while avoiding repeated full-string copies.
self._tts_chunks.append(normalized_chunk) self._tts_chunks.append(normalized_chunk)
# Reset the flush timer on every incoming chunk so the entire # Flush in short rolling windows instead of waiting for the whole
# response is accumulated before synthesis begins. The timer # response so streamed Nanobot output can start speaking sooner.
# fires once no new chunks arrive for the configured delay. self._schedule_tts_flush_after(self._tts_response_end_delay_s, reset=False)
self._schedule_tts_flush_after(self._tts_response_end_delay_s)
async def flush_partial_output_text(self) -> None:
async with self._tts_buffer_lock:
if not self._pc or not self._outbound_track:
return
if not self._tts_partial_buffer.strip():
return
self._commit_partial_buffer_locked()
self._schedule_tts_flush_after(0.05, reset=True)
def interrupt_output(self) -> None:
if self._tts_flush_handle:
self._tts_flush_handle.cancel()
self._tts_flush_handle = None
self._tts_chunks.clear()
self._tts_partial_buffer = ""
self._stt_suppress_until = 0.0
if self._outbound_track:
self._outbound_track.clear()
async def handle_offer(self, payload: dict[str, Any]) -> dict[str, Any] | None: async def handle_offer(self, payload: dict[str, Any]) -> dict[str, Any] | None:
if not AIORTC_AVAILABLE or not RTCPeerConnection or not RTCSessionDescription: if not AIORTC_AVAILABLE or not RTCPeerConnection or not RTCSessionDescription:
@ -980,7 +1242,10 @@ class WebRTCVoiceSession:
self._active_message_metadata = _coerce_message_metadata(msg.get("metadata", {})) self._active_message_metadata = _coerce_message_metadata(msg.get("metadata", {}))
self.set_push_to_talk_pressed(bool(msg.get("pressed", False))) self.set_push_to_talk_pressed(bool(msg.get("pressed", False)))
elif msg_type == "command": elif msg_type == "command":
asyncio.create_task(self._gateway.send_command(str(msg.get("command", "")))) command = str(msg.get("command", "")).strip()
if command == "reset":
self.interrupt_output()
asyncio.create_task(self._gateway.send_command(command))
elif msg_type == "card-response": elif msg_type == "card-response":
asyncio.create_task( asyncio.create_task(
self._gateway.send_card_response( self._gateway.send_card_response(
@ -1037,6 +1302,7 @@ class WebRTCVoiceSession:
self._tts_flush_handle.cancel() self._tts_flush_handle.cancel()
self._tts_flush_handle = None self._tts_flush_handle = None
self._tts_chunks.clear() self._tts_chunks.clear()
self._tts_partial_buffer = ""
if self._incoming_audio_task: if self._incoming_audio_task:
self._incoming_audio_task.cancel() self._incoming_audio_task.cancel()
@ -1063,21 +1329,28 @@ class WebRTCVoiceSession:
return return
asyncio.create_task(self._flush_tts(), name="voice-tts-flush") asyncio.create_task(self._flush_tts(), name="voice-tts-flush")
def _schedule_tts_flush_after(self, delay_s: float) -> None: def _schedule_tts_flush_after(self, delay_s: float, *, reset: bool = True) -> None:
if self._tts_flush_handle: if self._tts_flush_handle:
if not reset:
return
self._tts_flush_handle.cancel() self._tts_flush_handle.cancel()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
self._tts_flush_handle = loop.call_later(max(0.05, delay_s), self._schedule_tts_flush) self._tts_flush_handle = loop.call_later(max(0.05, delay_s), self._schedule_tts_flush)
async def _flush_tts(self) -> None: async def _flush_tts(self) -> None:
async with self._tts_flush_lock: async with self._tts_flush_lock:
while True:
async with self._tts_buffer_lock: async with self._tts_buffer_lock:
self._tts_flush_handle = None self._tts_flush_handle = None
raw_text = "\n".join(self._tts_chunks) if not self._tts_chunks and self._tts_partial_buffer.strip():
self._tts_chunks.clear() self._commit_partial_buffer_locked()
if not self._tts_chunks:
return
raw_text = self._tts_chunks.pop(0)
clean_text = self._clean_tts_text(raw_text) clean_text = self._clean_tts_text(raw_text)
if not clean_text: if not clean_text:
return continue
if not self._outbound_track: if not self._outbound_track:
return return
@ -1090,9 +1363,11 @@ class WebRTCVoiceSession:
import traceback # noqa: local import in exception handler import traceback # noqa: local import in exception handler
traceback.print_exc() traceback.print_exc()
# Restore the lost text so a future flush can retry it. retry_segments = _split_tts_retry_segments(clean_text)
if retry_segments:
async with self._tts_buffer_lock: async with self._tts_buffer_lock:
self._tts_chunks.insert(0, clean_text) self._tts_chunks[0:0] = retry_segments
continue
await self._publish_system(f"TTS synthesis error: {exc}") await self._publish_system(f"TTS synthesis error: {exc}")
return return
@ -1152,13 +1427,13 @@ class WebRTCVoiceSession:
if not pcm16: if not pcm16:
continue continue
if not self._audio_seen_notice_sent: if self._audio_debug and not self._audio_seen_notice_sent:
self._audio_seen_notice_sent = True self._audio_seen_notice_sent = True
await self._publish_system("Receiving microphone audio on host.") await self._publish_debug("Receiving microphone audio on host.")
if not self._audio_format_notice_sent: if self._audio_debug and not self._audio_format_notice_sent:
self._audio_format_notice_sent = True self._audio_format_notice_sent = True
await self._publish_system( await self._publish_debug(
"Inbound audio frame stats: " "Inbound audio frame stats: "
f"sample_rate={int(getattr(frame, 'sample_rate', 0) or 0)}, " f"sample_rate={int(getattr(frame, 'sample_rate', 0) or 0)}, "
f"samples={int(getattr(frame, 'samples', 0) or 0)}, " f"samples={int(getattr(frame, 'samples', 0) or 0)}, "
@ -1261,16 +1536,25 @@ class WebRTCVoiceSession:
None, None,
) )
normalized_duration_ms = (len(normalized_pcm) / 2 / 16_000) * 1000.0 normalized_duration_ms = (len(normalized_pcm) / 2 / 16_000) * 1000.0
if not self._ptt_timing_correction_notice_sent: if self._audio_debug and not self._ptt_timing_correction_notice_sent:
self._ptt_timing_correction_notice_sent = True self._ptt_timing_correction_notice_sent = True
await self._publish_system( await self._publish_debug(
"Corrected PTT timing mismatch " "Corrected PTT timing mismatch "
f"(estimated source={nearest_source_rate}Hz)." f"(estimated source={nearest_source_rate}Hz)."
) )
await self._enqueue_stt_segment(pcm16=normalized_pcm, duration_ms=normalized_duration_ms) await self._enqueue_stt_segment(
pcm16=normalized_pcm,
duration_ms=normalized_duration_ms,
metadata=dict(self._active_message_metadata),
)
async def _enqueue_stt_segment(self, pcm16: bytes, duration_ms: float) -> None: async def _enqueue_stt_segment(
self,
pcm16: bytes,
duration_ms: float,
metadata: dict[str, Any],
) -> None:
if duration_ms < self._stt_min_ptt_ms: if duration_ms < self._stt_min_ptt_ms:
return return
@ -1284,17 +1568,17 @@ class WebRTCVoiceSession:
await self._publish_system("Voice input backlog detected; dropping stale segment.") await self._publish_system("Voice input backlog detected; dropping stale segment.")
with contextlib.suppress(asyncio.QueueFull): with contextlib.suppress(asyncio.QueueFull):
self._stt_segments.put_nowait(pcm16) self._stt_segments.put_nowait(STTSegment(pcm=pcm16, metadata=dict(metadata)))
async def _stt_worker(self) -> None: async def _stt_worker(self) -> None:
while True: while True:
pcm16 = await self._stt_segments.get() segment = await self._stt_segments.get()
if not self._stt_first_segment_notice_sent: if self._audio_debug and not self._stt_first_segment_notice_sent:
self._stt_first_segment_notice_sent = True self._stt_first_segment_notice_sent = True
await self._publish_system("Push-to-talk audio captured. Running host STT...") await self._publish_debug("Push-to-talk audio captured. Running host STT...")
try: try:
transcript = await self._stt.transcribe_pcm( transcript = await self._stt.transcribe_pcm(
pcm=pcm16, pcm=segment.pcm,
sample_rate=16_000, sample_rate=16_000,
channels=1, channels=1,
) )
@ -1317,7 +1601,7 @@ class WebRTCVoiceSession:
try: try:
await self._gateway.send_user_message( await self._gateway.send_user_message(
transcript, transcript,
metadata=dict(self._active_message_metadata), metadata=dict(segment.metadata),
) )
except RuntimeError as exc: except RuntimeError as exc:
if self._closed: if self._closed:
@ -1360,6 +1644,11 @@ class WebRTCVoiceSession:
async def _publish_system(self, text: str) -> None: async def _publish_system(self, text: str) -> None:
await self._gateway.bus.publish(WisperEvent(role="system", text=text)) await self._gateway.bus.publish(WisperEvent(role="system", text=text))
async def _publish_debug(self, text: str) -> None:
if not self._audio_debug:
return
await self._publish_system(text)
async def _publish_agent_state(self, state: str) -> None: async def _publish_agent_state(self, state: str) -> None:
await self._gateway.bus.publish(WisperEvent(role="agent-state", text=state)) await self._gateway.bus.publish(WisperEvent(role="agent-state", text=state))