From 0edf8c3fef050edc08ecaea99d3ae981c887a809 Mon Sep 17 00:00:00 2001 From: kacper Date: Tue, 24 Mar 2026 08:54:47 -0400 Subject: [PATCH] feat: polish life os cards and voice stack --- app.py | 157 +- .../instances/live-calorie-tracker/card.json | 17 + .../instances/live-calorie-tracker/state.json | 9 + .../instances/live-weather-01545/state.json | 6 +- .../templates/list-total-live/manifest.json | 24 + .../templates/list-total-live/template.html | 336 ++++ .../templates/todo-item-live/template.html | 376 ++-- .../templates/weather-live/manifest.json | 6 +- .../templates/weather-live/template.html | 131 +- frontend/src/App.tsx | 2 + frontend/src/components/CardFeed.tsx | 1610 ++++++++++++++++- frontend/src/hooks/usePTT.ts | 25 +- frontend/src/hooks/useWebRTC.ts | 12 +- frontend/src/index.css | 538 +++++- frontend/src/types.ts | 2 + requirements.txt | 1 + scripts/install_melotts_cpu.sh | 52 + scripts/melotts_server.py | 252 +++ scripts/melotts_tts.py | 106 ++ supertonic_gateway.py | 104 +- voice_rtc.py | 417 ++++- 21 files changed, 3681 insertions(+), 502 deletions(-) create mode 100644 examples/cards/instances/live-calorie-tracker/card.json create mode 100644 examples/cards/instances/live-calorie-tracker/state.json create mode 100644 examples/cards/templates/list-total-live/manifest.json create mode 100644 examples/cards/templates/list-total-live/template.html create mode 100644 scripts/install_melotts_cpu.sh create mode 100644 scripts/melotts_server.py create mode 100644 scripts/melotts_tts.py diff --git a/app.py b/app.py index 8201ccb..f881b64 100644 --- a/app.py +++ b/app.py @@ -38,6 +38,10 @@ _JSONRPC_VERSION = "2.0" _TOOL_JOB_TIMEOUT_SECONDS = 300.0 _TOOL_JOB_RETENTION_SECONDS = 15 * 60 _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_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 +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]: try: payload = await request.json() @@ -114,6 +131,76 @@ async def _read_json_request(request: Request) -> dict[str, Any]: 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: card_id = _normalize_card_id(str(raw.get("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, "context_summary": str(raw.get("context_summary", "")), "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", "")), "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]]: CARD_INSTANCES_DIR.mkdir(parents=True, exist_ok=True) cards: list[dict[str, Any]] = [] + now = datetime.now(timezone.utc) for instance_dir in CARD_INSTANCES_DIR.iterdir(): if not instance_dir.is_dir(): continue @@ -295,6 +384,9 @@ def _load_cards() -> list[dict[str, Any]]: continue if card.get("state") == "archived": 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) return _sort_cards(cards) @@ -817,6 +909,62 @@ async def delete_card(card_id: str) -> JSONResponse: 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") async def get_templates() -> JSONResponse: return JSONResponse(_list_templates()) @@ -975,8 +1123,15 @@ async def _sender_loop( ) -> None: while True: 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": - await voice_session.queue_output_text(event.text) + for segment in _chunk_tts_text(event.text): + await voice_session.queue_output_text(segment) continue typed_event = _to_typed_message(event.to_dict()) if typed_event is None: diff --git a/examples/cards/instances/live-calorie-tracker/card.json b/examples/cards/instances/live-calorie-tracker/card.json new file mode 100644 index 0000000..21ab6b0 --- /dev/null +++ b/examples/cards/instances/live-calorie-tracker/card.json @@ -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" +} diff --git a/examples/cards/instances/live-calorie-tracker/state.json b/examples/cards/instances/live-calorie-tracker/state.json new file mode 100644 index 0000000..4786c11 --- /dev/null +++ b/examples/cards/instances/live-calorie-tracker/state.json @@ -0,0 +1,9 @@ +{ + "left_label": "Cal", + "right_label": "Food", + "total_label": "Total", + "total_suffix": "cal", + "max_digits": 4, + "score": 76, + "rows": [] +} diff --git a/examples/cards/instances/live-weather-01545/state.json b/examples/cards/instances/live-weather-01545/state.json index df80a06..27db94e 100644 --- a/examples/cards/instances/live-weather-01545/state.json +++ b/examples/cards/instances/live-weather-01545/state.json @@ -1,10 +1,12 @@ { "title": "Weather 01545", - "subtitle": "OpenWeatherMap live context", + "subtitle": "Weather", "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", "temperature_name": "OpenWeatherMap Temperature", "humidity_name": "OpenWeatherMap Humidity", - "condition_label": "OpenWeatherMap live context", + "condition_label": "Weather", "refresh_ms": 86400000 } diff --git a/examples/cards/templates/list-total-live/manifest.json b/examples/cards/templates/list-total-live/manifest.json new file mode 100644 index 0000000..990d4cc --- /dev/null +++ b/examples/cards/templates/list-total-live/manifest.json @@ -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" +} diff --git a/examples/cards/templates/list-total-live/template.html b/examples/cards/templates/list-total-live/template.html new file mode 100644 index 0000000..34465c2 --- /dev/null +++ b/examples/cards/templates/list-total-live/template.html @@ -0,0 +1,336 @@ + + +
+
+
Value
+
Item
+
+
+
+
+
Total
+
0
+
+
+ + diff --git a/examples/cards/templates/todo-item-live/template.html b/examples/cards/templates/todo-item-live/template.html index 9b3ab52..f96f326 100644 --- a/examples/cards/templates/todo-item-live/template.html +++ b/examples/cards/templates/todo-item-live/template.html @@ -235,6 +235,8 @@ letter-spacing: 0.005em; color: #624d40; opacity: 0.95; + white-space: pre-wrap; + overflow-wrap: anywhere; cursor: pointer; touch-action: manipulation; } @@ -324,175 +326,43 @@ text-align: left; } - .task-card__editor-overlay { - display: none; - position: fixed; - 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__title--editing, + .task-card__body--editing { + cursor: text; } - .task-card__editor-sheet { + .task-card__inline-editor { + display: block; width: 100%; - max-width: 100%; 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; - overflow: hidden; - } - - .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; + margin: 0; + padding: 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; - } - - .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; - font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', sans-serif; - font-size: 0.94rem; - line-height: 1.36; - font-weight: 400; - letter-spacing: 0.004em; + overflow: hidden; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + letter-spacing: inherit; + border-radius: 0; + box-shadow: none; } - .task-card__editor-input:focus, - .task-card__editor-textarea:focus { - border-color: rgba(88, 112, 111, 0.48); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), - 0 0 0 3px rgba(88, 112, 111, 0.12); + .task-card__inline-editor::placeholder { + color: rgba(98, 77, 64, 0.6); + opacity: 1; + font-style: italic; } - .task-card__editor-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - min-width: 0; - flex-wrap: wrap; + .task-card__inline-editor--title { + min-height: 1.2em; } - .task-card__editor-action-row { - display: flex; - 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; + .task-card__inline-editor--body { + min-height: 1.34em; } @@ -759,49 +629,7 @@ } }; - const editorOverlayEl = doc.createElement('div'); - editorOverlayEl.className = 'task-card__editor-overlay'; - editorOverlayEl.innerHTML = ` - - `; - 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; + let activeInlineEdit = null; const setBusy = (busy) => { laneToggleEl.disabled = busy || !taskPath; @@ -811,11 +639,11 @@ for (const button of tagsEl.querySelectorAll('button')) { if (button instanceof HTMLButtonElement) button.disabled = busy; } - editorTitleInputEl.disabled = busy; - editorBodyInputEl.disabled = busy; - editorCloseEl.disabled = busy; - editorCancelEl.disabled = busy; - editorSaveEl.disabled = busy; + summaryEl.style.pointerEvents = busy ? 'none' : ''; + descriptionEl.style.pointerEvents = busy ? 'none' : ''; + if (activeInlineEdit?.input instanceof HTMLTextAreaElement) { + activeInlineEdit.input.disabled = busy; + } }; const closeMoveMenu = () => { @@ -858,26 +686,97 @@ window.dispatchEvent(new Event('nanobot:cards-refresh')); }; - const closeEditor = () => { - editorOverlayEl.style.display = 'none'; + const autosizeInlineEditor = (editor, minHeight = 0) => { + 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') => { - if (!taskPath) return; + const beginInlineEdit = (field) => { + if (!taskPath || activeInlineEdit) return; closeMoveMenu(); - editorTitleInputEl.value = title; - editorBodyInputEl.value = body; - if (editorOverlayEl.parentElement !== doc.body) { - doc.body.appendChild(editorOverlayEl); - } - editorOverlayEl.style.display = 'block'; - view.requestAnimationFrame(() => { - const target = focusField === 'description' ? editorBodyInputEl : editorTitleInputEl; - target.focus(); - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { - const end = target.value.length; - target.setSelectionRange(end, end); + const host = field === 'title' ? summaryEl : descriptionEl; + const currentValue = field === 'title' ? title : body; + const editor = document.createElement('textarea'); + 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; } + 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(() => { + editor.focus(); + const end = editor.value.length; + editor.setSelectionRange(end, end); }); }; @@ -958,13 +857,14 @@ if (payload && typeof payload === 'object' && payload.error) { throw new Error(String(payload.error)); } - closeEditor(); refreshCards(); + return true; } catch (error) { console.error('Task edit failed', error); setBusy(false); setStatus('Unavailable', '#8e3023', '#f3d3cc'); publishLiveContent(lane, true, String(error)); + return false; } }; @@ -1094,48 +994,16 @@ 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) => { event.preventDefault(); event.stopPropagation(); - openEditor('title'); + beginInlineEdit('title'); }); descriptionEl.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); - openEditor('description'); + beginInlineEdit('description'); }); const render = () => { diff --git a/examples/cards/templates/weather-live/manifest.json b/examples/cards/templates/weather-live/manifest.json index 21ab6b1..40bcf75 100644 --- a/examples/cards/templates/weather-live/manifest.json +++ b/examples/cards/templates/weather-live/manifest.json @@ -3,12 +3,14 @@ "title": "Live Weather", "notes": "Live weather summary card. Fill template_state with subtitle, tool_name (defaults to Home Assistant GetLiveContext), provider_prefix or exact sensor names, optional condition_label, and refresh_ms. Wind and pressure render when matching sensors exist in the live context payload.", "example_state": { - "subtitle": "OpenWeatherMap live context", + "subtitle": "Weather", "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", "temperature_name": "OpenWeatherMap Temperature", "humidity_name": "OpenWeatherMap Humidity", - "condition_label": "OpenWeatherMap live context", + "condition_label": "Weather", "refresh_ms": 86400000 }, "created_at": "2026-03-11T04:12:48.601255+00:00", diff --git a/examples/cards/templates/weather-live/template.html b/examples/cards/templates/weather-live/template.html index 7dc4f6f..925deb6 100644 --- a/examples/cards/templates/weather-live/template.html +++ b/examples/cards/templates/weather-live/template.html @@ -1,4 +1,13 @@
+
Loading…
Loading… @@ -9,24 +18,22 @@ °F
-
--
-
-
Humidity
+
󰖎
--
-
Wind
+
--
-
Pressure
-
--
+
+
--
-
Updated
-
--
+
+
--
@@ -40,22 +47,20 @@ const subtitleEl = root.querySelector('[data-weather-subtitle]'); const tempEl = root.querySelector('[data-weather-temp]'); const unitEl = root.querySelector('[data-weather-unit]'); - const condEl = root.querySelector('[data-weather-condition]'); const humidityEl = root.querySelector('[data-weather-humidity]'); const windEl = root.querySelector('[data-weather-wind]'); - const pressureEl = root.querySelector('[data-weather-pressure]'); - const updatedEl = root.querySelector('[data-weather-updated]'); + const rainEl = root.querySelector('[data-weather-rain]'); + const uvEl = root.querySelector('[data-weather-uv]'); 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 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 temperatureName = typeof state.temperature_name === 'string' ? state.temperature_name.trim() : ''; const humidityName = typeof state.humidity_name === 'string' ? state.humidity_name.trim() : ''; - const pressureName = typeof state.pressure_name === 'string' ? state.pressure_name.trim() : ''; - const windName = typeof state.wind_name === 'string' ? state.wind_name.trim() : ''; - const conditionLabel = typeof state.condition_label === 'string' ? state.condition_label.trim() : ''; const refreshMsRaw = Number(state.refresh_ms); const refreshMs = Number.isFinite(refreshMsRaw) && refreshMsRaw >= 60000 ? refreshMsRaw : 24 * 60 * 60 * 1000; @@ -162,6 +167,18 @@ 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 () => { if (configuredToolName) return configuredToolName; if (!window.__nanobotListTools) return 'mcp_home_assistant_GetLiveContext'; @@ -187,24 +204,41 @@ }) || 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 resolvedToolName = await resolveToolName(); if (!resolvedToolName) { const errorText = 'Missing tool_name'; setStatus('No tool', '#b91c1c'); - updatedEl.textContent = errorText; updateLiveContent({ kind: 'weather', subtitle: subtitleEl.textContent || null, tool_name: null, temperature: null, temperature_unit: String(state.unit || '°F'), - condition: null, humidity: null, wind: null, - pressure: null, + rain: null, + uv: null, status: 'No tool', - updated_at: errorText, error: errorText, }); return; @@ -212,7 +246,10 @@ setStatus('Refreshing', '#6b7280'); 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 prefix = providerPrefix || 'OpenWeatherMap'; const temperatureEntry = findEntry(entries, [ @@ -223,32 +260,29 @@ humidityName, `${prefix} Humidity`, ]); - const pressureEntry = findEntry(entries, [ - pressureName, - `${prefix} Pressure`, - ]); - const windEntry = findEntry(entries, [ - windName, - `${prefix} Wind speed`, - `${prefix} Wind`, - ]); const temperature = Number(temperatureEntry?.state); tempEl.textContent = Number.isFinite(temperature) ? String(Math.round(temperature)) : '--'; unitEl.textContent = String(temperatureEntry?.attributes?.unit_of_measurement || state.unit || '°F'); - condEl.textContent = conditionLabel || `${prefix || 'Weather'} live context`; + const humidity = Number(humidityEntry?.state); humidityEl.textContent = Number.isFinite(humidity) ? `${Math.round(humidity)}%` : '--'; - const windSpeed = Number(windEntry?.state); - const windUnit = String(windEntry?.attributes?.unit_of_measurement || 'mph'); - windEl.textContent = Number.isFinite(windSpeed) ? `${windSpeed} ${windUnit}` : '--'; - const pressure = Number(pressureEntry?.state); - pressureEl.textContent = Number.isFinite(pressure) - ? `${pressure} ${String(pressureEntry?.attributes?.unit_of_measurement || '').trim()}`.trim() - : '--'; - const updatedText = new Date().toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); - updatedEl.textContent = updatedText; - subtitleEl.textContent = subtitle || prefix || 'Home Assistant live context'; + + const nwsEntry = firstForecastEntry(forecastBundle, 'nws'); + const uvEntry = firstForecastEntry(forecastBundle, 'uv', 'uv_index'); + const nwsSource = forecastBundle && typeof forecastBundle === 'object' && forecastBundle.nws && typeof forecastBundle.nws === 'object' ? forecastBundle.nws : null; + + const windSpeed = Number(nwsEntry?.wind_speed); + const windUnit = String(nwsSource?.wind_speed_unit || 'mph'); + windEl.textContent = Number.isFinite(windSpeed) ? `${Math.round(windSpeed)} ${windUnit}` : '--'; + + 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'); updateLiveContent({ kind: 'weather', @@ -256,29 +290,32 @@ tool_name: resolvedToolName, temperature: Number.isFinite(temperature) ? Math.round(temperature) : null, temperature_unit: unitEl.textContent || null, - condition: condEl.textContent || null, humidity: Number.isFinite(humidity) ? Math.round(humidity) : null, wind: windEl.textContent || null, - pressure: pressureEl.textContent || null, + rain: rainEl.textContent || null, + uv: Number.isFinite(uvValue) ? Math.round(uvValue) : null, status: 'Live', - updated_at: updatedText, }); } catch (error) { const errorText = String(error); setStatus('Unavailable', '#b91c1c'); - updatedEl.textContent = errorText; + tempEl.textContent = '--'; + unitEl.textContent = String(state.unit || '°F'); + humidityEl.textContent = '--'; + windEl.textContent = '--'; + rainEl.textContent = '--'; + uvEl.textContent = '--'; updateLiveContent({ kind: 'weather', subtitle: subtitleEl.textContent || null, tool_name: resolvedToolName, temperature: null, temperature_unit: unitEl.textContent || null, - condition: null, humidity: null, wind: null, - pressure: null, + rain: null, + uv: null, status: 'Unavailable', - updated_at: errorText, error: errorText, }); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 379d0ff..d0eade0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -438,9 +438,11 @@ export function App() { const { agentStateOverride, handlePointerDown, handlePointerMove, handlePointerUp } = usePTT({ connected: rtc.connected && !rtc.textOnly, + currentAgentState: rtc.agentState, onSendPtt: (pressed) => rtc.sendJson({ type: "voice-ptt", pressed, metadata: selectedCardMetadata() }), onBootstrap: rtc.connect, + onInterrupt: () => rtc.sendJson({ type: "command", command: "reset" }), }); const effectiveAgentState = agentStateOverride ?? rtc.agentState; diff --git a/frontend/src/components/CardFeed.tsx b/frontend/src/components/CardFeed.tsx index adaa9c6..625c0fd 100644 --- a/frontend/src/components/CardFeed.tsx +++ b/frontend/src/components/CardFeed.tsx @@ -1,6 +1,6 @@ import { marked } from "marked"; -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; -import type { CardItem, CardLane, JsonValue } from "../types"; +import { useEffect, useRef, useState } from "preact/hooks"; +import type { CardItem, JsonValue } from "../types"; const EXECUTABLE_SCRIPT_TYPES = new Set([ "", @@ -45,6 +45,391 @@ interface ManualToolAsyncOptions { timeoutMs?: number; } +type TaskLane = "backlog" | "in-progress" | "blocked" | "done" | "canceled"; +interface ListTotalRow { + value: string; + name: string; +} + +interface ListTotalCardState { + leftLabel: string; + rightLabel: string; + totalLabel: string; + totalSuffix: string; + maxDigits: number; + score: number; + rows: ListTotalRow[]; +} + +interface TaskCardState { + taskPath: string; + taskKey: string; + title: string; + lane: TaskLane; + created: string; + updated: string; + due: string; + tags: string[]; + body: string; + metadata: Record; +} + +const TASK_LANES: TaskLane[] = ["backlog", "in-progress", "blocked", "done", "canceled"]; + +const TASK_LANE_LABELS: Record = { + backlog: "Backlog", + "in-progress": "In Progress", + blocked: "Blocked", + done: "Done", + canceled: "Canceled", +}; + +const TASK_ACTION_LABELS: Record = { + backlog: "Backlog", + "in-progress": "Start", + blocked: "Block", + done: "Done", + canceled: "Cancel", +}; + +const TASK_LANE_THEMES: Record< + TaskLane, + { accent: string; accentSoft: string; muted: string; buttonInk: string } +> = { + backlog: { + accent: "#5f7884", + accentSoft: "rgba(95, 120, 132, 0.13)", + muted: "#6b7e87", + buttonInk: "#294a57", + }, + "in-progress": { + accent: "#4f7862", + accentSoft: "rgba(79, 120, 98, 0.13)", + muted: "#5e7768", + buttonInk: "#214437", + }, + blocked: { + accent: "#a55f4b", + accentSoft: "rgba(165, 95, 75, 0.13)", + muted: "#906659", + buttonInk: "#6c2f21", + }, + done: { + accent: "#6d7f58", + accentSoft: "rgba(109, 127, 88, 0.12)", + muted: "#6b755d", + buttonInk: "#304121", + }, + canceled: { + accent: "#7b716a", + accentSoft: "rgba(123, 113, 106, 0.12)", + muted: "#7b716a", + buttonInk: "#433932", + }, +}; + +function isTaskLane(value: unknown): value is TaskLane { + return typeof value === "string" && TASK_LANES.includes(value as TaskLane); +} + +function parseTemplateStateFromContent(content: string): Record | undefined { + if (!content.includes("data-card-state")) return undefined; + try { + const parsed = new DOMParser().parseFromString(content, "text/html"); + const stateEl = parsed.querySelector('script[data-card-state][type="application/json"]'); + if (!(stateEl instanceof HTMLScriptElement)) return undefined; + const payload = JSON.parse(stateEl.textContent || "{}"); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined; + return payload as Record; + } catch { + return undefined; + } +} + +function normalizeTaskTag(raw: string): string { + const trimmed = raw.trim().replace(/^#+/, "").replace(/\s+/g, "-"); + return trimmed ? `#${trimmed}` : ""; +} + +function normalizeTaskTags(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const tags: string[] = []; + for (const value of raw) { + const tag = normalizeTaskTag(String(value || "")); + const key = tag.toLowerCase(); + if (!tag || seen.has(key)) continue; + seen.add(key); + tags.push(tag); + } + return tags; +} + +function normalizeTaskMetadata(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record).filter(([, value]) => value !== undefined), + ); +} + +function sanitizeListTotalValue(raw: unknown, maxDigits: number): string { + return String(raw || "") + .replace(/\D+/g, "") + .slice(0, maxDigits); +} + +function sanitizeListTotalName(raw: unknown): string { + return String(raw || "") + .replace(/\s+/g, " ") + .trimStart(); +} + +function isBlankListTotalRow(row: ListTotalRow | null | undefined): boolean { + if (!row) return true; + return !row.value.trim() && !row.name.trim(); +} + +function ensureTrailingBlankListTotalRow(rows: ListTotalRow[]): ListTotalRow[] { + const next = rows.map((row) => ({ value: row.value, name: row.name })); + if (!next.length || !isBlankListTotalRow(next[next.length - 1])) { + next.push({ value: "", name: "" }); + } + return next; +} + +function normalizeListTotalRows(raw: unknown, maxDigits: number): ListTotalRow[] { + if (!Array.isArray(raw)) return [{ value: "", name: "" }]; + return ensureTrailingBlankListTotalRow( + raw + .filter((row) => row && typeof row === "object" && !Array.isArray(row)) + .map((row) => { + const record = row as Record; + return { + value: sanitizeListTotalValue(record.value, maxDigits), + name: sanitizeListTotalName(record.name), + }; + }), + ); +} + +function normalizeListTotalCardState( + card: Pick, +): ListTotalCardState | null { + if (card.templateKey !== "list-total-live") return null; + const raw = card.templateState; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const maxDigits = Math.max( + 1, + Math.min( + 4, + Number.isFinite(Number((raw as Record).max_digits)) + ? Number((raw as Record).max_digits) + : 4, + ), + ); + return { + leftLabel: + typeof (raw as Record).left_label === "string" && + String((raw as Record).left_label).trim() + ? String((raw as Record).left_label).trim() + : "Value", + rightLabel: + typeof (raw as Record).right_label === "string" && + String((raw as Record).right_label).trim() + ? String((raw as Record).right_label).trim() + : "Item", + totalLabel: + typeof (raw as Record).total_label === "string" && + String((raw as Record).total_label).trim() + ? String((raw as Record).total_label).trim() + : "Total", + totalSuffix: + typeof (raw as Record).total_suffix === "string" + ? String((raw as Record).total_suffix).trim() + : "", + maxDigits, + score: + typeof (raw as Record).score === "number" && + Number.isFinite((raw as Record).score as number) + ? Math.max(0, Math.min(100, (raw as Record).score as number)) + : 24, + rows: normalizeListTotalRows((raw as Record).rows, maxDigits), + }; +} + +function normalizeTaskCardState( + card: Pick, +): TaskCardState | null { + if (card.templateKey !== "todo-item-live") return null; + const raw = card.templateState ?? parseTemplateStateFromContent(card.content); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const taskPath = typeof raw.task_path === "string" ? raw.task_path.trim() : ""; + const lane = isTaskLane(raw.lane) ? raw.lane : "backlog"; + return { + taskPath, + taskKey: typeof raw.task_key === "string" ? raw.task_key.trim() : "", + title: + typeof raw.title === "string" && raw.title.trim() + ? raw.title.trim() + : card.title || "(Untitled task)", + lane, + created: typeof raw.created === "string" ? raw.created.trim() : "", + updated: typeof raw.updated === "string" ? raw.updated.trim() : "", + due: typeof raw.due === "string" ? raw.due.trim() : "", + tags: normalizeTaskTags(raw.tags), + body: typeof raw.body === "string" ? raw.body : "", + metadata: normalizeTaskMetadata(raw.metadata), + }; +} + +function normalizeTaskFromPayload(raw: unknown, fallback: TaskCardState): TaskCardState { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fallback; + const record = raw as Record; + return { + taskPath: typeof record.path === "string" ? record.path.trim() : fallback.taskPath, + taskKey: fallback.taskKey, + title: + typeof record.title === "string" && record.title.trim() + ? record.title.trim() + : fallback.title, + lane: isTaskLane(record.lane) ? record.lane : fallback.lane, + created: typeof record.created === "string" ? record.created.trim() : fallback.created, + updated: typeof record.updated === "string" ? record.updated.trim() : fallback.updated, + due: typeof record.due === "string" ? record.due.trim() : "", + tags: normalizeTaskTags(record.tags), + body: typeof record.body === "string" ? record.body : fallback.body, + metadata: normalizeTaskMetadata(record.metadata), + }; +} + +function dueScore(hoursUntilDue: number): number { + if (hoursUntilDue <= 0) return 100; + if (hoursUntilDue <= 6) return 96; + if (hoursUntilDue <= 24) return 92; + if (hoursUntilDue <= 72) return 82; + if (hoursUntilDue <= 168) return 72; + return 62; +} + +function ageScore(ageDays: number): number { + if (ageDays >= 30) return 80; + if (ageDays >= 21) return 76; + if (ageDays >= 14) return 72; + if (ageDays >= 7) return 68; + if (ageDays >= 3) return 62; + if (ageDays >= 1) return 58; + return 54; +} + +function computeTaskScore(task: TaskCardState): number { + const now = Date.now(); + const rawDue = task.due ? (task.due.includes("T") ? task.due : `${task.due}T12:00:00`) : ""; + const dueMs = rawDue ? new Date(rawDue).getTime() : Number.NaN; + let score = 54; + if (Number.isFinite(dueMs)) { + const hoursUntilDue = (dueMs - now) / (60 * 60 * 1000); + score = dueScore(hoursUntilDue); + } else { + const createdMs = task.created ? new Date(task.created).getTime() : Number.NaN; + if (Number.isFinite(createdMs)) { + const ageDays = Math.max(0, (now - createdMs) / (24 * 60 * 60 * 1000)); + score = ageScore(ageDays); + } + } + if (task.lane === "blocked") return Math.min(100, score + 4); + if (task.lane === "in-progress") return Math.min(100, score + 2); + return score; +} + +function summarizeTaskBody(task: TaskCardState): string { + const trimmed = task.body.trim(); + if (!trimmed || /^##\s+Imported\b/i.test(trimmed)) return ""; + return trimmed; +} + +function renderTaskBodyMarkdown(body: string): string { + if (!body) return ""; + return body + .replace(/\r\n?/g, "\n") + .trim() + .split("\n") + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) return ''; + + let className = "task-card-ui__md-line"; + let content = trimmed; + let prefix = ""; + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + className += " task-card-ui__md-line--heading"; + content = headingMatch[2]; + } else if (/^[-*]\s+/.test(trimmed)) { + className += " task-card-ui__md-line--bullet"; + content = trimmed.replace(/^[-*]\s+/, ""); + prefix = "\u2022 "; + } else if (/^\d+\.\s+/.test(trimmed)) { + className += " task-card-ui__md-line--bullet"; + content = trimmed.replace(/^\d+\.\s+/, ""); + prefix = "\u2022 "; + } else if (/^>\s+/.test(trimmed)) { + className += " task-card-ui__md-line--quote"; + content = trimmed.replace(/^>\s+/, ""); + prefix = "> "; + } + + const html = marked.parseInline(content, { gfm: true, breaks: true }) as string; + return `${ + prefix ? `${prefix}` : "" + }${html}`; + }) + .join(""); +} + +function formatTaskDue(task: TaskCardState): string { + if (!task.due) return ""; + const raw = task.due.includes("T") ? task.due : `${task.due}T00:00:00`; + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) return task.due; + if (task.due.includes("T")) { + const label = parsed.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return label.replace(/\s([AP]M)$/i, "$1"); + } + return parsed.toLocaleDateString([], { month: "short", day: "numeric" }); +} + +function taskMoveOptions(lane: TaskLane): Array<{ lane: TaskLane; label: string }> { + return TASK_LANES.filter((targetLane) => targetLane !== lane).map((targetLane) => ({ + lane: targetLane, + label: TASK_ACTION_LABELS[targetLane], + })); +} + +function taskCardLiveContent(task: TaskCardState, errorText: string): Record { + return { + kind: "file_task", + exists: true, + task_path: task.taskPath || null, + task_key: task.taskKey || null, + title: task.title || null, + lane: task.lane, + created: task.created || null, + updated: task.updated || null, + due: task.due || null, + tags: task.tags, + metadata: task.metadata, + score: computeTaskScore(task), + status: task.lane, + error: errorText || null, + }; +} + function cloneJsonValue(value: T | null | undefined): T | undefined { if (value === null || value === undefined) return undefined; try { @@ -54,6 +439,25 @@ function cloneJsonValue(value: T | null | undefined): T | u } } +function parseToolPayload( + result: ManualToolResult | null | undefined, +): Record | null { + const payload = result?.parsed; + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + return payload as Record; + } + const raw = typeof result?.content === "string" ? result.content : ""; + if (!raw.trim()) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + function readCardState(script: HTMLScriptElement | null): Record { const root = script?.closest("[data-nanobot-card-root]"); if (!(root instanceof HTMLElement)) return {}; @@ -163,6 +567,24 @@ function clearCardSelection(cardId: string | null | undefined): void { dispatchCardSelectionChange(key, undefined); } +function nextLocalMidnightIso(): string { + const tomorrow = new Date(); + tomorrow.setHours(24, 0, 0, 0); + return tomorrow.toISOString(); +} + +async function snoozeCardUntilTomorrow(cardId: string | null | undefined): Promise { + const key = (cardId || "").trim(); + if (!key) throw new Error("card id is required"); + const resp = await fetch(`/cards/${encodeURIComponent(key)}/snooze`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ until: nextLocalMidnightIso() }), + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); + window.dispatchEvent(new Event("nanobot:cards-refresh")); +} + async function decodeJsonError(resp: Response): Promise { try { const payload = (await resp.json()) as { error?: unknown }; @@ -175,6 +597,20 @@ async function decodeJsonError(resp: Response): Promise { return `request failed (${resp.status})`; } +async function updateCardTemplateState( + cardId: string, + templateState: Record, +): Promise { + const key = cardId.trim(); + if (!key) throw new Error("card id is required"); + const resp = await fetch(`/cards/${encodeURIComponent(key)}/state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template_state: templateState }), + }); + if (!resp.ok) throw new Error(await decodeJsonError(resp)); +} + function normalizeManualToolResult( payload: Partial | null | undefined, fallbackName: string, @@ -437,30 +873,6 @@ declare global { } } -const LANE_TITLES: Record = { - attention: "Attention", - work: "Work", - context: "Context", - history: "History", -}; -const LANE_ORDER: CardLane[] = ["attention", "work", "context", "history"]; -const LANE_RANK: Record = { - attention: 0, - work: 1, - context: 2, - history: 3, -}; - -function readCardScore(card: CardItem): number { - if (!card.serverId) return card.priority; - const liveContent = window.__nanobotGetCardLiveContent?.(card.serverId); - if (!liveContent || typeof liveContent !== "object" || Array.isArray(liveContent)) { - return card.priority; - } - const score = (liveContent as Record).score; - return typeof score === "number" && Number.isFinite(score) ? score : card.priority; -} - interface CardProps { card: CardItem; onDismiss(id: number): void; @@ -544,18 +956,1088 @@ function CardQuestionBody({ ); } +type TaskEditField = "title" | "body"; +type TaskStatusKind = "error" | "neutral"; + +function autosizeTaskEditor(editor: HTMLTextAreaElement | null): void { + if (!editor) return; + editor.style.height = "0px"; + editor.style.height = `${Math.max(editor.scrollHeight, 20)}px`; +} + +async function callTaskBoardAction( + argumentsValue: Record, +): Promise | null> { + const result = await callManualTool("task_board", argumentsValue); + const payload = parseToolPayload(result); + if (payload && typeof payload.error === "string" && payload.error.trim()) { + throw new Error(payload.error); + } + return payload; +} + +function useTaskCardState(card: CardItem) { + const initialTask = normalizeTaskCardState(card); + const [task, setTask] = useState(initialTask); + const [busy, setBusy] = useState(false); + const [statusLabel, setStatusLabel] = useState(""); + const [statusKind, setStatusKind] = useState("neutral"); + const [errorText, setErrorText] = useState(""); + const [laneMenuOpen, setLaneMenuOpen] = useState(false); + const [editingField, setEditingField] = useState(null); + const [draftTitle, setDraftTitle] = useState(initialTask?.title ?? ""); + const [draftBody, setDraftBody] = useState(initialTask?.body ?? ""); + const [holdingTag, setHoldingTag] = useState(null); + + useEffect(() => { + const nextTask = normalizeTaskCardState(card); + setTask(nextTask); + setBusy(false); + setStatusLabel(""); + setStatusKind("neutral"); + setErrorText(""); + setLaneMenuOpen(false); + setEditingField(null); + setDraftTitle(nextTask?.title ?? ""); + setDraftBody(nextTask?.body ?? ""); + }, [card.serverId, card.updatedAt]); + + return { + task, + setTask, + busy, + setBusy, + statusLabel, + setStatusLabel, + statusKind, + setStatusKind, + errorText, + setErrorText, + laneMenuOpen, + setLaneMenuOpen, + editingField, + setEditingField, + draftTitle, + setDraftTitle, + draftBody, + setDraftBody, + holdingTag, + setHoldingTag, + }; +} + +function useTaskCardEffects({ + rootRef, + laneMenuRef, + titleEditorRef, + bodyEditorRef, + holdTimerRef, + task, + errorText, + laneMenuOpen, + editingField, + closeLaneMenu, +}: { + rootRef: { current: HTMLDivElement | null }; + laneMenuRef: { current: HTMLDivElement | null }; + titleEditorRef: { current: HTMLTextAreaElement | null }; + bodyEditorRef: { current: HTMLTextAreaElement | null }; + holdTimerRef: { current: number | null }; + task: TaskCardState | null; + errorText: string; + laneMenuOpen: boolean; + editingField: TaskEditField | null; + closeLaneMenu(): void; +}) { + useEffect(() => { + ensureCardStateHelper(); + }, []); + + useEffect(() => { + return () => { + if (holdTimerRef.current !== null) { + window.clearTimeout(holdTimerRef.current); + } + window.__nanobotSetCardLiveContent?.(rootRef.current, null); + window.__nanobotSetCardSelection?.(rootRef.current, null); + window.__nanobotSetCardRefresh?.(rootRef.current, null); + }; + }, []); + + useEffect(() => { + if (!laneMenuOpen) return; + const handlePointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !laneMenuRef.current?.contains(event.target)) { + closeLaneMenu(); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closeLaneMenu(); + }; + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [laneMenuOpen, closeLaneMenu]); + + useEffect(() => { + const editor = editingField === "title" ? titleEditorRef.current : bodyEditorRef.current; + if (!editor) return; + autosizeTaskEditor(editor); + editor.focus(); + const end = editor.value.length; + editor.setSelectionRange(end, end); + }, [editingField]); + + useEffect(() => { + window.__nanobotSetCardLiveContent?.( + rootRef.current, + task ? taskCardLiveContent(task, errorText) : null, + ); + }, [task, errorText]); +} + +function createTagHoldHandlers({ + setHoldingTag, + holdTimerRef, + removeTag, +}: { + setHoldingTag: (value: string | null) => void; + holdTimerRef: { current: number | null }; + removeTag: (tag: string) => Promise; +}) { + const clearTagHold = () => { + if (holdTimerRef.current !== null) { + window.clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + setHoldingTag(null); + }; + + const beginTagHold = (tag: string, busy: boolean) => { + if (busy) return; + clearTagHold(); + setHoldingTag(tag); + holdTimerRef.current = window.setTimeout(() => { + holdTimerRef.current = null; + setHoldingTag(null); + if (window.confirm(`Remove ${tag} from this task?`)) void removeTag(tag); + }, 650); + }; + + return { beginTagHold, clearTagHold }; +} + +async function runTaskBusyAction( + setBusy: (value: boolean) => void, + setStatusLabel: (value: string) => void, + setStatusKind: (value: TaskStatusKind) => void, + setErrorText: (value: string) => void, + action: () => Promise, +) { + setBusy(true); + setStatusKind("neutral"); + setStatusLabel("Saving"); + setErrorText(""); + try { + await action(); + setBusy(false); + setStatusLabel(""); + } catch (error) { + console.error("Task card action failed", error); + setBusy(false); + setStatusKind("error"); + setStatusLabel("Unavailable"); + setErrorText(error instanceof Error ? error.message : String(error)); + } +} + +function createTaskCardActions({ + task, + setTask, + setBusy, + setStatusLabel, + setStatusKind, + setErrorText, + setLaneMenuOpen, + setEditingField, + setDraftTitle, + setDraftBody, + setHoldingTag, + holdTimerRef, + titleEditorRef, +}: { + task: TaskCardState; + setTask: ( + value: TaskCardState | ((current: TaskCardState | null) => TaskCardState | null), + ) => void; + setBusy: (value: boolean) => void; + setStatusLabel: (value: string) => void; + setStatusKind: (value: TaskStatusKind) => void; + setErrorText: (value: string) => void; + setLaneMenuOpen: (value: boolean | ((current: boolean) => boolean)) => void; + setEditingField: (value: TaskEditField | null) => void; + setDraftTitle: (value: string) => void; + setDraftBody: (value: string) => void; + setHoldingTag: (value: string | null) => void; + holdTimerRef: { current: number | null }; + titleEditorRef: { current: HTMLTextAreaElement | null }; +}) { + const closeLaneMenu = () => setLaneMenuOpen(false); + const refreshCards = () => { + closeLaneMenu(); + window.dispatchEvent(new Event("nanobot:cards-refresh")); + }; + + const moveTask = async (lane: TaskLane) => + runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { + const payload = await callTaskBoardAction({ action: "move", task: task.taskPath, lane }); + const nextPath = + typeof payload?.task_path === "string" ? payload.task_path.trim() : task.taskPath; + setTask((current) => + current + ? { ...current, taskPath: nextPath, lane, updated: new Date().toISOString() } + : current, + ); + refreshCards(); + }); + + const editField = async (field: TaskEditField, rawValue: string) => { + const nextValue = rawValue.trim(); + if (field === "title" && !nextValue) { + titleEditorRef.current?.focus(); + return; + } + const currentValue = field === "title" ? task.title : task.body; + if (nextValue === currentValue) { + setEditingField(null); + return; + } + await runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { + const payload = await callTaskBoardAction({ + action: "edit", + task: task.taskPath, + ...(field === "title" ? { title: nextValue } : { description: nextValue }), + }); + const nextTask = normalizeTaskFromPayload(payload?.task, { + ...task, + ...(field === "title" ? { title: nextValue } : { body: nextValue }), + }); + setTask(nextTask); + setDraftTitle(nextTask.title); + setDraftBody(nextTask.body); + setEditingField(null); + refreshCards(); + }); + }; + + const addTag = async () => { + const raw = window.prompt("Add tag to task", ""); + const tag = raw == null ? "" : normalizeTaskTag(raw); + if (!tag) return; + await runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { + const payload = await callTaskBoardAction({ + action: "add_tag", + task: task.taskPath, + tags: [tag], + }); + const nextTask = normalizeTaskFromPayload(payload?.task, { + ...task, + tags: Array.from(new Set([...task.tags, tag])), + }); + setTask(nextTask); + refreshCards(); + }); + }; + + const removeTag = async (tag: string) => + runTaskBusyAction(setBusy, setStatusLabel, setStatusKind, setErrorText, async () => { + const payload = await callTaskBoardAction({ + action: "remove_tag", + task: task.taskPath, + tags: [tag], + }); + const nextTask = normalizeTaskFromPayload(payload?.task, { + ...task, + tags: task.tags.filter((value) => value !== tag), + }); + setTask(nextTask); + refreshCards(); + }); + + const { beginTagHold, clearTagHold } = createTagHoldHandlers({ + setHoldingTag, + holdTimerRef, + removeTag, + }); + + return { addTag, beginTagHold, clearTagHold, closeLaneMenu, editField, moveTask, refreshCards }; +} + +function useTaskCardRefs() { + return { + rootRef: useRef(null), + laneMenuRef: useRef(null), + titleEditorRef: useRef(null), + bodyEditorRef: useRef(null), + holdTimerRef: useRef(null), + }; +} + +function TaskCardHeaderRow({ + lane, + taskPath, + laneMenuOpen, + statusLabel, + statusKind, + busy, + laneMenuRef, + onToggleMenu, + onMove, +}: { + lane: TaskLane; + taskPath: string; + laneMenuOpen: boolean; + statusLabel: string; + statusKind: TaskStatusKind; + busy: boolean; + laneMenuRef: { current: HTMLDivElement | null }; + onToggleMenu(): void; + onMove(lane: TaskLane): void; +}) { + return ( +
+
+ + {laneMenuOpen && ( + + )} +
+ {statusLabel ? ( + + {statusLabel} + + ) : null} +
+ ); +} + +function TaskCardTitleField({ + task, + busy, + editingField, + draftTitle, + titleEditorRef, + onDraftTitle, + onStartEdit, + onSave, + onCancel, +}: { + task: TaskCardState; + busy: boolean; + editingField: TaskEditField | null; + draftTitle: string; + titleEditorRef: { current: HTMLTextAreaElement | null }; + onDraftTitle(value: string): void; + onStartEdit(): void; + onSave(): void; + onCancel(): void; +}) { + if (editingField === "title") { + return ( +