diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml
index ad82e7de1..155be63d1 100644
--- a/.github/workflows/python-tests.yml
+++ b/.github/workflows/python-tests.yml
@@ -75,6 +75,18 @@ jobs:
defaults:
run:
working-directory: backends/advanced
+ services:
+ # Some backend tests hit real local services (vault locks are Redis-backed
+ # and fail closed; test_leading_silence_trim_db uses Mongo). Match the
+ # defaults the code assumes: redis://localhost:6379, mongodb://localhost:27018.
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6379:6379
+ mongo:
+ image: mongo:8
+ ports:
+ - 27018:27017
steps:
- name: Checkout code
@@ -90,6 +102,11 @@ jobs:
with:
version: "latest"
+ - name: Install system dependencies
+ # libopus0: native library behind opuslib (services/device_audio.py),
+ # imported transitively during test collection.
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libopus0
+
- name: Install test dependencies
run: uv sync --locked --group test
diff --git a/.github/workflows/robot-tests.yml b/.github/workflows/robot-tests.yml
index 1d3fffa79..9eb226923 100644
--- a/.github/workflows/robot-tests.yml
+++ b/.github/workflows/robot-tests.yml
@@ -100,6 +100,15 @@ jobs:
echo "✓ Logs saved to backends/advanced/logs/"
ls -lh logs/
+ - name: Upload service logs
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: robot-service-logs
+ path: backends/advanced/logs/
+ if-no-files-found: warn
+ retention-days: 14
+
- name: Check if test results exist
if: always()
id: check_results
diff --git a/backends/advanced/docker-compose-test.yml b/backends/advanced/docker-compose-test.yml
index 6bf75b87b..317a6995a 100644
--- a/backends/advanced/docker-compose-test.yml
+++ b/backends/advanced/docker-compose-test.yml
@@ -62,6 +62,11 @@ services:
- LANGFUSE_BASE_URL=http://langfuse-web-test:3000
- LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key
- LANGFUSE_SECRET_KEY=sk-lf-test-secret-key
+ extra_hosts:
+ # Mock services (LLM/STT) publish ports on the host and are addressed as
+ # host.docker.internal. Docker on Linux (CI) does not resolve that name
+ # without an explicit host-gateway mapping; podman resolves it natively.
+ - "host.docker.internal:host-gateway"
depends_on:
mongo-test:
condition: service_healthy
@@ -383,9 +388,16 @@ services:
- LANGFUSE_BASE_URL=http://langfuse-web-test:3000
- LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key
- LANGFUSE_SECRET_KEY=sk-lf-test-secret-key
+ extra_hosts:
+ # See chronicle-backend-test: required for Docker-on-Linux (CI) to reach
+ # the mock LLM/STT services published on the host.
+ - "host.docker.internal:host-gateway"
depends_on:
- chronicle-backend-test:
- condition: service_healthy
+ # NOTE: no dependency on chronicle-backend-test. The backend's /readiness
+ # healthcheck requires the worker fleet heartbeat in Redis, so gating the
+ # workers on backend health deadlocks startup (backend waits for workers,
+ # workers wait for backend). Matches prod compose: workers only need
+ # mongo + redis.
mongo-test:
condition: service_healthy
redis-test:
diff --git a/backends/advanced/pyproject.toml b/backends/advanced/pyproject.toml
index dec87b445..66f519b86 100644
--- a/backends/advanced/pyproject.toml
+++ b/backends/advanced/pyproject.toml
@@ -104,6 +104,9 @@ filterwarnings = [
"ignore::UserWarning",
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
+ # opuslib 3.x uses `is not 0`, a SyntaxWarning on py3.12 that "error" would
+ # promote to SyntaxError on first (uncached) import. Third-party; not ours.
+ "ignore::SyntaxWarning:.*opuslib.*",
]
[tool.coverage.run]
diff --git a/backends/advanced/src/advanced_omi_backend/config_loader.py b/backends/advanced/src/advanced_omi_backend/config_loader.py
index e91f71594..df263d98b 100644
--- a/backends/advanced/src/advanced_omi_backend/config_loader.py
+++ b/backends/advanced/src/advanced_omi_backend/config_loader.py
@@ -16,6 +16,11 @@
# Global config cache
_config_cache: Optional[DictConfig] = None
+# Runtime overrides registered by save_config_section(), keyed by section path.
+# Re-applied on every load_config() so they survive cache reloads even when
+# CONFIG_FILE points away from config.yml (test environments).
+_runtime_overrides: dict = {}
+
def get_config_dir() -> Path:
"""Get config directory path (single source of truth)."""
@@ -117,6 +122,14 @@ def load_config(force_reload: bool = False) -> DictConfig:
f"{[m.get('name') for m in extra_defaults]}"
)
+ # Re-apply runtime overrides saved via save_config_section(). When
+ # CONFIG_FILE points away from config.yml (test environments), the saved
+ # values exist only in memory — without this they silently revert on the
+ # next reload, so a runtime toggle only "took" until some unrelated code
+ # path refreshed the config.
+ for section_path, values in _runtime_overrides.items():
+ OmegaConf.update(merged, section_path, values, merge=True)
+
# Cache result
_config_cache = merged
@@ -182,8 +195,11 @@ def save_config_section(section_path: str, values: dict) -> bool:
try:
config_path = get_config_dir() / "config.yml"
- # Load existing config
- existing_config = {}
+ # Load existing config. Must be a DictConfig even when the file doesn't
+ # exist yet — OmegaConf.update() raises "Unexpected type" on a plain
+ # dict, which made the very first runtime settings save fail silently
+ # on installs without a config.yml.
+ existing_config = OmegaConf.create({})
if config_path.exists():
existing_config = OmegaConf.load(config_path)
@@ -193,13 +209,16 @@ def save_config_section(section_path: str, values: dict) -> bool:
# Save back to file
OmegaConf.save(existing_config, config_path)
- # Reload config from the primary config file (CONFIG_FILE env var)
- merged = reload_config()
+ # Register a runtime override BEFORE reloading: when CONFIG_FILE points
+ # to a different file than config.yml (test configs), the value we just
+ # saved is not in the file load_config() reads, so it must be re-applied
+ # on every load — a one-shot in-memory patch would silently revert on
+ # the next reload_config() from any code path.
+ _runtime_overrides[section_path] = values
- # Also apply the values to the in-memory cache directly.
- # This is needed when CONFIG_FILE points to a different file than config.yml
- # (e.g., test configs), so the saved values still take effect at runtime.
- OmegaConf.update(merged, section_path, values, merge=True)
+ # Reload config from the primary config file (CONFIG_FILE env var);
+ # load_config() re-applies _runtime_overrides on top.
+ reload_config()
logger.info(f"Saved config section '{section_path}' to {config_path}")
return True
diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py
index 3c01891b2..10e36a035 100644
--- a/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py
+++ b/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py
@@ -32,7 +32,9 @@
utcnow,
)
from advanced_omi_backend.models.user import User
-from advanced_omi_backend.services.device_context import request_conversation_context_jobs
+from advanced_omi_backend.services.device_context import (
+ request_conversation_context_jobs,
+)
from advanced_omi_backend.services.memory.vault_manager import ConvDocVaultManager
router = APIRouter(prefix="/device-input", tags=["device-input"])
@@ -180,6 +182,24 @@ async def ingest_activity(
try:
await item.insert()
accepted += 1
+ frame_id = incoming.metadata.get("representative_frame_id")
+ if (
+ frame_id is not None
+ and "screen_context" in source.capabilities
+ and any(
+ incoming.metadata.get(key)
+ for key in ("app_name", "window_name", "text")
+ )
+ ):
+ await DeviceInputJob(
+ user_id=source.user_id,
+ source_id=source.source_id,
+ kind="thumbnail",
+ start_at=incoming.captured_at,
+ end_at=incoming.ended_at,
+ purpose="timeline_thumbnail",
+ payload={"item_id": str(item.id), "frame_id": frame_id},
+ ).insert()
except DuplicateKeyError:
duplicates += 1
existing = await DeviceInputItem.find_one(
@@ -307,6 +327,37 @@ async def complete_job(
return {"ok": True}
+@router.post("/jobs/{job_id}/thumbnail")
+async def complete_thumbnail_job(
+ job_id: str,
+ file: UploadFile = File(...),
+ source: CaptureSource = Depends(_device_source),
+):
+ job = await DeviceInputJob.get(job_id)
+ if job is None or job.source_id != source.source_id or job.kind != "thumbnail":
+ raise HTTPException(status_code=404, detail="Thumbnail job not found")
+ item_id = job.payload.get("item_id")
+ item = await DeviceInputItem.get(item_id) if item_id else None
+ if item is None or item.source_id != source.source_id:
+ raise HTTPException(status_code=404, detail="Timeline item not found")
+ content_type = (file.content_type or "").split(";", 1)[0]
+ if not content_type.startswith("image/"):
+ raise HTTPException(status_code=415, detail="Thumbnail must be an image")
+ data = await file.read(_MAX_IMAGE_BYTES + 1)
+ if len(data) > _MAX_IMAGE_BYTES:
+ raise HTTPException(status_code=413, detail="Thumbnail exceeds the media limit")
+ item.media_data = data
+ item.media_filename = file.filename or "screenpipe-thumbnail.jpg"
+ item.media_content_type = content_type
+ item.content_hash = hashlib.sha256(data).hexdigest()
+ item.metadata = {**item.metadata, "thumbnail_available": True}
+ await item.save()
+ job.status = "complete"
+ job.completed_at = utcnow()
+ await job.save()
+ return {"ok": True, "item_id": str(item.id)}
+
+
@router.get("/sources")
async def list_sources(user: User = Depends(current_active_user)):
rows = (
@@ -476,6 +527,16 @@ async def _immich_bytes(asset_id: str, endpoint: str) -> tuple[bytes, str]:
@router.get("/items/{item_id}/thumbnail")
async def context_thumbnail(item_id: str, user: User = Depends(current_active_user)):
item = await _owned_item(item_id, user)
+ if (
+ item.media_data
+ and item.media_content_type
+ and item.media_content_type.startswith("image/")
+ ):
+ return Response(
+ content=item.media_data,
+ media_type=item.media_content_type,
+ headers={"Cache-Control": "private, max-age=3600"},
+ )
asset_id = item.metadata.get("asset_id")
if item.kind != "immich_memory" or not asset_id:
raise HTTPException(
@@ -489,6 +550,43 @@ async def context_thumbnail(item_id: str, user: User = Depends(current_active_us
)
+@router.post("/items/{item_id}/request-thumbnail")
+async def request_item_thumbnail(
+ item_id: str, user: User = Depends(current_active_user)
+):
+ item = await _owned_item(item_id, user)
+ if item.kind != "activity":
+ raise HTTPException(status_code=409, detail="Only activity items have frames")
+ if item.media_data:
+ return {"status": "complete"}
+ frame_id = (
+ item.metadata.get("representative_frame_id")
+ or item.metadata.get("last_frame_id")
+ or item.metadata.get("first_frame_id")
+ )
+ if frame_id is None:
+ raise HTTPException(status_code=409, detail="Activity has no source frame")
+ existing = await DeviceInputJob.find_one(
+ DeviceInputJob.source_id == item.source_id,
+ DeviceInputJob.kind == "thumbnail",
+ {"payload.item_id": item_id},
+ {"status": {"$in": ["pending", "claimed"]}},
+ )
+ if existing:
+ return {"status": existing.status, "job_id": str(existing.id)}
+ job = DeviceInputJob(
+ user_id=item.user_id,
+ source_id=item.source_id,
+ kind="thumbnail",
+ start_at=item.captured_at,
+ end_at=item.ended_at,
+ purpose="timeline_thumbnail",
+ payload={"item_id": item_id, "frame_id": frame_id},
+ )
+ await job.insert()
+ return {"status": "pending", "job_id": str(job.id)}
+
+
@router.post("/items/{item_id}/promote")
async def promote_context_item(item_id: str, user: User = Depends(current_active_user)):
item = await _owned_item(item_id, user)
diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py
index ab7524058..31416c815 100644
--- a/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py
+++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py
@@ -18,14 +18,24 @@
``decode_responses`` setting.
"""
+import asyncio
import json
import logging
import time
+import uuid
+from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import AsyncIterator, Literal, Optional
+from redis.exceptions import WatchError
+
+from advanced_omi_backend.redis_keys import (
+ conversation_create_lock as conversation_create_lock_key,
+)
+from advanced_omi_backend.redis_keys import conversation_current
+
logger = logging.getLogger(__name__)
# Key templates
@@ -391,6 +401,184 @@ async def persist_session(self, session_id: str) -> None:
async def delete(self, session_id: str) -> None:
await self._redis.delete(self._key(session_id))
+ # ------------------------------------------------ conversation assignment
+ async def set_current_conversation(
+ self,
+ session_id: str,
+ conversation_id: str,
+ *,
+ ttl: Optional[int] = 86400,
+ ) -> None:
+ """Assign the conversation that receives this session's persisted audio.
+
+ ``ttl=None`` is reserved for an ``always_persist`` placeholder, whose
+ lifetime is tied to the session rather than a rotation timeout.
+ """
+ key = conversation_current(session_id)
+ if ttl is None:
+ await self._redis.set(key, conversation_id)
+ else:
+ await self._redis.set(key, conversation_id, ex=ttl)
+
+ async def get_current_conversation_id(self, session_id: str) -> Optional[str]:
+ """Return the decoded current-conversation assignment, if one exists."""
+ return _to_str(await self._redis.get(conversation_current(session_id)))
+
+ async def assign_current_conversation_if_active(
+ self,
+ session_id: str,
+ conversation_id: str,
+ *,
+ ttl: Optional[int] = 86400,
+ ) -> bool:
+ """Assign only when the session is active and has no current owner.
+
+ The session status check and pointer creation share one Redis transaction,
+ closing the finalization race between a separate ``get_status`` and ``set``.
+ """
+ session_key = self._key(session_id)
+ current_key = conversation_current(session_id)
+
+ while True:
+ async with self._redis.pipeline(transaction=True) as pipe:
+ try:
+ await pipe.watch(session_key, current_key)
+ status = _to_str(await pipe.hget(session_key, "status"))
+ current_id = _to_str(await pipe.get(current_key))
+ if status != SessionStatus.ACTIVE.value or current_id is not None:
+ await pipe.unwatch()
+ return False
+
+ pipe.multi()
+ if ttl is None:
+ pipe.set(current_key, conversation_id)
+ else:
+ pipe.set(current_key, conversation_id, ex=ttl)
+ await pipe.execute()
+ return True
+ except WatchError:
+ continue
+
+ async def replace_current_conversation_if_active(
+ self,
+ session_id: str,
+ expected_id: str,
+ replacement_id: str,
+ *,
+ ttl: Optional[int] = 86400,
+ ) -> bool:
+ """Atomically rotate an active session from one owner to its successor.
+
+ There is no unassigned interval: an audio XADD watching the same pointer
+ observes either ``expected_id`` or ``replacement_id``. Finalization and a
+ competing rotation both make the compare-and-swap fail without mutation.
+ """
+ session_key = self._key(session_id)
+ current_key = conversation_current(session_id)
+
+ while True:
+ async with self._redis.pipeline(transaction=True) as pipe:
+ try:
+ await pipe.watch(session_key, current_key)
+ status = _to_str(await pipe.hget(session_key, "status"))
+ current_id = _to_str(await pipe.get(current_key))
+ if (
+ status != SessionStatus.ACTIVE.value
+ or current_id != expected_id
+ ):
+ await pipe.unwatch()
+ return False
+
+ pipe.multi()
+ if ttl is None:
+ pipe.set(current_key, replacement_id)
+ else:
+ pipe.set(current_key, replacement_id, ex=ttl)
+ await pipe.execute()
+ return True
+ except WatchError:
+ continue
+
+ async def clear_current_conversation(
+ self,
+ session_id: str,
+ *,
+ expected_id: Optional[str] = None,
+ ) -> bool:
+ """Clear the assignment, optionally only when it still has ``expected_id``.
+
+ The compare-and-delete form prevents a late cleanup for conversation A
+ from deleting a successor assignment to conversation B.
+ """
+ key = conversation_current(session_id)
+ if expected_id is None:
+ return bool(await self._redis.delete(key))
+
+ while True:
+ async with self._redis.pipeline(transaction=True) as pipe:
+ try:
+ await pipe.watch(key)
+ if _to_str(await pipe.get(key)) != expected_id:
+ await pipe.unwatch()
+ return False
+ pipe.multi()
+ pipe.delete(key)
+ result = await pipe.execute()
+ return bool(result[0])
+ except WatchError:
+ continue
+
+ async def expire_current_conversation(self, session_id: str, ttl: int) -> bool:
+ """Apply a cleanup TTL only when a conversation assignment exists."""
+ return bool(await self._redis.expire(conversation_current(session_id), ttl))
+
+ @asynccontextmanager
+ async def conversation_create_lock(
+ self,
+ session_id: str,
+ *,
+ wait_timeout: float = 5.0,
+ lease_seconds: int = 30,
+ poll: float = 0.05,
+ ):
+ """Serialize get/create/assign sequences for one streaming session.
+
+ Creation remains available if Redis locking is unhealthy: after
+ ``wait_timeout`` the caller runs unlocked and receives ``False``. This
+ avoids deadlocking audio ingestion while making the degraded mode visible
+ to callers and logs.
+ """
+ key = conversation_create_lock_key(session_id)
+ token = uuid.uuid4().hex
+ deadline = time.monotonic() + wait_timeout
+ acquired = False
+
+ while time.monotonic() < deadline:
+ acquired = bool(
+ await self._redis.set(key, token, nx=True, ex=lease_seconds)
+ )
+ if acquired:
+ break
+ await asyncio.sleep(poll)
+
+ try:
+ yield acquired
+ finally:
+ if acquired:
+ while True:
+ async with self._redis.pipeline(transaction=True) as pipe:
+ try:
+ await pipe.watch(key)
+ if _to_str(await pipe.get(key)) != token:
+ await pipe.unwatch()
+ break
+ pipe.multi()
+ pipe.delete(key)
+ await pipe.execute()
+ break
+ except WatchError:
+ continue
+
# ----------------------------------------------------------- field writes
async def set_audio_format(self, session_id: str, audio_format: dict) -> None:
await self._redis.hset(
diff --git a/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py b/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py
index 601f87252..f794b2167 100644
--- a/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py
+++ b/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py
@@ -2,7 +2,7 @@
import asyncio
import tempfile
-from datetime import timedelta
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@@ -21,18 +21,26 @@
_MAX_SESSION = timedelta(minutes=30)
+def _as_utc(value: datetime) -> datetime:
+ """Normalize Mongo's naïve UTC datetimes before ordering or arithmetic."""
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value.astimezone(timezone.utc)
+
+
def group_audio_sessions(items: list[DeviceInputItem]) -> list[list[DeviceInputItem]]:
sessions: list[list[DeviceInputItem]] = []
- for item in sorted(items, key=lambda row: row.captured_at):
+ for item in sorted(items, key=lambda row: _as_utc(row.captured_at)):
if not sessions:
sessions.append([item])
continue
previous = sessions[-1][-1]
- previous_end = previous.ended_at or previous.captured_at
- session_start = sessions[-1][0].captured_at
+ previous_end = _as_utc(previous.ended_at or previous.captured_at)
+ session_start = _as_utc(sessions[-1][0].captured_at)
+ captured_at = _as_utc(item.captured_at)
if (
- item.captured_at - previous_end > _SESSION_GAP
- or item.captured_at - session_start >= _MAX_SESSION
+ captured_at - previous_end > _SESSION_GAP
+ or captured_at - session_start >= _MAX_SESSION
):
sessions.append([item])
else:
@@ -41,7 +49,7 @@ def group_audio_sessions(items: list[DeviceInputItem]) -> list[list[DeviceInputI
async def _mix_session(items: list[DeviceInputItem], workspace: Path, output: Path) -> None:
- start = min(item.captured_at for item in items)
+ start = min(_as_utc(item.captured_at) for item in items)
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
valid = [item for item in items if item.media_data]
if not valid:
@@ -54,7 +62,9 @@ async def _mix_session(items: list[DeviceInputItem], workspace: Path, output: Pa
chains = []
labels = []
for index, item in enumerate(valid):
- delay_ms = max(0, int((item.captured_at - start).total_seconds() * 1000))
+ delay_ms = max(
+ 0, int((_as_utc(item.captured_at) - start).total_seconds() * 1000)
+ )
label = f"a{index}"
chains.append(
f"[{index}:a]aresample=16000,aformat=channel_layouts=mono,adelay={delay_ms}[{label}]"
@@ -109,7 +119,9 @@ async def process_device_audio() -> dict[str, Any]:
if user is None:
continue
for session in group_audio_sessions(source_items):
- session_end = max((item.ended_at or item.captured_at) for item in session)
+ session_end = max(
+ _as_utc(item.ended_at or item.captured_at) for item in session
+ )
if session_end > utcnow() - _CLOSE_DELAY:
continue
with tempfile.TemporaryDirectory(
@@ -138,7 +150,9 @@ async def process_device_audio() -> dict[str, Any]:
Conversation.conversation_id == conversation_id
)
if conversation is not None:
- conversation.created_at = min(item.captured_at for item in session)
+ conversation.created_at = min(
+ _as_utc(item.captured_at) for item in session
+ )
conversation.external_source_id = f"screenpipe:{source_id}:{session[0].source_item_id}-{session[-1].source_item_id}"
conversation.external_source_type = "screenpipe"
await conversation.save()
diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py
index adfb5d433..43fd80dda 100644
--- a/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py
+++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py
@@ -14,6 +14,7 @@
"""
import logging
+import re
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -233,14 +234,38 @@ async def _run_agent_with_note_guarantee(
"short or low-information transcript, summarize the exact utterance rather "
"than leaving either section blank."
)
- recovery = await agent_class(user_root, force_fallback=True).run(
- transcript,
- source_id,
- date=trusted_date,
- duration_minutes=source_duration_minutes,
- title=source_title,
- guidance=recovery_guidance,
- )
+ # The recovery pass is best-effort: it may fail outright (no
+ # defaults.fallback_llm configured, fallback unreachable, ...). The note
+ # guarantee must survive that — degrade to an empty recovery result and
+ # let the source-preserving fallback note below do its job, rather than
+ # failing the whole memory job with nothing recorded.
+ try:
+ recovery = await agent_class(user_root, force_fallback=True).run(
+ transcript,
+ source_id,
+ date=trusted_date,
+ duration_minutes=source_duration_minutes,
+ title=source_title,
+ guidance=recovery_guidance,
+ )
+ except Exception as exc: # noqa: BLE001 - degrade, never lose the note
+ # Lazy import: circular dependency (agent → memory_agent → llm_client →
+ # back into providers), same as _agent_class above.
+ from ..agent.memory_agent import MemoryAgentResult
+
+ memory_logger.error(
+ "Memory-agent recovery pass failed for %s (%s); falling back to "
+ "the source-preserving conversation note",
+ source_id,
+ exc,
+ )
+ recovery = MemoryAgentResult(
+ conversation_id=source_id,
+ rounds=0,
+ touched=[],
+ summary="",
+ errors=[f"recovery pass failed: {exc}"],
+ )
recovery.rounds += result.rounds
recovery.tool_calls += result.tool_calls
recovery.touched = list(dict.fromkeys((*result.touched, *recovery.touched)))
@@ -440,27 +465,51 @@ async def _record_agent_touches(
),
)
- @staticmethod
- def _speaker_rename_guidance(transcript_diff: Optional[list]) -> str:
+ # Diarization placeholders ("Speaker 0", "Unknown Speaker 1") — the only labels a
+ # conversation-scoped diff may globally rename. A person note under a real name
+ # aggregates facts from many conversations, so renaming it from one conversation's
+ # relabel merges the wrong person's whole history (ankush.md -> roshan.md, 2026-07-17).
+ _PLACEHOLDER_SPEAKER_RE = re.compile(
+ r"^(unknown\s+)?speaker[\s_]*\d+$", re.IGNORECASE
+ )
+
+ @classmethod
+ def _speaker_rename_guidance(cls, transcript_diff: Optional[list]) -> str:
"""Turn a speaker diff into an instruction to rename the matching person notes."""
if not transcript_diff:
return ""
renames: dict[str, str] = {}
+ relabels: dict[str, str] = {}
for ch in transcript_diff:
if isinstance(ch, dict) and ch.get("type") == "speaker_change":
old, new = ch.get("old_speaker"), ch.get("new_speaker")
if old and new and old != new:
- renames[old] = new
- if not renames:
+ if cls._PLACEHOLDER_SPEAKER_RE.match(old.strip()):
+ renames[old] = new
+ else:
+ relabels[old] = new
+ parts: list[str] = []
+ if renames:
+ pairs = "; ".join(f"'{o}' is now '{n}'" for o, n in renames.items())
+ parts.append(
+ f"Placeholder speakers were identified: {pairs}. For each, if a "
+ "People/.md note exists, call rename_person(old, new) FIRST "
+ "— it renames the note and rewrites every [[wikilink]] across the vault — "
+ "then record the conversation and update the renamed person notes. Do not "
+ "leave notes under placeholder speaker labels."
+ )
+ if relabels:
+ pairs = "; ".join(f"'{o}' is now '{n}'" for o, n in relabels.items())
+ parts.append(
+ f"Attribution changed between named people IN THIS CONVERSATION ONLY: "
+ f"{pairs}. Fix the conversation note and move only facts sourced from "
+ "this conversation between the affected person notes. Do NOT call "
+ "rename_person for these — both notes describe real people whose facts "
+ "come from many other conversations."
+ )
+ if not parts:
return ""
- pairs = "; ".join(f"'{o}' is now '{n}'" for o, n in renames.items())
- return (
- "This is a REPROCESS after speaker re-identification. Speaker labels changed: "
- f"{pairs}. For each change, if a People/.md note exists, call "
- "rename_person(old, new) FIRST — it renames the note and rewrites every "
- "[[wikilink]] across the vault — then record the conversation and update the "
- "renamed person notes. Do not leave notes under the old speaker labels."
- )
+ return "This is a REPROCESS after speaker re-identification. " + " ".join(parts)
# =========================================================================
# SEARCH
diff --git a/backends/advanced/tests/conftest.py b/backends/advanced/tests/conftest.py
new file mode 100644
index 000000000..6abb1cc0a
--- /dev/null
+++ b/backends/advanced/tests/conftest.py
@@ -0,0 +1,18 @@
+"""Shared pytest fixtures and test environment defaults.
+
+Several backend modules (notably ``advanced_omi_backend.auth``) validate that
+required secrets are configured at *import* time. In CI there is no ``.env``
+file, so importing the app during test collection would raise
+``ValueError: is not set``. We provide deterministic test defaults here so
+collection succeeds without depending on a developer's local ``.env``.
+
+``setdefault`` is used so a real environment (CI secrets or a local ``.env``
+already exported) always wins over these placeholders.
+"""
+
+import os
+
+# Import-time required secrets (see advanced_omi_backend.auth).
+os.environ.setdefault("AUTH_SECRET_KEY", "test-auth-secret-key")
+os.environ.setdefault("ADMIN_PASSWORD", "test-admin-password")
+os.environ.setdefault("ADMIN_EMAIL", "admin@example.com")
diff --git a/backends/advanced/tests/test_device_audio_ingest.py b/backends/advanced/tests/test_device_audio_ingest.py
index 271511e8a..24e4e597c 100644
--- a/backends/advanced/tests/test_device_audio_ingest.py
+++ b/backends/advanced/tests/test_device_audio_ingest.py
@@ -44,3 +44,17 @@ def test_continuous_capture_is_bounded_into_processing_windows():
rows = [item(str(index), start + timedelta(minutes=index)) for index in range(32)]
sessions = group_audio_sessions(rows)
assert [len(session) for session in sessions] == [30, 2]
+
+
+def test_mongo_naive_and_aware_timestamps_group_together():
+ naive = datetime(2026, 7, 22, 10, 0)
+ aware = datetime(2026, 7, 22, 10, 0, 30, tzinfo=timezone.utc)
+ sessions = group_audio_sessions(
+ [
+ item("mongo-naive", naive),
+ item("api-aware", aware),
+ ]
+ )
+ assert [[row.source_item_id for row in session] for session in sessions] == [
+ ["mongo-naive", "api-aware"]
+ ]
diff --git a/backends/advanced/webui/chronicle-espresso-preset.js b/backends/advanced/webui/chronicle-espresso-preset.js
new file mode 100644
index 000000000..9f2456a87
--- /dev/null
+++ b/backends/advanced/webui/chronicle-espresso-preset.js
@@ -0,0 +1,107 @@
+/**
+ * Chronicle "Espresso" design-system preset (Tailwind v3).
+ *
+ * Source of truth: the Chronicle Design System project (claude.ai/design),
+ * tokens/*.css. Warm espresso neutrals + a terracotta brand ramp + a
+ * forest-green status family. Dark is the default surface.
+ *
+ * Applied by REMAPPING Tailwind's default color NAMES to the Espresso ramps,
+ * so the thousands of existing `bg-gray-800 / text-blue-600 / dark:*` utilities
+ * reskin with no per-component edits. Light->dark ordering is preserved, so
+ * every hand-paired `x-50 dark:x-900` keeps working.
+ *
+ * gray -> espresso neutrals blue -> terracotta (brand accent)
+ * green -> forest (success/verifier) red -> danger amber/yellow -> warning
+ * purple-> suggest orange -> clay sky/cyan -> info-blue
+ * aliases: emerald/teal/lime->forest, zinc/slate/neutral/stone->espresso,
+ * indigo/violet->purple, pink/rose->red
+ *
+ * IMPORTANT: keep this file identical to the copy in
+ * extras/speaker-recognition/webui/chronicle-espresso-preset.js.
+ * The two web UIs build in separate Docker contexts, so the preset is
+ * duplicated rather than shared via a package.
+ */
+
+// warm espresso neutrals (DS gray scale, extended to 950)
+const espresso = {
+ 50: '#f7f3ea', 100: '#f2ece2', 200: '#ddd5c6', 300: '#c9bfae', 400: '#948976',
+ 500: '#6b5f4f', 600: '#42392f', 700: '#2c251d', 800: '#211b15', 900: '#191410', 950: '#120d0a',
+}
+
+// brand terracotta (DS terracotta ramp; 50/100/200/800/950 interpolated)
+const terracotta = {
+ 50: '#fbeee7', 100: '#f7dccd', 200: '#f0c3ac', 300: '#ecab93', 400: '#e07856',
+ 500: '#d2694a', 600: '#c2551f', 700: '#a8471f', 800: '#7c351a', 900: '#3a1f14', 950: '#241009',
+}
+
+// forest green — positive / verifier / secondary (part of the palette)
+const forest = {
+ 50: '#eaf3ec', 100: '#d1e7d5', 200: '#a9cfb0', 300: '#8fc79a', 400: '#6f9a5f',
+ 500: '#4f7d54', 600: '#3f6b47', 700: '#34614a', 800: '#294a39', 900: '#1f3729', 950: '#132218',
+}
+
+// danger red (DS red ramp)
+const danger = {
+ 50: '#fbeae7', 100: '#f7d5cf', 200: '#f0b3a8', 300: '#eda093', 400: '#e8735f',
+ 500: '#dc4a3a', 600: '#c53a2b', 700: '#a32d20', 800: '#7d2419', 900: '#5a1a12', 950: '#360e09',
+}
+
+// warning amber (DS amber ramp) — shared by both `amber` and `yellow`
+const amber = {
+ 50: '#fcf4e1', 100: '#f8e7bb', 200: '#f2d488', 300: '#f0c674', 400: '#e6ad3f',
+ 500: '#d99521', 600: '#b8781a', 700: '#925c15', 800: '#6d4513', 900: '#4a2f0f', 950: '#2b1b08',
+}
+
+// warm clay (DS clay/apricot/ochre) — for `orange`
+const clay = {
+ 50: '#fbeee4', 100: '#f6dcc6', 200: '#efc39c', 300: '#e5a86f', 400: '#e59b52',
+ 500: '#d9822f', 600: '#c26a20', 700: '#9c521c', 800: '#743e18', 900: '#4c2911', 950: '#2c170a',
+}
+
+// suggest purple (DS purple)
+const suggest = {
+ 50: '#f4eef8', 100: '#e7d9ef', 200: '#d4bce2', 300: '#c2a0d4', 400: '#a986c4',
+ 500: '#9169b0', 600: '#775394', 700: '#5f4278', 800: '#48335c', 900: '#312340', 950: '#1d1526',
+}
+
+// muted info-blue (DS --info-fg family) — keeps a real "info" hue distinct from the brand
+const info = {
+ 50: '#eef3f7', 100: '#d7e3ec', 200: '#b5cbdd', 300: '#8fb0c9', 400: '#6b93b0',
+ 500: '#4f7896', 600: '#3f6079', 700: '#344e61', 800: '#2a3d4b', 900: '#1f2c37', 950: '#141d24',
+}
+
+/** @type {import('tailwindcss').Config} */
+export default {
+ theme: {
+ extend: {
+ colors: {
+ // neutrals
+ gray: espresso, zinc: espresso, slate: espresso, neutral: espresso, stone: espresso,
+ // brand
+ blue: terracotta, terracotta,
+ // status
+ green: forest, emerald: forest, teal: forest, lime: forest, forest,
+ red: danger, rose: danger, pink: danger,
+ amber, yellow: amber,
+ orange: clay, clay,
+ purple: suggest, violet: suggest, indigo: suggest,
+ sky: info, cyan: info, info,
+ },
+ fontFamily: {
+ sans: ['system-ui', '-apple-system', '"Segoe UI"', 'Roboto', 'Helvetica', 'Arial', 'sans-serif'],
+ mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', '"Liberation Mono"', 'monospace'],
+ },
+ borderRadius: {
+ sm: '0.25rem', // DS chips (4px)
+ },
+ boxShadow: {
+ // warm-tinted ambient elevation for the espresso surfaces
+ sm: '0 1px 2px 0 rgba(20,12,6,.40)',
+ DEFAULT: '0 2px 4px -1px rgba(20,12,6,.45)',
+ md: '0 6px 14px -4px rgba(20,12,6,.50)',
+ lg: '0 12px 28px -6px rgba(20,12,6,.55), 0 4px 10px -4px rgba(20,12,6,.45)',
+ xl: '0 14px 36px rgba(20,12,6,.60)',
+ },
+ },
+ },
+}
diff --git a/backends/advanced/webui/src/components/AsrContextSettings.tsx b/backends/advanced/webui/src/components/AsrContextSettings.tsx
index 86edb57df..a37855b18 100644
--- a/backends/advanced/webui/src/components/AsrContextSettings.tsx
+++ b/backends/advanced/webui/src/components/AsrContextSettings.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Mic, Save, Sparkles, Tag } from 'lucide-react'
import { systemApi } from '../services/api'
+import { Button, Card, StateBadge, Textarea } from './ui'
interface AsrModelInfo {
name: string
@@ -19,23 +20,19 @@ interface AsrContextData {
function HintTypeBadge({ hintType }: { hintType: AsrModelInfo['hint_type'] }) {
if (hintType === 'context_prompt') {
return (
-
+
Context prompt (LLM)
-
+
)
}
if (hintType === 'keyword_boosting') {
return (
-
+
Keyword boosting (acoustic)
-
+
)
}
- return (
-
- No recognition hints
-
- )
+ return No recognition hints
}
function ProviderRow({ label, model, onSaved }: {
@@ -88,22 +85,21 @@ function ProviderRow({ label, model, onSaved }: {
Context — describe the domain, names, or jargon to help this LLM-based model.
It informs recognition but is never transcribed.
-
-
+
)
}
diff --git a/backends/advanced/webui/src/components/MemoryAuditCard.tsx b/backends/advanced/webui/src/components/MemoryAuditCard.tsx
index 96fa31f0a..d64da965c 100644
--- a/backends/advanced/webui/src/components/MemoryAuditCard.tsx
+++ b/backends/advanced/webui/src/components/MemoryAuditCard.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { ArrowUp, Loader2 } from 'lucide-react'
import { conversationsApi } from '../services/api'
+import { Card, MetadataChip, StateBadge } from './ui'
interface MemoryAuditEntry {
id: string
@@ -13,12 +14,9 @@ interface MemoryAuditEntry {
created_at?: string | null
}
-const OPERATION_STYLES: Record = {
- create: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
- update: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
- delete: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
- delete_all: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
-}
+// Deletions keep a restrained danger tint in this audit list; other operations
+// are plain metadata (mirrors MemoryLedger).
+const isDestructiveOp = (op: string) => op === 'delete' || op === 'delete_all'
function formatTime(value?: string | null): string {
if (!value) return ''
@@ -55,12 +53,12 @@ export default function MemoryAuditCard({ conversationId }: { conversationId: st
if (!loading && !error && entries.length === 0) return null
return (
-
+
@@ -80,14 +78,11 @@ export default function MemoryAuditCard({ conversationId }: { conversationId: st
-
- {entry.operation}
-
+ {isDestructiveOp(entry.operation) ? (
+ {entry.operation}
+ ) : (
+ {entry.operation}
+ )}
{entry.note_path || '(whole vault)'}
@@ -104,6 +99,6 @@ export default function MemoryAuditCard({ conversationId }: { conversationId: st
))}
)}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/PluginSettings.tsx b/backends/advanced/webui/src/components/PluginSettings.tsx
index 05576120d..57ee7d7a4 100644
--- a/backends/advanced/webui/src/components/PluginSettings.tsx
+++ b/backends/advanced/webui/src/components/PluginSettings.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { Puzzle, RefreshCw, CheckCircle, Save, RotateCcw, AlertCircle } from 'lucide-react'
import { systemApi } from '../services/api'
import { useAuth } from '../contexts/AuthContext'
+import { Alert, Button, Card, Textarea } from './ui'
interface PluginSettingsProps {
className?: string
@@ -100,7 +101,7 @@ export default function PluginSettings({ className }: PluginSettingsProps) {
return (
-
+
{/* Header */}
@@ -110,47 +111,45 @@ export default function PluginSettings({ className }: PluginSettingsProps) {
- }
>
-
- Reset
-
-
+ }
>
-
- Reload
-
+ Reload
+
{/* Messages */}
{message && (
-
+ } className="mb-4">
+ {message}
+
)}
{error && (
-
+ } className="mb-4">
+ {error}
+
)}
{/* Editor */}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/PluginSettingsForm.tsx b/backends/advanced/webui/src/components/PluginSettingsForm.tsx
index dec0a697d..7b98975f1 100644
--- a/backends/advanced/webui/src/components/PluginSettingsForm.tsx
+++ b/backends/advanced/webui/src/components/PluginSettingsForm.tsx
@@ -3,6 +3,7 @@ import { RefreshCw, AlertCircle } from 'lucide-react'
import { systemApi } from '../services/api'
import PluginListSidebar from './plugins/PluginListSidebar'
import PluginConfigPanel from './plugins/PluginConfigPanel'
+import { Alert, Card } from './ui'
interface PluginMetadata {
plugin_id: string
@@ -259,7 +260,7 @@ export default function PluginSettingsForm({ className }: PluginSettingsFormProp
return (
-
+
{/* Header */}
@@ -282,18 +283,13 @@ export default function PluginSettingsForm({ className }: PluginSettingsFormProp
{/* Status Messages */}
{message && (
-
+
{message}
)}
{error && (
-
+
} className="mx-6 mt-4">
+ {error}
+
)}
{/* Main Content */}
@@ -333,7 +329,7 @@ export default function PluginSettingsForm({ className }: PluginSettingsFormProp
)}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/RemoteControl.tsx b/backends/advanced/webui/src/components/RemoteControl.tsx
index 28dfc9b54..4e9b5d6ae 100644
--- a/backends/advanced/webui/src/components/RemoteControl.tsx
+++ b/backends/advanced/webui/src/components/RemoteControl.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { CheckCircle, Circle, Play, RefreshCw, Smartphone, Square } from 'lucide-react'
import { systemApi } from '../services/api'
+import { Button, Card, MetadataChip } from './ui'
interface RemoteControlData {
available: boolean
@@ -54,7 +55,7 @@ export default function RemoteControl({ isAdmin }: { isAdmin: boolean }) {
const missingDeps = data.tmux_available === false || data.claude_available === false
return (
-
+
Claude Remote Control
@@ -85,9 +86,7 @@ export default function RemoteControl({ isAdmin }: { isAdmin: boolean }) {
{running ? 'Running' : 'Stopped'}
{data.name && {data.name} }
{data.managed && (
-
- auto-start on boot
-
+ auto-start on boot
)}
{data.dir && (
@@ -102,22 +101,22 @@ export default function RemoteControl({ isAdmin }: { isAdmin: boolean }) {
{running ? (
<>
- act('restart')}
disabled={busy}
- className="flex items-center gap-1 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
+ icon={ }
>
-
- Restart
-
-
+ act('stop')}
disabled={busy}
- className="flex items-center gap-1 px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
+ icon={ }
>
-
- Stop
-
+ Stop
+
>
) : (
services.py remote-control install.
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/AudioRecordingControls.tsx b/backends/advanced/webui/src/components/audio/AudioRecordingControls.tsx
index c98e30233..9200cafa0 100644
--- a/backends/advanced/webui/src/components/audio/AudioRecordingControls.tsx
+++ b/backends/advanced/webui/src/components/audio/AudioRecordingControls.tsx
@@ -1,4 +1,5 @@
import { UseAudioRecordingReturn } from '../../hooks/useAudioRecording'
+import { Button } from '../ui'
interface AudioRecordingControlsProps {
recording: UseAudioRecordingReturn
@@ -28,13 +29,15 @@ export default function AudioRecordingControls({ recording }: AudioRecordingCont
{/* Audio Start */}
-
📤 Start
-
+
Send audio-start
diff --git a/backends/advanced/webui/src/components/audio/DebugPanel.tsx b/backends/advanced/webui/src/components/audio/DebugPanel.tsx
index 9424a91e1..372274a45 100644
--- a/backends/advanced/webui/src/components/audio/DebugPanel.tsx
+++ b/backends/advanced/webui/src/components/audio/DebugPanel.tsx
@@ -1,4 +1,5 @@
import { UseAudioRecordingReturn } from '../../hooks/useAudioRecording'
+import { Card } from '../ui'
interface DebugPanelProps {
recording: UseAudioRecordingReturn
@@ -6,7 +7,7 @@ interface DebugPanelProps {
export default function DebugPanel({ recording }: DebugPanelProps) {
return (
-
+
🐛 Debug Information
@@ -70,6 +71,6 @@ export default function DebugPanel({ recording }: DebugPanelProps) {
• Protocol: Wyoming (JSON headers + binary payloads)
• Direct Checks: WS={recording.hasValidWebSocket ? '✅' : '❌'} Mic={recording.hasValidMicrophone ? '✅' : '❌'} Ctx={recording.hasValidAudioContext ? '✅' : '❌'}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/MainRecordingControls.tsx b/backends/advanced/webui/src/components/audio/MainRecordingControls.tsx
index bb1daff42..d2ac337e5 100644
--- a/backends/advanced/webui/src/components/audio/MainRecordingControls.tsx
+++ b/backends/advanced/webui/src/components/audio/MainRecordingControls.tsx
@@ -1,5 +1,6 @@
import { Mic, Square } from 'lucide-react'
import { UseAudioRecordingReturn } from '../../hooks/useAudioRecording'
+import { Card } from '../ui'
interface MainRecordingControlsProps {
recording: UseAudioRecordingReturn
@@ -9,7 +10,7 @@ export default function MainRecordingControls({ recording }: MainRecordingContro
const isHttps = window.location.protocol === 'https:'
return (
-
+
@@ -55,6 +56,6 @@ export default function MainRecordingControls({ recording }: MainRecordingContro
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/RecordingStatus.tsx b/backends/advanced/webui/src/components/audio/RecordingStatus.tsx
index 03a9a19fa..e82f87236 100644
--- a/backends/advanced/webui/src/components/audio/RecordingStatus.tsx
+++ b/backends/advanced/webui/src/components/audio/RecordingStatus.tsx
@@ -1,6 +1,7 @@
import { Wifi, WifiOff, Radio } from 'lucide-react'
import { UseAudioRecordingReturn } from '../../hooks/useAudioRecording'
import { useAuth } from '../../contexts/AuthContext'
+import { Card } from '../ui'
interface RecordingStatusProps {
recording: UseAudioRecordingReturn
@@ -38,7 +39,7 @@ export default function RecordingStatus({ recording }: RecordingStatusProps) {
return (
<>
{/* Connection Status */}
-
+
{getStatusIcon()}
@@ -61,10 +62,10 @@ export default function RecordingStatus({ recording }: RecordingStatusProps) {
-
+
{/* Component Status Indicators */}
-
+
📊 Component Status
@@ -138,7 +139,7 @@ export default function RecordingStatus({ recording }: RecordingStatusProps) {
-
+
>
)
}
diff --git a/backends/advanced/webui/src/components/audio/SimpleDebugPanel.tsx b/backends/advanced/webui/src/components/audio/SimpleDebugPanel.tsx
index febc90c8c..069483ddd 100644
--- a/backends/advanced/webui/src/components/audio/SimpleDebugPanel.tsx
+++ b/backends/advanced/webui/src/components/audio/SimpleDebugPanel.tsx
@@ -1,4 +1,5 @@
import { RecordingContextType } from '../../contexts/RecordingContext'
+import { Card } from '../ui'
interface SimpleDebugPanelProps {
recording: RecordingContextType
@@ -6,7 +7,7 @@ interface SimpleDebugPanelProps {
export default function SimpleDebugPanel({ recording }: SimpleDebugPanelProps) {
return (
-
+
🐛 Debug Information
@@ -70,6 +71,6 @@ export default function SimpleDebugPanel({ recording }: SimpleDebugPanelProps) {
• Sequential Flow: Mic → WebSocket → Audio-Start → Streaming
• Security: {recording.canAccessMicrophone ? '✅ HTTPS/Localhost' : '❌ Insecure Connection'}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/SimplifiedControls.tsx b/backends/advanced/webui/src/components/audio/SimplifiedControls.tsx
index e755c69e8..65668a0a0 100644
--- a/backends/advanced/webui/src/components/audio/SimplifiedControls.tsx
+++ b/backends/advanced/webui/src/components/audio/SimplifiedControls.tsx
@@ -1,5 +1,6 @@
import { Mic, Square, Loader2, Monitor } from 'lucide-react'
import { RecordingContextType } from '../../contexts/RecordingContext'
+import { Card } from '../ui'
interface SimplifiedControlsProps {
recording: RecordingContextType
@@ -46,7 +47,7 @@ export default function SimplifiedControls({ recording }: SimplifiedControlsProp
const isDisabled = recording.isRecording ? false : (processing || !canStart)
return (
-
+
{/* Single Toggle Button */}
@@ -116,6 +117,6 @@ export default function SimplifiedControls({ recording }: SimplifiedControlsProp
)}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/StatusDisplay.tsx b/backends/advanced/webui/src/components/audio/StatusDisplay.tsx
index cd543a563..666ff8ed3 100644
--- a/backends/advanced/webui/src/components/audio/StatusDisplay.tsx
+++ b/backends/advanced/webui/src/components/audio/StatusDisplay.tsx
@@ -1,6 +1,7 @@
import React from 'react'
import { Check, Loader2, AlertCircle, Mic, Monitor, Wifi, Play, Radio } from 'lucide-react'
import { RecordingContextType, RecordingStep, AudioSource } from '../../contexts/RecordingContext'
+import { Card } from '../ui'
interface StatusDisplayProps {
recording: RecordingContextType
@@ -107,7 +108,7 @@ export default function StatusDisplay({ recording }: StatusDisplayProps) {
}
return (
-
+
Recording Setup Progress
@@ -160,6 +161,6 @@ export default function StatusDisplay({ recording }: StatusDisplayProps) {
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/WakeFeedback.tsx b/backends/advanced/webui/src/components/audio/WakeFeedback.tsx
index e2c7c6f99..a92941687 100644
--- a/backends/advanced/webui/src/components/audio/WakeFeedback.tsx
+++ b/backends/advanced/webui/src/components/audio/WakeFeedback.tsx
@@ -1,4 +1,5 @@
import { useWakeFeedback } from '../../hooks/useWakeFeedback'
+import { Card } from '../ui'
/**
* Live wake-word feedback for the Live Recording screen.
@@ -14,7 +15,7 @@ export default function WakeFeedback() {
if (phase === 'idle' && !lastCommand && !lastBlocked) return null
return (
-
+
{/* Phase badge with a pulsing dot */}
{phase !== 'idle' && (
@@ -70,6 +71,6 @@ export default function WakeFeedback() {
{lastBlocked && (
{lastBlocked}
)}
-
+
)
}
diff --git a/backends/advanced/webui/src/components/audio/WaveformDisplay.tsx b/backends/advanced/webui/src/components/audio/WaveformDisplay.tsx
index 17172420f..5543cf1c2 100644
--- a/backends/advanced/webui/src/components/audio/WaveformDisplay.tsx
+++ b/backends/advanced/webui/src/components/audio/WaveformDisplay.tsx
@@ -103,7 +103,7 @@ export const WaveformDisplay: React.FC
= ({
if (index === hoveredSegment) {
ctx.fillStyle = `${seg.color}55`;
ctx.fillRect(x1, 0, x2 - x1, height);
- ctx.strokeStyle = '#111827';
+ ctx.strokeStyle = '#191410';
ctx.lineWidth = 2.5;
ctx.strokeRect(Math.max(1, x1), 1, Math.max(3, x2 - x1), height - 2);
}
@@ -138,7 +138,7 @@ export const WaveformDisplay: React.FC = ({
const barWidth = width / samples.length;
const centerY = height / 2;
- ctx.fillStyle = '#3b82f6'; // Blue bars (Tailwind blue-500)
+ ctx.fillStyle = '#d2694a'; // Terracotta bars (brand accent)
samples.forEach((amplitude, i) => {
const x = i * barWidth;
@@ -194,7 +194,7 @@ export const WaveformDisplay: React.FC = ({
const x = progress * width;
// Draw vertical line
- ctx.strokeStyle = '#ef4444'; // Red line (Tailwind red-500)
+ ctx.strokeStyle = '#dc4a3a'; // Red playhead (danger-500)
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, 0);
diff --git a/backends/advanced/webui/src/components/audio/WaveformRegionEditor.tsx b/backends/advanced/webui/src/components/audio/WaveformRegionEditor.tsx
index fd8f18b71..085f81112 100644
--- a/backends/advanced/webui/src/components/audio/WaveformRegionEditor.tsx
+++ b/backends/advanced/webui/src/components/audio/WaveformRegionEditor.tsx
@@ -194,7 +194,7 @@ export const WaveformRegionEditor: React.FC = ({
const sr = data.sample_rate || 3
const i0 = Math.max(0, Math.floor(t0 * sr))
const i1 = Math.min(data.samples.length - 1, Math.ceil(t1 * sr))
- ctx.fillStyle = '#3b82f6'
+ ctx.fillStyle = '#d2694a'
for (let i = i0; i <= i1; i++) {
const xA = ((i / sr - t0) / span) * w
const xB = (((i + 1) / sr - t0) / span) * w
@@ -227,7 +227,7 @@ export const WaveformRegionEditor: React.FC = ({
const ct = playheadRef.current
if (ct != null && ct >= t0 && ct <= t1) {
const xp = ((ct - t0) / span) * w
- ctx.strokeStyle = '#ef4444' // red-500
+ ctx.strokeStyle = '#dc4a3a' // danger-500
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(xp, 0)
diff --git a/backends/advanced/webui/src/components/dataAudit/AuditFilterBar.tsx b/backends/advanced/webui/src/components/dataAudit/AuditFilterBar.tsx
index 26bc65913..9012b3d24 100644
--- a/backends/advanced/webui/src/components/dataAudit/AuditFilterBar.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/AuditFilterBar.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Plus, RefreshCw, X } from 'lucide-react'
import { AUDIT_FILTERS, FilterContext } from './filters'
+import { Button } from '../ui'
interface Props {
filters: Record
@@ -171,15 +172,15 @@ export default function AuditFilterBar({
)}
- }
>
-
- Refresh
-
+ Refresh
+
)
}
diff --git a/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx b/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx
index 7ae5c99b1..d09ae705d 100644
--- a/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/AuditTable.tsx
@@ -5,6 +5,8 @@ import { AuditConversation } from '../../services/api'
import { formatDate, formatDuration, processingStatusChip } from './format'
import PreviewStrip from './PreviewStrip'
import SegmentTriage from './SegmentTriage'
+import BackgroundReview from './BackgroundReview'
+import { MetadataChip, StateBadge } from '../ui'
type SortKey = 'title' | 'created_at' | 'duration_seconds' | 'speech_fraction' | 'archive_reason'
type SortDir = 'asc' | 'desc'
@@ -253,9 +255,7 @@ export default function AuditTable({
{r.client_id}
{r.derived_operation && (
-
- {r.derived_operation}
-
+
{r.derived_operation}
)}
{(() => {
const chip = processingStatusChip(r.processing_status, r.failure_stage)
@@ -284,17 +284,15 @@ export default function AuditTable({
{r.speakers.length === 0 &&
none }
{r.speakers.map((s) => (
-
- {s}
-
+
{s}
))}
{r.unknown_speech_segments > 0 && (
-
{r.unknown_speech_segments} to review
-
+
)}
{r.marginal_identified_segments > 0 && (
) : (
-
- {r.archive_reason || 'archived'}
-
+ {r.archive_reason || 'archived'}
)}
@@ -342,6 +338,12 @@ export default function AuditTable({
onDecisionsChanged={onTriageChanged}
marginalThreshold={marginalThreshold}
/>
+
+
+
)}
diff --git a/backends/advanced/webui/src/components/dataAudit/AuditToolbar.tsx b/backends/advanced/webui/src/components/dataAudit/AuditToolbar.tsx
index 74d49dd48..af0220944 100644
--- a/backends/advanced/webui/src/components/dataAudit/AuditToolbar.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/AuditToolbar.tsx
@@ -1,4 +1,5 @@
import { CheckCircle2, GitMerge, Loader2, PackageOpen, Trash2, UserCheck, VolumeX } from 'lucide-react'
+import { Button } from '../ui'
interface Props {
total: number
@@ -43,76 +44,80 @@ export default function AuditToolbar({
{triagePendingCount > 0 && (
-
+ ) : (
+
+ )
+ }
>
- {applyingTriage ? (
-
- ) : (
-
- )}
-
- {applyingTriage
- ? 'Applying…'
- : `Apply triage (${triagePendingCount} across ${triageConversationCount})`}
-
-
+ {applyingTriage
+ ? 'Applying…'
+ : `Apply triage (${triagePendingCount} across ${triageConversationCount})`}
+
)}
-
+ ) : nothingToAnalyze ? (
+
+ ) : (
+
+ )
+ }
>
- {analyzing ? (
-
- ) : nothingToAnalyze ? (
-
- ) : (
-
- )}
-
- {analyzing
- ? 'Analyzing…'
- : nothingToAnalyze
- ? 'Audio analyzed'
- : unanalyzedCount != null
- ? `Analyze audio (${unanalyzedCount})`
- : 'Analyze audio'}
-
-
-
+ }
>
-
- Export…
-
-
+ }
>
-
- Merge selected
-
+ Merge selected
+
{archiving ? : }
Archive selected
diff --git a/backends/advanced/webui/src/components/dataAudit/BulkSplitModal.tsx b/backends/advanced/webui/src/components/dataAudit/BulkSplitModal.tsx
index 713883fdb..f2f1cb3e4 100644
--- a/backends/advanced/webui/src/components/dataAudit/BulkSplitModal.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/BulkSplitModal.tsx
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { AlertTriangle, Loader2, Scissors, X } from 'lucide-react'
+import { AlertTriangle, Loader2, Scissors } from 'lucide-react'
import { AuditConversation, dataAuditApi } from '../../services/api'
import { formatDuration } from './format'
+import { Alert, Button, Modal } from '../ui'
interface Props {
conversations: AuditConversation[]
@@ -119,128 +120,111 @@ export default function BulkSplitModal({ conversations, minGapSeconds, onClose,
}
return (
-
-
- {/* Header */}
-
-
-
-
-
- Split at silence gaps
-
-
- {conversations.length} selected · splitting at gaps ≥{' '}
- {formatDuration(minGapSeconds)}
-
-
-
-
-
-
-
-
-
- {/* Summary */}
-
- {loadingPreviews ? (
-
-
- Detecting gaps…
-
- ) : (
-
- {splittable.length} conversation
- {splittable.length === 1 ? '' : 's'} → {totalNewParts} new parts
- {skipped > 0 && (
-
- {' '}
- · {skipped} skipped (no qualifying gaps / not analyzed)
-
- )}
-
- )}
-
-
- {error && (
-
- )}
-
- {/* Per-conversation breakdown */}
-
- {previews.map((p) => {
- const parts = p.splitPoints.length + 1
- return (
-
-
-
{p.title}
-
{formatDuration(p.durationSeconds)}
-
-
- {p.status === 'loading' && (
-
- )}
- {p.status === 'ready' && p.splitPoints.length > 0 && (
-
- {p.splitPoints.length} gap{p.splitPoints.length === 1 ? '' : 's'} → {parts}{' '}
- parts
-
- )}
- {p.status === 'ready' && p.splitPoints.length === 0 && (
- no qualifying gaps
- )}
- {p.status === 'needs_analysis' && (
- not analyzed
- )}
- {p.status === 'error' && (
-
- error
-
- )}
-
-
- )
- })}
-
-
-
- {/* Footer */}
-
+
}
+ title={
+ <>
+ Split at silence gaps
+
+ {conversations.length} selected · splitting at gaps ≥ {formatDuration(minGapSeconds)}
+
+ >
+ }
+ maxWidthClassName="max-w-2xl max-h-[85vh] overflow-y-auto"
+ footer={
+
{progress ||
'Each original is soft-deleted (recoverable from Archive). Transcripts are reassigned by time; memories and titles regenerate per part.'}
-
+
Cancel
-
-
+ : undefined}
>
- {splitting && }
-
- Split {splittable.length} conversation{splittable.length === 1 ? '' : 's'}
-
-
+ Split {splittable.length} conversation{splittable.length === 1 ? '' : 's'}
+
+ }
+ >
+
+ {/* Summary */}
+
+ {loadingPreviews ? (
+
+
+ Detecting gaps…
+
+ ) : (
+
+ {splittable.length} conversation
+ {splittable.length === 1 ? '' : 's'} → {totalNewParts} new parts
+ {skipped > 0 && (
+
+ {' '}
+ · {skipped} skipped (no qualifying gaps / not analyzed)
+
+ )}
+
+ )}
+
+
+ {error && (
+
}>
+ {error}
+
+ )}
+
+ {/* Per-conversation breakdown */}
+
+ {previews.map((p) => {
+ const parts = p.splitPoints.length + 1
+ return (
+
+
+
{p.title}
+
{formatDuration(p.durationSeconds)}
+
+
+ {p.status === 'loading' && (
+
+ )}
+ {p.status === 'ready' && p.splitPoints.length > 0 && (
+
+ {p.splitPoints.length} gap{p.splitPoints.length === 1 ? '' : 's'} → {parts}{' '}
+ parts
+
+ )}
+ {p.status === 'ready' && p.splitPoints.length === 0 && (
+ no qualifying gaps
+ )}
+ {p.status === 'needs_analysis' && (
+ not analyzed
+ )}
+ {p.status === 'error' && (
+
+ error
+
+ )}
+
+
+ )
+ })}
+
-
+
)
}
diff --git a/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx b/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx
index 617240751..9c3dd973b 100644
--- a/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/ExportModal.tsx
@@ -7,7 +7,6 @@ import {
PackageOpen,
ShieldCheck,
Trash2,
- X,
} from 'lucide-react'
import {
AuditConversation,
@@ -16,6 +15,7 @@ import {
ScreenResult,
dataAuditApi,
} from '../../services/api'
+import { Alert, Button, Modal, Textarea } from '../../components/ui'
import { useJobPolling } from '../../hooks/useJobPolling'
import { formatDate, formatDuration } from './format'
@@ -351,27 +351,24 @@ export default function ExportModal({ selected, onClose }: Props) {
const needsScreenFirst = screenEnabled && !resultValid && !screening
return (
-
-
- {/* Header */}
-
-
-
-
- Export for annotation
-
-
-
-
-
-
-
-
+
}
+ maxWidthClassName="max-w-2xl"
+ className="max-h-[85vh] overflow-y-auto"
+ footer={
+
+ Close
+
+ }
+ >
+
{error && (
-
+
}>
+ {error}
+
)}
{/* New export */}
@@ -484,12 +481,12 @@ export default function ExportModal({ selected, onClose }: Props) {
-
@@ -549,27 +546,26 @@ export default function ExportModal({ selected, onClose }: Props) {
- : undefined}
>
- {busy && }
-
- {screening
- ? progress && progress.total
- ? `Screening ${progress.done}/${progress.total}…`
- : 'Screening…'
- : exporting
- ? status || 'Exporting…'
- : needsScreenFirst
- ? `Screen ${selected.length} conversation${selected.length === 1 ? '' : 's'}`
- : `Export ${selected.length} conversation${selected.length === 1 ? '' : 's'}` +
- (screenEnabled && totalExcluded > 0
- ? ` · withholding ${totalExcluded} segment${totalExcluded === 1 ? '' : 's'}`
- : '')}
-
-
+ {screening
+ ? progress && progress.total
+ ? `Screening ${progress.done}/${progress.total}…`
+ : 'Screening…'
+ : exporting
+ ? status || 'Exporting…'
+ : needsScreenFirst
+ ? `Screen ${selected.length} conversation${selected.length === 1 ? '' : 's'}`
+ : `Export ${selected.length} conversation${selected.length === 1 ? '' : 's'}` +
+ (screenEnabled && totalExcluded > 0
+ ? ` · withholding ${totalExcluded} segment${totalExcluded === 1 ? '' : 's'}`
+ : '')}
+
{selected.length === 0 && !busy && (
Select conversations in the table first
@@ -668,17 +664,7 @@ export default function ExportModal({ selected, onClose }: Props) {
- {/* Footer */}
-
-
- Close
-
-
-
-
+
)
}
diff --git a/backends/advanced/webui/src/components/dataAudit/GuidedEnrollment.tsx b/backends/advanced/webui/src/components/dataAudit/GuidedEnrollment.tsx
index 82b4774c0..f74337740 100644
--- a/backends/advanced/webui/src/components/dataAudit/GuidedEnrollment.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/GuidedEnrollment.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
+ ArrowUp,
Check,
AudioLines,
Eraser,
@@ -31,6 +32,7 @@ import { formatClock } from './format'
import SpeakerInlineInput from '../SpeakerInlineInput'
import { Region, WaveformRegionEditor } from '../audio/WaveformRegionEditor'
import { useJobPolling } from '../../hooks/useJobPolling'
+import { Button, StateBadge } from '../ui'
type EnrolledSpeaker = { speaker_id: string; name: string }
@@ -100,15 +102,18 @@ function EnrollmentTrend({ sessions }: { sessions: GuidedEnrollmentSession[] })
Observed gallery cohesion
-
higher is more internally consistent
+
+
+ Higher is better
+
-
+
{points.map((point) => (
-
+
{point.cohesion.toFixed(3)}
))}
@@ -187,10 +192,14 @@ function BenchmarkPanel({ speakerName }: { speakerName: string }) {
Five folds grouped by conversation; cached embeddings; live galleries unchanged.
-
- {running ? : }
+ : }
+ >
{running ? 'Benchmarking' : report ? 'Run again' : 'Run benchmark'}
-
+
{progress &&
{progress}
}
{benchmarkError &&
{benchmarkError}
}
@@ -224,22 +233,14 @@ function BenchmarkPanel({ speakerName }: { speakerName: string }) {
function flagBadge(clip: GuidedEnrollmentGalleryResponse['clips'][number]) {
if (clip.flags.includes('mislabel'))
return (
-
+
sounds like {clip.suggested?.name || 'another speaker'}
-
+
)
if (clip.flags.includes('junk'))
- return (
-
- junk
-
- )
+ return
junk
if (clip.flags.includes('weak'))
- return (
-
- weak match
-
- )
+ return
weak match
return null
}
@@ -846,18 +847,20 @@ export default function GuidedEnrollment() {
{speaker && (
-
speaker && suggest(speaker)}
disabled={!speaker || loading || submitting}
- className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded bg-blue-600 text-white disabled:opacity-50"
+ icon={
+ loading ? (
+
+ ) : (
+
+ )
+ }
>
- {loading ? (
-
- ) : (
-
- )}
Fresh batch
-
+
{
@@ -872,15 +875,15 @@ export default function GuidedEnrollment() {
Sort: most informative
Sort: best match first
-
discoverCorpus(speaker)}
disabled={discovering}
- className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-50"
title="Refresh the reusable speech-embedding index and rescore it against this gallery"
+ icon={discovering ? : }
>
- {discovering ? : }
{discovering ? 'Searching corpus' : 'Refresh corpus'}
-
+
mineFiles(speaker, Array.from(e.target.files || []))}
/>
- miningInputRef.current?.click()}
disabled={mining || discovering}
- className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-50"
title="Upload unlabelled audio (recordings, exports) and mine it for this speaker's voice. Files are kept out of memory processing."
+ icon={mining ? : }
>
- {mining ? : }
{mining ? 'Uploading…' : 'Mine audio files'}
-
+
{suggestion && (
gallery: {suggestion.speaker.n_clips ?? '?'} clips
@@ -1071,15 +1074,23 @@ export default function GuidedEnrollment() {
)
})}
- : undefined}
>
- {submitting && }
Submit {decidedCount}/{suggestion.batch.length} & next batch
-
- Skip remaining & fresh batch
+
+ }
+ >
+ Skip remaining & fresh batch
+
>
)}
diff --git a/backends/advanced/webui/src/components/dataAudit/MergePreviewModal.tsx b/backends/advanced/webui/src/components/dataAudit/MergePreviewModal.tsx
index 9fdb9c85b..bbe4a9259 100644
--- a/backends/advanced/webui/src/components/dataAudit/MergePreviewModal.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/MergePreviewModal.tsx
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
-import { AlertTriangle, GitMerge, Loader2, X } from 'lucide-react'
+import { AlertTriangle, GitMerge, Loader2 } from 'lucide-react'
import { AuditConversation, dataAuditApi } from '../../services/api'
import { formatDate, formatDuration } from './format'
+import { Alert, Button, Modal } from '../ui'
interface Props {
conversations: AuditConversation[]
@@ -39,84 +40,71 @@ export default function MergePreviewModal({ conversations, onClose, onDone }: Pr
}
return (
-
-
- {/* Header */}
-
-
-
-
- Merge {ordered.length} conversations
-
-
-
-
-
-
-
-
- {error && (
-
- )}
-
-
- {ordered.map((c, i) => (
-
-
- {i + 1}.
- {c.title || c.conversation_id.slice(0, 8)}
-
-
- {formatDate(c.created_at)} · {formatDuration(c.duration_seconds)}
-
-
- ))}
-
-
-
- Combined duration: {formatDuration(totalDuration)}
-
-
-
-
- Conversations must be adjacent — if another conversation from this device sits
- between them (even one filtered out of this view), the merge is rejected.
-
-
- Wall-clock gaps between the recordings are elided; a note marker in the transcript
- records each seam.
-
-
- The originals are soft-deleted (recoverable from Archive). Memories and the title
- are regenerated for the merged conversation.
-
-
-
-
- {/* Footer */}
-
-
+ }
+ maxWidthClassName="max-w-xl max-h-[85vh] overflow-y-auto"
+ footer={
+ <>
+
Cancel
-
-
+ : undefined}
>
- {merging && }
- Merge
-
+ Merge
+
+ >
+ }
+ >
+
+ {error && (
+
}>
+ {error}
+
+ )}
+
+
+ {ordered.map((c, i) => (
+
+
+ {i + 1}.
+ {c.title || c.conversation_id.slice(0, 8)}
+
+
+ {formatDate(c.created_at)} · {formatDuration(c.duration_seconds)}
+
+
+ ))}
+
+
+
+ Combined duration: {formatDuration(totalDuration)}
+
+
+
+ Conversations must be adjacent — if another conversation from this device sits
+ between them (even one filtered out of this view), the merge is rejected.
+
+
+ Wall-clock gaps between the recordings are elided; a note marker in the transcript
+ records each seam.
+
+
+ The originals are soft-deleted (recoverable from Archive). Memories and the title
+ are regenerated for the merged conversation.
+
+
-
+
)
}
diff --git a/backends/advanced/webui/src/components/dataAudit/SplitConversationModal.tsx b/backends/advanced/webui/src/components/dataAudit/SplitConversationModal.tsx
index 3b60ea493..7942263ee 100644
--- a/backends/advanced/webui/src/components/dataAudit/SplitConversationModal.tsx
+++ b/backends/advanced/webui/src/components/dataAudit/SplitConversationModal.tsx
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { AlertTriangle, Loader2, Scissors, X } from 'lucide-react'
+import { AlertTriangle, Loader2, Scissors } from 'lucide-react'
import { SilenceGap, dataAuditApi } from '../../services/api'
import { useJobPolling } from '../../hooks/useJobPolling'
import { formatClock, formatDuration } from './format'
import PreviewStrip from './PreviewStrip'
+import { Alert, Button, Modal } from '../ui'
// Minimal shape so both the Data Audit table rows and the conversation
// detail page can open this modal.
@@ -115,78 +116,94 @@ export default function SplitConversationModal({ conversation, onClose, onDone }
}
return (
-
-
- {/* Header */}
-
+
}
+ title={
+ <>
+ Split conversation
+
+ {conversation.title || conversation.conversation_id.slice(0, 8)} ·{' '}
+ {formatDuration(conversation.duration_seconds)}
+
+ >
+ }
+ maxWidthClassName="max-w-2xl max-h-[85vh] overflow-y-auto"
+ footer={
+
+
+ The original is soft-deleted (recoverable from Archive). Transcript segments are
+ reassigned by time; memories and titles are regenerated per part.
+
-
-
-
Split conversation
-
- {conversation.title || conversation.conversation_id.slice(0, 8)} ·{' '}
- {formatDuration(conversation.duration_seconds)}
-
-
+
+ Cancel
+
+
: undefined}
+ >
+ Split into {Math.max(previewParts.length, 2)} parts
+
-
-
-
-
-
- {/* Controls */}
-
-
-
- Min. silence gap (minutes)
-
- setGapMinutes(Math.max(1, Number(e.target.value)))}
- className="w-28 mt-1 px-2 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-700 dark:text-gray-200"
- />
-
-
-
- VAD threshold
- {speechThreshold.toFixed(2)}
-
- setSpeechThreshold(Number(e.target.value))}
- className="w-full mt-1"
- />
-
- {loading &&
}
+ }
+ >
+
+ {/* Controls */}
+
+
+
+ Min. silence gap (minutes)
+
+ setGapMinutes(Math.max(1, Number(e.target.value)))}
+ className="w-28 mt-1 px-2 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-700 dark:text-gray-200"
+ />
+
+
+
+ VAD threshold
+ {speechThreshold.toFixed(2)}
+
+ setSpeechThreshold(Number(e.target.value))}
+ className="w-full mt-1"
+ />
+ {loading &&
}
+
- {error && (
-
- )}
+ {error && (
+
}>
+ {error}
+
+ )}
- {/* Needs analysis */}
- {needsAnalysis && !loading && (
-
-
- This conversation's audio hasn't been VAD-analyzed yet. Run analysis to locate
- silence gaps (this decodes the audio and can take a few minutes for long recordings).
-
-
- {analyzing && }
- {analyzing ? 'Analyzing audio…' : 'Analyze audio'}
-
-
- )}
+ {/* Needs analysis */}
+ {needsAnalysis && !loading && (
+
+
+ This conversation's audio hasn't been VAD-analyzed yet. Run analysis to locate
+ silence gaps (this decodes the audio and can take a few minutes for long recordings).
+
+
: undefined}
+ >
+ {analyzing ? 'Analyzing audio…' : 'Analyze audio'}
+
+
+ )}
{/* Speech timeline: blue = speech, amber = detected gaps, red = chosen split points */}
{!needsAnalysis && !loading && duration > 0 && (
@@ -247,32 +264,7 @@ export default function SplitConversationModal({ conversation, onClose, onDone }
)}
>
)}
-
-
- {/* Footer */}
-
-
- The original is soft-deleted (recoverable from Archive). Transcript segments are
- reassigned by time; memories and titles are regenerated per part.
-
-
-
- Cancel
-
-
- {splitting && }
- Split into {Math.max(previewParts.length, 2)} parts
-
-
-
-
+
)
}
diff --git a/backends/advanced/webui/src/components/finetuning/EnrollmentCandidates.tsx b/backends/advanced/webui/src/components/finetuning/EnrollmentCandidates.tsx
index 1d152151b..5edc09c70 100644
--- a/backends/advanced/webui/src/components/finetuning/EnrollmentCandidates.tsx
+++ b/backends/advanced/webui/src/components/finetuning/EnrollmentCandidates.tsx
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Play, Pause, RefreshCw, ShieldCheck, Check, AlertTriangle } from 'lucide-react'
import { finetuningApi } from '../../services/api'
import { useGaplessPlayer } from '../../hooks/useGaplessPlayer'
+import { Alert, Button, IconButton } from '../ui'
interface Clip {
conversation_id: string
@@ -15,6 +16,7 @@ interface Clip {
gated_in: boolean
default_selected: boolean
reasons: string[]
+ auto_identified?: boolean
}
interface SpeakerGroup {
speaker: string
@@ -44,10 +46,13 @@ export default function EnrollmentCandidates() {
const [enrolling, setEnrolling] = useState(false)
const [resultMsg, setResultMsg] = useState
(null)
const [error, setError] = useState(null)
+ // Off by default: only clips you relabelled by hand are candidates. When on,
+ // segments auto-labelled by identification are also shown (never pre-ticked).
+ const [includeIdentified, setIncludeIdentified] = useState(false)
const { data, isLoading, refetch, isFetching } = useQuery({
- queryKey: ['finetuning', 'enrollmentCandidates'],
- queryFn: () => finetuningApi.getEnrollmentCandidates().then((r) => r.data),
+ queryKey: ['finetuning', 'enrollmentCandidates', includeIdentified],
+ queryFn: () => finetuningApi.getEnrollmentCandidates(includeIdentified).then((r) => r.data),
})
// Seed the selection from the gate's defaults whenever fresh data arrives.
@@ -76,6 +81,30 @@ export default function EnrollmentCandidates() {
})
}
+ const setSpeakerSelected = (group: SpeakerGroup, shouldSelect: boolean) => {
+ setSelected((prev) => {
+ const next = new Set(prev)
+ group.clips.forEach((clip) => {
+ const key = clipKey(clip)
+ if (shouldSelect && clip.default_selected) next.add(key)
+ else next.delete(key)
+ })
+ return next
+ })
+ }
+
+ const selectAllSpeakers = () => {
+ setSelected(
+ new Set(
+ (data?.candidates || []).flatMap((group) =>
+ group.clips.filter((clip) => clip.default_selected).map(clipKey)
+ )
+ )
+ )
+ }
+
+ const deselectAllSpeakers = () => setSelected(new Set())
+
const playClip = (c: Clip) => {
const segId = `enroll-${clipKey(c)}`
if (player.playingSegmentId === segId) player.stop()
@@ -118,32 +147,40 @@ export default function EnrollmentCandidates() {
Curated Speaker Enrollment
-
refetch()}
disabled={isFetching}
- className="flex items-center space-x-1.5 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
+ icon={ }
>
-
- Refresh
-
+ Refresh
+
-
- Clips from each conversation's active version, gated for quality (≥ {data?.min_duration ?? 3}s, no cross-talk,
- deduped). Clean clips are pre-selected; greyed clips are excluded with a reason — tick them to override.
- Only the checked clips are enrolled.
+
+ Only the segments you relabelled by hand are candidates, gated for quality
+ (≥ {data?.min_duration ?? 3}s, no cross-talk, deduped). Clean clips are pre-selected; greyed clips are
+ excluded with a reason — tick them to override. Only the checked clips are enrolled.
+
+ setIncludeIdentified(e.target.checked)}
+ className="h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
+ />
+ Also show auto-identified segments
+ (off by default — never pre-ticked; enrolling auto-matches reinforces weak IDs)
+
{resultMsg && (
-
-
- {resultMsg}
-
+
}>
+ {resultMsg}
+
)}
{error && (
-
+
}>
+ {error}
+
)}
{isLoading ? (
@@ -154,16 +191,50 @@ export default function EnrollmentCandidates() {
) : (
<>
+
+
+ Choose speakers to enroll
+
+
+
+ Select all
+
+
+ Deselect all
+
+
+
- {data.candidates.map((group) => (
-
-
-
{group.speaker}
-
- {group.clips.filter((c) => selected.has(clipKey(c))).length} of {group.clips.length} selected
-
-
-
+ {data.candidates.map((group) => {
+ const selectedCount = group.clips.filter((c) => selected.has(clipKey(c))).length
+ const defaultCount = group.clips.filter((c) => c.default_selected).length
+ const speakerSelected = selectedCount > 0
+ return (
+
+
+
+ setSpeakerSelected(group, !speakerSelected)}
+ disabled={defaultCount === 0}
+ className="h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500 disabled:cursor-not-allowed disabled:opacity-40"
+ />
+ {group.speaker}
+
+
+ {selectedCount} of {group.clips.length} clips selected
+
+
+
{group.clips.map((c) => {
const isSel = selected.has(clipKey(c))
const segId = `enroll-${clipKey(c)}`
@@ -185,17 +256,22 @@ export default function EnrollmentCandidates() {
onChange={() => toggle(c)}
className="h-4 w-4 rounded border-gray-300 text-emerald-600 focus:ring-emerald-500"
/>
-
playClip(c)}
- className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
- title={`Play ${c.duration}s`}
+ className="flex-shrink-0"
+ label={`Play ${c.duration}s`}
>
{playing ? : }
-
+
{c.duration}s
{c.text || (no text) }
+ {c.auto_identified && (
+
+ auto-identified
+
+ )}
{!c.gated_in && (
{c.reasons.join(', ')}
@@ -210,9 +286,10 @@ export default function EnrollmentCandidates() {
)
})}
+
-
- ))}
+ )
+ })}
diff --git a/backends/advanced/webui/src/components/layout/Layout.tsx b/backends/advanced/webui/src/components/layout/Layout.tsx
index 78f07fa06..242161e29 100644
--- a/backends/advanced/webui/src/components/layout/Layout.tsx
+++ b/backends/advanced/webui/src/components/layout/Layout.tsx
@@ -7,6 +7,7 @@ import { useSSE, SSEStatus } from '../../hooks/useSSE'
import { useSystemEventsSummary } from '../../hooks/useSystemEvents'
import GlobalRecordingIndicator from './GlobalRecordingIndicator'
import UserLoopModal from '../UserLoopModal'
+import { IconButton } from '../ui'
export default function Layout() {
const location = useLocation()
@@ -43,7 +44,7 @@ export default function Layout() {
{ path: '/wakeword-lab', label: 'Wake-Word Lab', icon: Target },
{ path: '/queue', label: 'Queue & Events', icon: Layers },
{ path: '/plugins', label: 'Plugins', icon: Puzzle },
- { path: '/finetuning', label: 'Fine-tuning', icon: Zap },
+ { path: '/finetuning', label: 'Training', icon: Zap },
{ path: '/network', label: 'Network', icon: Network },
{ path: '/system', label: 'System Status', icon: Activity },
{ path: '/system-errors', label: 'System Errors', icon: AlertTriangle },
@@ -81,13 +82,13 @@ export default function Layout() {
{/* Hamburger — opens the nav drawer on mobile only */}
-
setMobileNavOpen(true)}
- className="lg:hidden p-2 -ml-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300"
- aria-label="Open navigation menu"
+ className="lg:hidden -ml-2"
>
-
+
Chronicle Dashboard
@@ -100,13 +101,9 @@ export default function Layout() {
{/* Global Recording Indicator */}
-
+
{isDark ? : }
-
+
{/* User info — hidden on small screens to avoid overflow */}
@@ -145,13 +142,12 @@ export default function Layout() {
Chronicle
- setMobileNavOpen(false)}
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300"
- aria-label="Close navigation menu"
>
-
+
{navLinks}
diff --git a/backends/advanced/webui/src/components/plugins/EnvVarsSection.tsx b/backends/advanced/webui/src/components/plugins/EnvVarsSection.tsx
index 382baeca9..742e2af9d 100644
--- a/backends/advanced/webui/src/components/plugins/EnvVarsSection.tsx
+++ b/backends/advanced/webui/src/components/plugins/EnvVarsSection.tsx
@@ -1,5 +1,6 @@
import { Key } from 'lucide-react'
import FormField, { FieldSchema } from './FormField'
+import { Alert } from '../ui'
interface EnvVarsSectionProps {
schema: Record
@@ -80,12 +81,10 @@ export default function EnvVarsSection({
})}
-
-
- Note: Changing environment variables requires a backend restart to take effect.
- Existing values are masked with •••••••• for security.
-
-
+
+ Note: Changing environment variables requires a backend restart to take effect.
+ Existing values are masked with •••••••• for security.
+
)
}
diff --git a/backends/advanced/webui/src/components/plugins/FormField.tsx b/backends/advanced/webui/src/components/plugins/FormField.tsx
index cdd16ba3d..dc99226b9 100644
--- a/backends/advanced/webui/src/components/plugins/FormField.tsx
+++ b/backends/advanced/webui/src/components/plugins/FormField.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { AlertCircle, Eye, EyeOff } from 'lucide-react'
+import { Checkbox, IconButton, Input, Label, Select, Textarea } from '../ui'
export interface FieldSchema {
type: 'string' | 'number' | 'boolean' | 'password' | 'enum' | 'array' | 'object'
@@ -40,35 +41,23 @@ export default function FormField({
switch (schema.type) {
case 'boolean':
return (
-
- onChange(e.target.checked)}
- disabled={disabled}
- className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded disabled:opacity-50"
- />
-
- {schema.label}
-
-
+
onChange(e.target.checked)}
+ disabled={disabled}
+ label={schema.label}
+ />
)
case 'number':
return (
-
+
{schema.label}
{schema.required && * }
-
-
+
{schema.help_text && (
@@ -91,10 +79,7 @@ export default function FormField({
return (
-
+
{schema.label}
{schema.required && * }
{schema.env_var && (
@@ -102,9 +87,9 @@ export default function FormField({
(${schema.env_var})
)}
-
+
-
- setShowPassword(!showPassword)}
- title={showPassword ? 'Hide password' : 'Show password'}
- className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
disabled={disabled}
+ className="absolute right-2 top-1/2 -translate-y-1/2"
>
{showPassword ? (
) : (
)}
-
+
{schema.help_text && (
@@ -158,26 +142,22 @@ export default function FormField({
case 'enum':
return (
-
+
{schema.label}
{schema.required && * }
-
-
+ onChange(e.target.value)}
disabled={disabled}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{schema.options?.map((option) => (
{option.label}
))}
-
+
{schema.help_text && (
{schema.help_text}
@@ -190,14 +170,11 @@ export default function FormField({
const jsonStr = typeof value === 'string' ? value : JSON.stringify(value ?? schema.default ?? {}, null, 2)
return (
-
+
{schema.label}
{schema.required && * }
-
-
diff --git a/backends/advanced/webui/src/components/plugins/PluginListSidebar.tsx b/backends/advanced/webui/src/components/plugins/PluginListSidebar.tsx
index b3f0b14e4..954698e62 100644
--- a/backends/advanced/webui/src/components/plugins/PluginListSidebar.tsx
+++ b/backends/advanced/webui/src/components/plugins/PluginListSidebar.tsx
@@ -1,3 +1,5 @@
+import { StateBadge } from '../ui'
+
interface Plugin {
plugin_id: string
name: string
@@ -71,51 +73,27 @@ export default function PluginListSidebar({
const getStatusBadge = (plugin: Plugin) => {
if (!plugin.enabled) {
- return (
-
- Disabled
-
- )
+ return Disabled
}
const conn = connectivity[plugin.plugin_id]
if (conn?.ok) {
const label = conn.latency_ms != null ? `Active (${conn.latency_ms}ms)` : 'Active'
- return (
-
- {label}
-
- )
+ return {label}
}
if (conn && !conn.ok) {
- return (
-
- Error
-
- )
+ return Error
}
// Fallback to status-based badge
switch (plugin.status) {
case 'active':
- return (
-
- Active
-
- )
+ return Active
case 'error':
- return (
-
- Error
-
- )
+ return Error
default:
- return (
-
- Unknown
-
- )
+ return Unknown
}
}
diff --git a/backends/advanced/webui/src/components/transcript/TranscriptEditor.tsx b/backends/advanced/webui/src/components/transcript/TranscriptEditor.tsx
index 39213106f..4d8c2081a 100644
--- a/backends/advanced/webui/src/components/transcript/TranscriptEditor.tsx
+++ b/backends/advanced/webui/src/components/transcript/TranscriptEditor.tsx
@@ -8,6 +8,7 @@ import { PlayheadWaveform, PlayheadTimeLabel } from '../audio/PlayheadWaveform'
import { WaveformRegionEditor, Region } from '../audio/WaveformRegionEditor'
import InsertSegmentForm from './InsertSegmentForm'
import { useWaveformZoomDisabled } from './useWaveformZoom'
+import { IconButton, StateBadge } from '../ui'
export interface Segment {
start: number
@@ -29,6 +30,22 @@ interface TranscriptEditorProps {
isLive?: boolean
enrolledSpeakers: { speaker_id: string; name: string }[]
hideUnknownSpeakers?: boolean
+ speakerRecognition?: {
+ identification_mode?: string
+ identification_evidence?: {
+ similarity_threshold?: number
+ labels?: Record
+ }>
+ }>
+ }
+ } | null
/** Called after annotations are applied (parent should refetch the conversation). */
onChanged?: () => void
}
@@ -74,6 +91,7 @@ export default function TranscriptEditor({
isLive = false,
enrolledSpeakers,
hideUnknownSpeakers = false,
+ speakerRecognition = null,
onChanged,
}: TranscriptEditorProps) {
const player = useGaplessPlayer()
@@ -111,6 +129,7 @@ export default function TranscriptEditor({
const [newSpeakerRegion, setNewSpeakerRegion] = useState(null)
const [speakerSnipTime, setSpeakerSnipTime] = useState(null)
const [speakerFilters, setSpeakerFilters] = useState>({})
+ const [showRecognitionEvidence, setShowRecognitionEvidence] = useState(false)
// While inserting with the waveform open, the region drawn on it for the new segment.
const [insertRegion, setInsertRegion] = useState(null)
// Whether the insert menu drives the top waveform (draw the new segment's span).
@@ -458,17 +477,18 @@ export default function TranscriptEditor({
) : (
ins.insert_text
)}
-
+
Pending Insert
-
+
- handleDeleteAnnotation(ins.id)}
- className="ml-2 text-gray-400 hover:text-red-500"
- title="Remove insert"
+ className="ml-2"
>
-
+
))}
{/* When the waveform is available, the insert form moves up next to it (so you can
@@ -639,6 +659,56 @@ export default function TranscriptEditor({
)}
+ {speakerRecognition?.identification_evidence?.labels && (
+
+
setShowRecognitionEvidence((value) => !value)}
+ className="flex w-full items-center justify-between px-3 py-2 text-left text-xs text-gray-600 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700/40"
+ >
+
+ {showRecognitionEvidence ? : }
+ Speaker recognition evidence
+
+
+ threshold {speakerRecognition.identification_evidence.similarity_threshold?.toFixed(2) ?? '—'}
+
+
+ {showRecognitionEvidence && (
+
+ {Object.entries(speakerRecognition.identification_evidence.labels).map(([label, evidence]) => (
+
+
+ {label}
+
+ {evidence.assigned_name
+ ? `assigned ${evidence.assigned_name} · ${(evidence.assigned_confidence ?? 0).toFixed(3)}`
+ : 'left unknown'}
+
+
+
+ {(evidence.samples || []).map((sample) => (
+
+
+ {formatDuration(sample.start)}–{formatDuration(sample.end)}
+
+ {(sample.candidates || []).slice(0, 3).map((candidate, rank) => (
+
+ {rank + 1}. {candidate.name}
+ {candidate.similarity.toFixed(3)}
+
+ ))}
+ {!sample.candidates?.length &&
No candidates recorded }
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ )}
+
{/* Waveform — doubles as the timing editor while editing a segment */}
{showAudio && (
Segment {idx + 1} of {segments.length}
- selectSpeakerSegment(idx - 1)}
- className="p-1 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-30"
- title="Previous segment"
- aria-label="Previous segment"
+ label="Previous segment"
>
-
-
+ = segments.length - 1}
onClick={() => selectSpeakerSegment(idx + 1)}
- className="p-1 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-30"
- title="Next segment"
- aria-label="Next segment"
+ label="Next segment"
>
-
+
Continue
- setSelectedSpeakerSegment(null)} className="p-1 text-gray-400 hover:text-gray-700" title="Close selection">
+ setSelectedSpeakerSegment(null)} label="Close selection">
-
+
{speakerCreationMode === 'draw' && (
@@ -1048,9 +1112,14 @@ export default function TranscriptEditor({
) : (
<>
{diarA && (
-
handleDeleteAnnotation(diarA.id)} className="flex-shrink-0 mt-1 text-gray-400 hover:text-red-500" title={`Revert to "${diarA.original_speaker}"`}>
+ handleDeleteAnnotation(diarA.id)}
+ className="flex-shrink-0 mt-1"
+ danger
+ label={`Revert to "${diarA.original_speaker}"`}
+ >
-
+
)}
= {
+ info: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
+ success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
+ warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
+ danger: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
+}
+
+export interface AlertProps extends HTMLAttributes {
+ tone?: AlertTone
+ icon?: ReactNode
+ children: ReactNode
+}
+
+/** Inline status banner. Reserve tone for genuine signals. */
+export function Alert({ tone = 'info', icon, children, className, ...rest }: AlertProps) {
+ return (
+
+ {icon}
+ {children}
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/Button.tsx b/backends/advanced/webui/src/components/ui/Button.tsx
new file mode 100644
index 000000000..9755d64a0
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Button.tsx
@@ -0,0 +1,54 @@
+import { ButtonHTMLAttributes, ReactNode, forwardRef } from 'react'
+import clsx from 'clsx'
+
+export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
+export type ButtonSize = 'sm' | 'md'
+
+const VARIANT: Record = {
+ primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:hover:bg-blue-600',
+ secondary:
+ 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
+ danger: 'bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600',
+ ghost:
+ 'bg-transparent text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800',
+}
+
+const SIZE: Record = {
+ sm: 'px-3 py-1.5 text-sm',
+ md: 'px-4 py-2 text-sm',
+}
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant
+ size?: ButtonSize
+ /** Leading icon element (e.g. a lucide-react icon). */
+ icon?: ReactNode
+}
+
+/**
+ * Chronicle Espresso primary control. `variant` maps to the design-system button
+ * families (primary = terracotta accent, secondary = neutral chip, danger, ghost).
+ */
+export const Button = forwardRef(function Button(
+ { variant = 'secondary', size = 'sm', icon, children, className, type = 'button', ...rest },
+ ref
+) {
+ return (
+
+ {icon}
+ {children != null && {children} }
+
+ )
+})
diff --git a/backends/advanced/webui/src/components/ui/Card.tsx b/backends/advanced/webui/src/components/ui/Card.tsx
new file mode 100644
index 000000000..19132c427
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Card.tsx
@@ -0,0 +1,30 @@
+import { HTMLAttributes, ReactNode } from 'react'
+import clsx from 'clsx'
+
+export interface CardProps extends HTMLAttributes {
+ /** Filled surface with soft elevation (header/sidebar/main panel). Default is a bordered, transparent tile. */
+ raised?: boolean
+ /** Apply the standard inner padding (p-4). Set false for flush content (e.g. tables). Default true. */
+ padded?: boolean
+ children: ReactNode
+}
+
+/**
+ * Chronicle Espresso surface. `raised` gives the elevated card look
+ * (bg-surface-raised + shadow); otherwise it's a bordered tile on the page.
+ */
+export function Card({ raised, padded = true, children, className, ...rest }: CardProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/Checkbox.tsx b/backends/advanced/webui/src/components/ui/Checkbox.tsx
new file mode 100644
index 000000000..d65a04b77
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Checkbox.tsx
@@ -0,0 +1,28 @@
+import { InputHTMLAttributes, ReactNode } from 'react'
+import clsx from 'clsx'
+
+export interface CheckboxProps extends Omit, 'type'> {
+ label?: ReactNode
+ /** Secondary, fainter helper text after the label. */
+ hint?: ReactNode
+}
+
+/** Checkbox with an inline label; the terracotta accent colors the checked box. */
+export function Checkbox({ label, hint, className, ...rest }: CheckboxProps) {
+ return (
+
+
+ {label != null && {label} }
+ {hint != null && {hint} }
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/Chip.tsx b/backends/advanced/webui/src/components/ui/Chip.tsx
new file mode 100644
index 000000000..8ea00940a
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Chip.tsx
@@ -0,0 +1,64 @@
+import { ReactNode } from 'react'
+
+/**
+ * Muted pill for non-actionable metadata: versions, counts, provenance, kinds,
+ * operation history, provider names. Metadata should look like metadata — never
+ * give it an accent color. For a genuine state signal (error/warning/success),
+ * use StateBadge instead.
+ */
+export function MetadataChip({
+ children,
+ title,
+ className = '',
+}: {
+ children: ReactNode
+ title?: string
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export type StateTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'suggest' | 'mono'
+
+// `info` uses the muted info-blue (sky) ramp, kept distinct from the terracotta brand.
+const TONE: Record = {
+ neutral: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
+ info: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300',
+ success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
+ warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
+ danger: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
+ suggest: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
+ mono: 'bg-gray-50 font-mono text-gray-500 dark:bg-gray-900 dark:text-gray-400',
+}
+
+/**
+ * Badge for a genuine state signal (error, warning, success, active). Reserve
+ * color for state — do not use it for descriptive metadata (use MetadataChip).
+ */
+export function StateBadge({
+ tone = 'neutral',
+ children,
+ title,
+ className = '',
+}: {
+ tone?: StateTone
+ children: ReactNode
+ title?: string
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/IconButton.tsx b/backends/advanced/webui/src/components/ui/IconButton.tsx
new file mode 100644
index 000000000..88d1809a3
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/IconButton.tsx
@@ -0,0 +1,38 @@
+import { ButtonHTMLAttributes, ReactNode, forwardRef } from 'react'
+import clsx from 'clsx'
+
+export interface IconButtonProps extends ButtonHTMLAttributes {
+ /** Accessible label — applied to both aria-label and title. */
+ label: string
+ /** Red hover treatment for destructive actions. */
+ danger?: boolean
+ children: ReactNode
+}
+
+/** Borderless icon-only control (toolbars, row actions). */
+export const IconButton = forwardRef(function IconButton(
+ { label, danger, children, className, type = 'button', ...rest },
+ ref
+) {
+ return (
+
+ {children}
+
+ )
+})
diff --git a/backends/advanced/webui/src/components/ui/Input.tsx b/backends/advanced/webui/src/components/ui/Input.tsx
new file mode 100644
index 000000000..2b07adeca
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Input.tsx
@@ -0,0 +1,20 @@
+import { InputHTMLAttributes, TextareaHTMLAttributes, forwardRef } from 'react'
+import clsx from 'clsx'
+
+const FIELD =
+ 'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 ' +
+ 'focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 ' +
+ 'disabled:cursor-not-allowed disabled:opacity-60 ' +
+ 'dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500'
+
+export const Input = forwardRef>(
+ function Input({ className, ...rest }, ref) {
+ return
+ }
+)
+
+export const Textarea = forwardRef>(
+ function Textarea({ className, ...rest }, ref) {
+ return
+ }
+)
diff --git a/backends/advanced/webui/src/components/ui/Label.tsx b/backends/advanced/webui/src/components/ui/Label.tsx
new file mode 100644
index 000000000..c6cac8b4f
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Label.tsx
@@ -0,0 +1,18 @@
+import { LabelHTMLAttributes, ReactNode } from 'react'
+import clsx from 'clsx'
+
+export interface LabelProps extends LabelHTMLAttributes {
+ children: ReactNode
+}
+
+/** Form field label. */
+export function Label({ children, className, ...rest }: LabelProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/Modal.tsx b/backends/advanced/webui/src/components/ui/Modal.tsx
new file mode 100644
index 000000000..3f43c6a86
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Modal.tsx
@@ -0,0 +1,72 @@
+import { ReactNode, useEffect } from 'react'
+import clsx from 'clsx'
+
+export interface ModalProps {
+ open: boolean
+ onClose: () => void
+ title?: ReactNode
+ icon?: ReactNode
+ /** Footer actions row (right-aligned). */
+ footer?: ReactNode
+ children: ReactNode
+ /** Tailwind max-width class for the panel. Default `max-w-md`. */
+ maxWidthClassName?: string
+ className?: string
+ /** Close when the user presses Escape. Default true; set false for data-entry forms or in-flight operations. */
+ closeOnEscape?: boolean
+ /** Close when the user clicks the backdrop. Default true. */
+ closeOnBackdrop?: boolean
+}
+
+/** Centered dialog over a scrim. Closes on overlay click or Escape (both opt-out-able). */
+export function Modal({
+ open,
+ onClose,
+ title,
+ icon,
+ footer,
+ children,
+ maxWidthClassName = 'max-w-md',
+ className,
+ closeOnEscape = true,
+ closeOnBackdrop = true,
+}: ModalProps) {
+ useEffect(() => {
+ if (!open || !closeOnEscape) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [open, onClose, closeOnEscape])
+
+ if (!open) return null
+
+ return (
+
+
e.stopPropagation()}
+ role="dialog"
+ aria-modal="true"
+ className={clsx(
+ 'flex w-full flex-col gap-4 rounded-xl border border-gray-200 bg-white p-5 shadow-lg',
+ 'dark:border-gray-700 dark:bg-gray-800',
+ maxWidthClassName,
+ className
+ )}
+ >
+ {title != null && (
+
+ {icon}
+
{title}
+
+ )}
+
{children}
+ {footer != null &&
{footer}
}
+
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/Select.tsx b/backends/advanced/webui/src/components/ui/Select.tsx
new file mode 100644
index 000000000..45e9ad597
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Select.tsx
@@ -0,0 +1,27 @@
+import { SelectHTMLAttributes, ReactNode, forwardRef } from 'react'
+import clsx from 'clsx'
+
+export interface SelectProps extends SelectHTMLAttributes {
+ children: ReactNode
+}
+
+export const Select = forwardRef(function Select(
+ { children, className, ...rest },
+ ref
+) {
+ return (
+
+ {children}
+
+ )
+})
diff --git a/backends/advanced/webui/src/components/ui/StatCard.tsx b/backends/advanced/webui/src/components/ui/StatCard.tsx
new file mode 100644
index 000000000..7d8f3af10
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/StatCard.tsx
@@ -0,0 +1,34 @@
+import { HTMLAttributes, ReactNode } from 'react'
+import clsx from 'clsx'
+
+export type StatTone = 'neutral' | 'amber' | 'green' | 'red' | 'blue'
+
+const TONE: Record = {
+ neutral: 'text-gray-900 dark:text-gray-100',
+ amber: 'text-amber-600 dark:text-amber-400',
+ green: 'text-green-600 dark:text-green-400',
+ red: 'text-red-600 dark:text-red-400',
+ blue: 'text-blue-600 dark:text-blue-400',
+}
+
+export interface StatCardProps extends HTMLAttributes {
+ value: ReactNode
+ label: ReactNode
+ tone?: StatTone
+}
+
+/** Centered metric tile: large tone-colored value over a muted label. */
+export function StatCard({ value, label, tone = 'neutral', className, ...rest }: StatCardProps) {
+ return (
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/Tabs.tsx b/backends/advanced/webui/src/components/ui/Tabs.tsx
new file mode 100644
index 000000000..4a07f2bbb
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/Tabs.tsx
@@ -0,0 +1,74 @@
+import { ReactNode } from 'react'
+import clsx from 'clsx'
+
+export interface TabItem {
+ value: T
+ label: ReactNode
+ icon?: ReactNode
+}
+
+export interface TabsProps {
+ tabs: TabItem[]
+ value: T
+ onChange?: (value: T) => void
+ variant?: 'pill' | 'underline'
+ className?: string
+}
+
+/** Segmented navigation. `pill` = filled terracotta active tab; `underline` = accent-underlined active tab. */
+export function Tabs({
+ tabs,
+ value,
+ onChange,
+ variant = 'pill',
+ className,
+}: TabsProps) {
+ if (variant === 'underline') {
+ return (
+
+ {tabs.map((t) => {
+ const on = t.value === value
+ return (
+ onChange?.(t.value)}
+ className={clsx(
+ '-mb-px inline-flex items-center gap-1.5 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
+ on
+ ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
+ )}
+ >
+ {t.icon}
+ {t.label}
+
+ )
+ })}
+
+ )
+ }
+ return (
+
+ {tabs.map((t) => {
+ const on = t.value === value
+ return (
+ onChange?.(t.value)}
+ className={clsx(
+ 'inline-flex items-center gap-1.5 rounded-lg px-3.5 py-[7px] text-sm font-medium transition-colors',
+ on
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600'
+ )}
+ >
+ {t.icon}
+ {t.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/backends/advanced/webui/src/components/ui/index.ts b/backends/advanced/webui/src/components/ui/index.ts
new file mode 100644
index 000000000..4c396a5c8
--- /dev/null
+++ b/backends/advanced/webui/src/components/ui/index.ts
@@ -0,0 +1,27 @@
+// Chronicle Espresso UI primitives — the standard building blocks. Prefer these
+// over ad-hoc inline Tailwind for buttons, cards, inputs, badges, tabs, alerts
+// and modals so the design system stays consistent in one place.
+export { Button } from './Button'
+export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
+export { IconButton } from './IconButton'
+export type { IconButtonProps } from './IconButton'
+export { Card } from './Card'
+export type { CardProps } from './Card'
+export { Input, Textarea } from './Input'
+export { Select } from './Select'
+export type { SelectProps } from './Select'
+export { Checkbox } from './Checkbox'
+export type { CheckboxProps } from './Checkbox'
+export { Label } from './Label'
+export type { LabelProps } from './Label'
+export { StatCard } from './StatCard'
+export type { StatCardProps, StatTone } from './StatCard'
+export { Tabs } from './Tabs'
+export type { TabsProps, TabItem } from './Tabs'
+export { Alert } from './Alert'
+export type { AlertProps, AlertTone } from './Alert'
+export { Modal } from './Modal'
+export type { ModalProps } from './Modal'
+// Badges / chips. `StateBadge` is also exported as `Badge` for design-system parity.
+export { MetadataChip, StateBadge, StateBadge as Badge } from './Chip'
+export type { StateTone } from './Chip'
diff --git a/backends/advanced/webui/src/pages/Archive.tsx b/backends/advanced/webui/src/pages/Archive.tsx
index ae0ba1efd..202af02ef 100644
--- a/backends/advanced/webui/src/pages/Archive.tsx
+++ b/backends/advanced/webui/src/pages/Archive.tsx
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { Archive as ArchiveIcon, RefreshCw, Calendar, User, RotateCcw, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { conversationsApi, authApi } from '../services/api'
import { useConversations, useRestoreConversation, usePermanentDeleteConversation } from '../hooks/useConversations'
+import { Button } from '../components/ui'
interface Conversation {
conversation_id: string
@@ -167,12 +168,9 @@ export default function Archive() {
return (
{error}
-
{ setActionError(null); refetch() }}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
- >
+ { setActionError(null); refetch() }}>
Try Again
-
+
)
}
@@ -187,13 +185,9 @@ export default function Archive() {
Archived Conversations
- refetch()}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
- >
-
- Refresh
-
+ refetch()} icon={ }>
+ Refresh
+
{/* Archive Info */}
diff --git a/backends/advanced/webui/src/pages/Chat.tsx b/backends/advanced/webui/src/pages/Chat.tsx
index fdc1f6d9b..cbd2b5425 100644
--- a/backends/advanced/webui/src/pages/Chat.tsx
+++ b/backends/advanced/webui/src/pages/Chat.tsx
@@ -3,6 +3,7 @@ import { MessageCircle, Send, Plus, Trash2, Brain, Clock, User, Bot, BookOpen, L
import { useQueryClient } from '@tanstack/react-query'
import { chatApi } from '../services/api'
import { useChatSessions, useChatMessages, useCreateChatSession, useDeleteChatSession, useExtractChatMemories } from '../hooks/useChat'
+import { IconButton, MetadataChip } from '../components/ui'
interface ChatMessage {
message_id: string
@@ -272,13 +273,9 @@ export default function Chat() {
Chat
-
+
-
+
@@ -343,13 +340,13 @@ export default function Chat() {
{/* Open sessions list on mobile */}
-
setShowSessions(true)}
- className="md:hidden p-2 -ml-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 flex-shrink-0"
- aria-label="Show chat sessions"
+ className="md:hidden -ml-2 flex-shrink-0"
>
-
+
{currentSession.title}
@@ -359,7 +356,7 @@ export default function Chat() {
{extractMemories.isPending ? (
@@ -470,7 +467,9 @@ export default function Chat() {
{error}
setError(null)}
- className="ml-2 text-red-500 hover:text-red-700"
+ aria-label="Dismiss error"
+ title="Dismiss"
+ className="ml-2 rounded text-red-500 hover:text-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
>
✕
@@ -503,13 +502,13 @@ export default function Chat() {
-
Agentic memory
-
+
@@ -561,7 +560,9 @@ export default function Chat() {
setShowMemoryPanel(false)}
- className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
+ aria-label="Close memory context"
+ title="Close"
+ className="rounded text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400"
>
✕
@@ -569,9 +570,9 @@ export default function Chat() {
Using {memoryContext.memory_count} relevant memories to enhance this conversation.
- {memoryContext.memory_ids.slice(0, 3).map((id) => (
-
- Memory ID: {id}
+ {memoryContext.memory_ids.slice(0, 3).map((id, i) => (
+
+ Memory {i + 1}
))}
{memoryContext.memory_ids.length > 3 && (
diff --git a/backends/advanced/webui/src/pages/ConversationDetail.tsx b/backends/advanced/webui/src/pages/ConversationDetail.tsx
index 827672cac..0f2431215 100644
--- a/backends/advanced/webui/src/pages/ConversationDetail.tsx
+++ b/backends/advanced/webui/src/pages/ConversationDetail.tsx
@@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft, Calendar, User, Trash2, RefreshCw, MoreVertical,
RotateCcw, Zap, Download, Scissors,
- Save, X, Pencil, Clock, Database, Layers, Star, BarChart3, Hash, AudioLines
+ Save, X, Pencil, Clock, Database, Layers, Star, BarChart3, Hash, AudioLines, ChevronRight
} from 'lucide-react'
import { annotationsApi, speakerApi, systemApi, BACKEND_URL } from '../services/api'
import {
@@ -14,12 +14,14 @@ import {
import ConversationVersionHeader from '../components/ConversationVersionHeader'
import MemoryAuditCard from '../components/MemoryAuditCard'
import ConversationContextLens from '../components/ConversationContextLens'
+import BackgroundSuppressionCard from '../components/BackgroundSuppressionCard'
import { useGaplessPlayer } from '../hooks/useGaplessPlayer'
import { AUDIO_FORMAT } from '../utils/audioFormat'
import TranscriptEditor from '../components/transcript/TranscriptEditor'
import { useWaveformZoomDisabled } from '../components/transcript/useWaveformZoom'
import SplitConversationModal from '../components/dataAudit/SplitConversationModal'
import { getStorageKey } from '../utils/storage'
+import { Button } from '../components/ui'
interface Segment {
text: string
@@ -48,6 +50,8 @@ interface Conversation {
active_transcript_version_number?: number
starred?: boolean
starred_at?: string
+ speaker_recognition?: any
+ diarization_source?: 'provider' | 'pyannote'
}
export default function ConversationDetail() {
@@ -236,12 +240,16 @@ export default function ConversationDetail() {
}
}
- const handleReprocessSpeakers = async () => {
+ const handleReprocessSpeakers = async (diarizationSource: 'provider' | 'pyannote') => {
if (!id) return
setReprocessingSpeakers(true)
setOpenDropdown(false)
try {
- await reprocessSpeakersMutation.mutateAsync({ conversationId: id, transcriptVersionId: 'active' })
+ await reprocessSpeakersMutation.mutateAsync({
+ conversationId: id,
+ transcriptVersionId: 'active',
+ diarizationSource,
+ })
refetch()
} catch (err: any) {
setActionError(`Failed to reprocess speakers: ${err.message || 'Unknown error'}`)
@@ -416,15 +424,38 @@ export default function ConversationDetail() {
{reprocessingMemory ?
:
}
Reprocess Memory
-
- {reprocessingSpeakers ? : }
- Reprocess Speakers
-
+
+
+ {reprocessingSpeakers ? : }
+ Reprocess Speakers
+
+
+ {!reprocessingSpeakers && (() => {
+ const currentSource = conversation.diarization_source === 'pyannote' ? 'pyannote' : 'provider'
+ const alternateSource = currentSource === 'pyannote' ? 'provider' : 'pyannote'
+ const label = (source: 'provider' | 'pyannote') => source === 'pyannote' ? 'Pyannote' : 'Provider'
+ return (
+
+ handleReprocessSpeakers(currentSource)}
+ className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
+ >
+ Current ({label(currentSource)})
+
+ handleReprocessSpeakers(alternateSource)}
+ className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
+ >
+ {label(alternateSource)}
+
+
+ )
+ })()}
+
setWaveformZoomDisabled(!waveformZoomDisabled)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-2"
@@ -479,19 +510,6 @@ export default function ConversationDetail() {
)}
- {/* Version Selector */}
-
{
- refetch()
- }}
- />
-
{/* Main Content Grid */}
{/* Left Column - Main Content */}
@@ -510,21 +528,22 @@ export default function ConversationDetail() {
autoFocus
disabled={savingTitle}
/>
- }
>
-
{savingTitle ? 'Saving...' : 'Save'}
-
-
+
-
-
+ icon={ }
+ />
{titleEditError && (
{titleEditError}
@@ -553,12 +572,12 @@ export default function ConversationDetail() {
setShowDetailedSummary(!showDetailedSummary)}
- className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center space-x-1"
+ className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:underline flex items-center space-x-1"
>
{showDetailedSummary ? '\u25BC' : '\u25B6'} Detailed Summary
{showDetailedSummary && (
-
+
{conversation.detailed_summary}
@@ -584,6 +603,18 @@ export default function ConversationDetail() {
({segments.length} segments)
)}
+ {/* Transcript version selector — renders nothing unless multiple versions exist */}
+
{
+ refetch()
+ }}
+ />
@@ -611,8 +643,15 @@ export default function ConversationDetail() {
ID
-
- {conversation.conversation_id}
+
+ navigator.clipboard?.writeText(conversation.conversation_id || '')}
+ title={`${conversation.conversation_id} — click to copy`}
+ className="font-mono text-xs text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-300 rounded focus:outline-none focus-visible:ring-1 focus-visible:ring-gray-400"
+ >
+ {conversation.conversation_id?.slice(0, 8)}…
+
+ {/* Background-suppression disclosure: what was marked background (or
+ would be), cluster-grouped, with restore/confirm. Renders nothing
+ when the ledger is empty. */}
+
queryClient.invalidateQueries({ queryKey: ['conversation', id] })}
+ />
+
{/* Memory change history is intentionally full-width: paths, summaries, and
timestamps become unreadable in the narrow metadata rail. */}
diff --git a/backends/advanced/webui/src/pages/Conversations.tsx b/backends/advanced/webui/src/pages/Conversations.tsx
index b8597bc97..24dfd788a 100644
--- a/backends/advanced/webui/src/pages/Conversations.tsx
+++ b/backends/advanced/webui/src/pages/Conversations.tsx
@@ -1,13 +1,14 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
-import { MessageSquare, RefreshCw, Calendar, User, Play, Pause, MoreVertical, RotateCcw, Zap, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Save, X, AlertTriangle, Pencil, Search, Brain, Star, ArrowUpDown, Clock, UserX, Mic } from 'lucide-react'
+import { MessageSquare, RefreshCw, Calendar, User, Play, Pause, MoreVertical, RotateCcw, Zap, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Save, X, AlertTriangle, Pencil, Search, Brain, Star, ArrowUpDown, Clock, UserX, Mic, Regex, ListFilter, Check } from 'lucide-react'
import { conversationsApi, annotationsApi, speakerApi } from '../services/api'
import { useConversations, useDeleteConversation, useReprocessTranscript, useReprocessMemory, useReprocessSpeakers, useReprocessOrphan, useToggleStar } from '../hooks/useConversations'
import ConversationVersionHeader from '../components/ConversationVersionHeader'
import { PlayheadTimeLabel } from '../components/audio/PlayheadWaveform'
import { useGaplessPlayer } from '../hooks/useGaplessPlayer'
import TranscriptEditor from '../components/transcript/TranscriptEditor'
+import { Button, Checkbox } from '../components/ui'
interface Conversation {
conversation_id: string
@@ -45,11 +46,11 @@ interface Conversation {
}
-// "Unknown Speaker N", bare "Unknown", "Background/Noise" → styled as muted chips.
+// Unknown and background labels are metadata, not enrolled people.
const isUnknownLabel = (name?: string): boolean => {
if (!name || !name.trim()) return true
const n = name.trim().toLowerCase()
- return n === 'background/noise' || /^unknown(?:[ _]speaker)?(?:[ _]*\d+)?$/.test(n)
+ return ['noise', 'background speech'].includes(n) || /^unknown(?:[ _]speaker)?(?:[ _]*\d+)?$/.test(n)
}
const PAGE_SIZE = 20
@@ -127,11 +128,15 @@ export default function Conversations() {
const [savingTitle, setSavingTitle] = useState(false)
const [titleEditError, setTitleEditError] = useState(null)
- // Search state
+ // Search state (regex-only; semantic search was removed for performance reasons)
const [searchQuery, setSearchQuery] = useState('')
+ type SearchField = 'title' | 'summary' | 'speakers'
+ const allSearchFields: SearchField[] = ['title', 'summary', 'speakers']
+ const [searchFields, setSearchFields] = useState(allSearchFields)
const [searchResults, setSearchResults] = useState(null)
const [isSearching, setIsSearching] = useState(false)
const [searchTotal, setSearchTotal] = useState(0)
+ const [searchError, setSearchError] = useState(null)
const searchTimeoutRef = useRef | null>(null)
const loadEnrolledSpeakers = async () => {
@@ -160,7 +165,34 @@ export default function Conversations() {
return () => document.removeEventListener('click', handleClickOutside)
}, [])
- // Debounced search
+ const allFieldsSelected = searchFields.length === allSearchFields.length
+
+ const toggleSearchField = (field: SearchField) => {
+ setSearchFields((current) =>
+ current.includes(field)
+ ? current.filter((selected) => selected !== field)
+ : [...current, field]
+ )
+ }
+
+ const runSearch = async (query: string, fields: SearchField[]) => {
+ setIsSearching(true)
+ try {
+ const response = await conversationsApi.search(query, 50, 0, fields)
+ setSearchResults(response.data.conversations ?? [])
+ setSearchTotal(response.data.total ?? 0)
+ setSearchError(response.data.error ?? null)
+ } catch (err: any) {
+ console.error('Search failed:', err)
+ setSearchResults([])
+ setSearchTotal(0)
+ setSearchError(err?.response?.data?.error || 'Search failed')
+ } finally {
+ setIsSearching(false)
+ }
+ }
+
+ // Regex search runs live, debounced.
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
@@ -170,29 +202,26 @@ export default function Conversations() {
if (!trimmed) {
setSearchResults(null)
setSearchTotal(0)
+ setSearchError(null)
+ setIsSearching(false)
+ return
+ }
+
+ if (searchFields.length === 0) {
+ setSearchResults([])
+ setSearchTotal(0)
+ setSearchError(null)
setIsSearching(false)
return
}
setIsSearching(true)
- searchTimeoutRef.current = setTimeout(async () => {
- try {
- const response = await conversationsApi.search(trimmed, 50)
- setSearchResults(response.data.conversations ?? [])
- setSearchTotal(response.data.total ?? 0)
- } catch (err: any) {
- console.error('Search failed:', err)
- setSearchResults([])
- setSearchTotal(0)
- } finally {
- setIsSearching(false)
- }
- }, 300)
+ searchTimeoutRef.current = setTimeout(() => runSearch(trimmed, searchFields), 300)
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
}
- }, [searchQuery])
+ }, [searchQuery, searchFields])
const formatDate = (timestamp: number | string) => {
// Handle both Unix timestamp (number) and ISO string
@@ -421,7 +450,9 @@ export default function Conversations() {
}
// Find the conversation by conversation_id
- const conversation = conversations.find(c => c.conversation_id === conversationId)
+ const conversation = (searchResults ?? conversations).find(
+ c => c.conversation_id === conversationId,
+ )
if (!conversation || !conversation.conversation_id) {
console.error('Cannot expand detailed summary: conversation_id missing')
return
@@ -519,6 +550,11 @@ export default function Conversations() {
),
}
})
+ setSearchResults(prev => prev?.map(c =>
+ c.conversation_id === conversationId
+ ? { ...c, ...response.data.conversation }
+ : c
+ ) ?? null)
// Expand the transcript (the editor loads its own annotations)
setExpandedTranscripts(prev => new Set(prev).add(conversationId))
}
@@ -549,12 +585,9 @@ export default function Conversations() {
return (
{error}
-
{ setActionError(null); refetch() }}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
- >
+ { setActionError(null); refetch() }}>
Try Again
-
+
)
}
@@ -595,22 +628,19 @@ export default function Conversations() {
{hideUnknownSpeakers ? 'Unknown speakers hidden' : 'Hide unknown speakers'}
-
- { setDebugMode(e.target.checked); setPage(0) }}
- className="rounded border-gray-300"
- />
- Debug Mode
-
- { setDebugMode(e.target.checked); setPage(0) }}
+ label="Debug Mode"
+ />
+ refetch()}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
+ icon={ }
>
-
- Refresh
-
+ Refresh
+
@@ -622,7 +652,7 @@ export default function Conversations() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
- placeholder="Search conversations..."
+ placeholder="Search conversations or people..."
className="w-full pl-9 pr-9 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
{searchQuery && (
@@ -634,14 +664,73 @@ export default function Conversations() {
)}
-
-
- Semantic
-
+ {/* Match mode: compact icons keep the search row scannable. */}
+
+
+
+
+
+
+
+
+ {/* Search fields: Everything mirrors the three individual checkboxes. */}
+
+
{
+ event.stopPropagation()
+ setOpenDropdown(openDropdown === 'search-fields' ? null : 'search-fields')
+ }}
+ aria-haspopup="menu"
+ aria-expanded={openDropdown === 'search-fields'}
+ className="flex h-9 items-center gap-1.5 rounded-lg border border-gray-300 bg-white pl-2.5 pr-2 text-sm text-gray-700 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
+ >
+
+ {allFieldsSelected ? 'Everything' : searchFields.length === 0 ? 'No fields' : `${searchFields.length} fields`}
+
+
+ {openDropdown === 'search-fields' && (
+
event.stopPropagation()}
+ className="absolute left-0 z-30 mt-1 w-52 rounded-lg border border-gray-200 bg-white p-1.5 shadow-lg dark:border-gray-600 dark:bg-gray-800"
+ >
+ {[
+ { key: 'all', label: 'Everything', selected: allFieldsSelected },
+ { key: 'title', label: 'Titles', selected: searchFields.includes('title') },
+ { key: 'summary', label: 'Summaries', selected: searchFields.includes('summary') },
+ { key: 'speakers', label: 'Speakers', selected: searchFields.includes('speakers') },
+ ].map((option, index) => (
+ option.key === 'all'
+ ? setSearchFields(allFieldsSelected ? [] : allSearchFields)
+ : toggleSearchField(option.key as SearchField)}
+ className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:bg-gray-700 ${index === 0 ? 'mb-1 border-b border-gray-100 pb-2 dark:border-gray-700' : ''}`}
+ >
+
+ {option.selected && }
+
+ {option.label}
+
+ ))}
+
+ )}
+
{/* Sort Dropdown */}
+
{isSearching ? (
Searching...
+ ) : searchError ? (
+
+
+ {searchError}
+
+ ) : searchFields.length === 0 ? (
+
Select at least one field to search.
) : searchResults !== null ? (
-
{searchTotal} result{searchTotal !== 1 ? 's' : ''} for "{searchQuery.trim()}"
+
+ {searchTotal} result{searchTotal !== 1 ? 's' : ''}
+ {` for “${searchQuery.trim()}”`}
+
) : null}
)}
@@ -685,10 +784,15 @@ export default function Conversations() {
displayConversations.map((conversation) => (
{
+ const target = event.target as HTMLElement
+ if (target.closest('button, a, input, textarea, select, [role="button"]')) return
+ navigate(`/conversations/${conversation.conversation_id}`)
+ }}
+ className={`rounded-lg p-6 border cursor-pointer ${
conversation.is_orphan
? 'bg-amber-50 dark:bg-amber-900/10 border-amber-300 dark:border-amber-700'
- : 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600'
+ : 'bg-gray-50 dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
{/* Orphan Audio Session Banner */}
@@ -713,7 +817,7 @@ export default function Conversations() {
handleReprocessOrphan(conversation)}
disabled={reprocessingOrphan.has(conversation.conversation_id)}
- className="flex items-center space-x-1 px-3 py-1.5 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ className="flex items-center space-x-1 px-3 py-1.5 text-sm font-medium text-amber-700 dark:text-amber-300 bg-white dark:bg-transparent border border-amber-300 dark:border-amber-700 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{reprocessingOrphan.has(conversation.conversation_id) ? (
@@ -725,40 +829,6 @@ export default function Conversations() {
)}
- {/* Version Selector Header */}
- {
- // Update only this specific conversation without reloading all conversations
- // This prevents page scroll jump
- try {
- const response = await conversationsApi.getById(conversation.conversation_id!)
- if (response.status === 200 && response.data.conversation) {
- queryClient.setQueryData(conversationsQueryKey, (old: any) => {
- if (!old) return old
- return {
- ...old,
- conversations: old.conversations.map((c: Conversation) =>
- c.conversation_id === conversation.conversation_id
- ? { ...c, ...response.data.conversation }
- : c
- ),
- }
- })
- }
- } catch (err: any) {
- console.error('Failed to refresh conversation:', err)
- // Fallback to full reload on error
- refetch()
- }
- }}
- />
-
{/* Conversation Header */}
@@ -817,7 +887,7 @@ export default function Conversations() {
toggleDetailedSummary(conversation.conversation_id!)}
- className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center space-x-1"
+ className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:underline flex items-center space-x-1"
>
{expandedDetailedSummaries.has(conversation.conversation_id) ? '▼' : '▶'} Detailed Summary
@@ -826,7 +896,7 @@ export default function Conversations() {
{/* Detailed Summary Content */}
{expandedDetailedSummaries.has(conversation.conversation_id) && conversation.detailed_summary && (
-
+
{conversation.detailed_summary}
@@ -837,6 +907,35 @@ export default function Conversations() {
{/* Metadata */}
+
{
+ try {
+ const response = await conversationsApi.getById(conversation.conversation_id!)
+ if (response.status === 200 && response.data.conversation) {
+ queryClient.setQueryData(conversationsQueryKey, (old: any) => {
+ if (!old) return old
+ return {
+ ...old,
+ conversations: old.conversations.map((c: Conversation) =>
+ c.conversation_id === conversation.conversation_id
+ ? { ...c, ...response.data.conversation }
+ : c
+ ),
+ }
+ })
+ }
+ } catch (err: any) {
+ console.error('Failed to refresh conversation:', err)
+ refetch()
+ }
+ }}
+ />
{formatDate(conversation.created_at || '')}
@@ -882,15 +981,6 @@ export default function Conversations() {
) : null
})()}
- {
- e.stopPropagation()
- navigate(`/conversations/${conversation.conversation_id}`)
- }}
- className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
- >
- View Details
-
{/* Speakers at a glance (active version) */}
@@ -902,8 +992,8 @@ export default function Conversations() {
key={i}
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
isUnknownLabel(sp)
- ? 'bg-gray-100 dark:bg-gray-700/60 text-gray-500 dark:text-gray-400'
- : 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-200 dark:ring-blue-800'
+ ? 'bg-gray-100 dark:bg-gray-700/60 text-gray-400 dark:text-gray-500'
+ : 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200'
}`}
>
{sp}
@@ -1009,7 +1099,7 @@ export default function Conversations() {
{/* Transcript */}
-
+
event.stopPropagation()}>
{(() => {
// Get segments directly from conversation (returned by detail endpoint)
const segments = conversation.segments || []
@@ -1017,17 +1107,18 @@ export default function Conversations() {
return (
<>
{/* Transcript Header with Expand/Collapse */}
-
conversation.conversation_id && toggleTranscriptExpansion(conversation.conversation_id)}
>
-
+
Transcript {(segments.length > 0 || conversation.segment_count) && (
({segments.length || conversation.segment_count || 0} segments)
)}
-
+
{conversation.conversation_id && expandedTranscripts.has(conversation.conversation_id) ? (
@@ -1035,7 +1126,7 @@ export default function Conversations() {
)}
-
+
{/* Transcript Content - Conditionally Rendered */}
{conversation.conversation_id && expandedTranscripts.has(conversation.conversation_id) && (
diff --git a/backends/advanced/webui/src/pages/DataAudit.tsx b/backends/advanced/webui/src/pages/DataAudit.tsx
index 0da9f6ca8..7cd47339d 100644
--- a/backends/advanced/webui/src/pages/DataAudit.tsx
+++ b/backends/advanced/webui/src/pages/DataAudit.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
-import { Sparkles, Archive as ArchiveIcon, AlertTriangle, Mic, ArrowRight } from 'lucide-react'
-import { Link, useSearchParams } from 'react-router-dom'
+import { Sparkles, Archive as ArchiveIcon, AlertTriangle, Mic, Radio, ArrowRight } from 'lucide-react'
+import { useSearchParams } from 'react-router-dom'
import { dataAuditApi, AuditConversation } from '../services/api'
import { useJobPolling } from '../hooks/useJobPolling'
import AuditFilterBar from '../components/dataAudit/AuditFilterBar'
@@ -13,9 +13,18 @@ import AuditToolbar from '../components/dataAudit/AuditToolbar'
import AuditTable from '../components/dataAudit/AuditTable'
import SpeakerConfidencePanel from '../components/dataAudit/SpeakerConfidencePanel'
import DriftPanel from '../components/dataAudit/DriftPanel'
+import BackgroundReviewPanel from '../components/dataAudit/BackgroundReviewPanel'
import SplitConversationModal from '../components/dataAudit/SplitConversationModal'
import MergePreviewModal from '../components/dataAudit/MergePreviewModal'
import ExportModal from '../components/dataAudit/ExportModal'
+import GuidedEnrollment from '../components/dataAudit/GuidedEnrollment'
+import EnrollmentCandidates from '../components/finetuning/EnrollmentCandidates'
+import { Alert, Button, Label, Modal, Select, Tabs } from '../components/ui'
+
+// Data Audit is the single home for curation. A task hub picks the active flow:
+// audit conversations, enroll speakers (queue + guided enhance), or classify
+// background/role. Each flow reuses its existing panel(s).
+type CurationView = 'conversations' | 'enroll' | 'background'
type ArchiveReason = 'near_silent' | 'bad_speaker' | 'manual_cleanup'
@@ -80,6 +89,8 @@ export default function DataAudit() {
const [archivedOnly, setArchivedOnly] = useState(() =>
loadArchivedView(initialDatasetId)
)
+ // Which curation flow is active (task hub). Conversation audit is the default.
+ const [curationView, setCurationView] = useState
('conversations')
// Data
const [speakers, setSpeakers] = useState([])
@@ -372,140 +383,148 @@ export default function DataAudit() {
Data Audit
- Inspect recordings: find speech-free or mis-attributed audio, split long recordings at
- silence gaps, merge adjacent conversations, and archive audio.
+ Decide what the audio is — audit conversations, enroll speakers, and classify
+ background & role. One home for all curation.
- {!archivedOnly && (
-
-
- Speaker enrollment
-
-
- )}
- {/* View toggle */}
-
-
setArchivedOnly(false)}
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- !archivedOnly
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
- }`}
- >
- Conversations
-
-
setArchivedOnly(true)}
- className={`flex items-center space-x-1 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
- archivedOnly
- ? 'border-blue-600 text-blue-600 dark:text-blue-400'
- : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
- }`}
- >
-
- Archived stubs
-
+ {/* Task hub — pick a curation flow */}
+
+ {([
+ { key: 'conversations', icon: Sparkles, title: 'Audit conversations', metric: total ? `${total}` : '', blurb: 'Find speech-free or mis-attributed audio; split, merge, archive.' },
+ { key: 'enroll', icon: Mic, title: 'Enroll speakers', metric: triagePending.pending_count ? `${triagePending.pending_count}` : '', blurb: 'Review the relabel queue and strengthen voiceprints — deliberate, gated.' },
+ { key: 'background', icon: Radio, title: 'Background & role', metric: '', blurb: 'Content vs real people vs noise. Feeds background suppression.' },
+ ] as { key: CurationView; icon: any; title: string; metric: string; blurb: string }[]).map((t) => {
+ const active = curationView === t.key
+ const Icon = t.icon
+ return (
+
setCurationView(t.key)}
+ className={`text-left rounded-xl border p-4 transition-colors ${active ? 'border-blue-400 bg-blue-50/60 dark:bg-blue-900/15 dark:border-blue-700' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-blue-300'}`}
+ >
+
+
+ {t.metric && {t.metric} }
+
+ {t.title}
+ {t.blurb}
+ {active && }
+
+ )
+ })}
- {/* Filters (hidden in archived view) */}
- {!archivedOnly && (
-
setFilters((prev) => ({ ...prev, [key]: value }))}
- onResetFilter={(key) => {
- // Compute next state synchronously so the refetch can't see a
- // stale filters snapshot.
- const next = {
- ...filters,
- [key]: AUDIT_FILTERS.find((d) => d.key === key)?.defaultValue,
- }
- setFilters(next)
- loadConversations(next)
- }}
- onToggleFilter={(key, value) => {
- // Set + refetch synchronously so the single click takes effect now.
- const next = { ...filters, [key]: value }
- setFilters(next)
- loadConversations(next)
- }}
- onApply={() => loadConversations()}
- ctx={{ speakers, datasets }}
- loading={loading}
- />
- )}
-
- {/* Messages */}
- {message && (
-
- {message}
-
- )}
+ {/* Messages (shared across flows) */}
+ {message && {message} }
{error && (
-
- )}
- {scanCapped && !archivedOnly && (
-
-
-
Showing a capped working set — narrow filters or archive in batches to see the rest.
-
+ }>
+ {error}
+
)}
- {/* Speaker confidence overview (per-speaker baselines + noise magnets) */}
- {!archivedOnly && }
-
- {/* Guided enrollment: confirm high-information clips to improve a voiceprint */}
-
- {/* Drift: conversations whose speaker labels would change under the current gallery */}
- {!archivedOnly && }
-
- {/* Toolbar */}
- {!archivedOnly && (
- setMergeTargets(selectedRows)}
- onArchive={archiveSelected}
- onExport={() => setExportOpen(true)}
- />
+ {/* ── Enroll speakers flow: queue (deliberate, gated) + guided enhance ── */}
+ {curationView === 'enroll' && (
+
+
+
+
+
+
)}
- {/* Table */}
- setSplitTarget(row)}
- onTriageChanged={refreshTriagePending}
- marginalThreshold={marginalThreshold}
- />
-
- {!archivedOnly && (
-
- Tip: run Analyze audio first to populate speech metrics. Conversations
- showing “—” haven’t been analyzed yet and won’t match a speech-fraction filter.
-
+ {/* ── Background & role flow ─────────────────────────────────────────── */}
+ {curationView === 'background' && }
+
+ {/* ── Conversation audit flow ────────────────────────────────────────── */}
+ {curationView === 'conversations' && (
+ <>
+ {/* View toggle */}
+ setArchivedOnly(v === 'archived')}
+ tabs={[
+ { value: 'conversations', label: 'Conversations' },
+ { value: 'archived', label: 'Archived stubs', icon: },
+ ]}
+ />
+
+ {/* Filters (hidden in archived view) */}
+ {!archivedOnly && (
+ setFilters((prev) => ({ ...prev, [key]: value }))}
+ onResetFilter={(key) => {
+ // Compute next state synchronously so the refetch can't see a
+ // stale filters snapshot.
+ const next = {
+ ...filters,
+ [key]: AUDIT_FILTERS.find((d) => d.key === key)?.defaultValue,
+ }
+ setFilters(next)
+ loadConversations(next)
+ }}
+ onToggleFilter={(key, value) => {
+ // Set + refetch synchronously so the single click takes effect now.
+ const next = { ...filters, [key]: value }
+ setFilters(next)
+ loadConversations(next)
+ }}
+ onApply={() => loadConversations()}
+ ctx={{ speakers, datasets }}
+ loading={loading}
+ />
+ )}
+
+ {scanCapped && !archivedOnly && (
+ }>
+ Showing a capped working set — narrow filters or archive in batches to see the rest.
+
+ )}
+
+ {/* Toolbar */}
+ {!archivedOnly && (
+ setMergeTargets(selectedRows)}
+ onArchive={archiveSelected}
+ onExport={() => setExportOpen(true)}
+ />
+ )}
+
+ {/* Table */}
+ setSplitTarget(row)}
+ onTriageChanged={refreshTriagePending}
+ marginalThreshold={marginalThreshold}
+ />
+
+ {!archivedOnly && (
+
+ Tip: run Analyze audio first to populate speech metrics. Conversations
+ showing “—” haven’t been analyzed yet and won’t match a speech-fraction filter.
+
+ )}
+ >
)}
{/* Modals */}
@@ -527,55 +546,43 @@ export default function DataAudit() {
setExportOpen(false)} />
)}
{archiveReason !== null && (
-
-
-
-
-
-
- Permanently delete audio for {selected.size} conversation(s)?
-
-
- The audio bytes will be deleted to reclaim storage. A metadata stub
- (date, duration, reason) is kept so you know something was recorded.
- This cannot be undone.
-
-
-
+
setArchiveReason(null)}
+ title={`Permanently delete audio for ${selected.size} conversation(s)?`}
+ icon={ }
+ footer={
+ <>
+ setArchiveReason(null)} disabled={archiving}>
+ Cancel
+
+
+ Delete audio
+
+ >
+ }
+ >
+
+
+ The audio bytes will be deleted to reclaim storage. A metadata stub
+ (date, duration, reason) is kept so you know something was recorded.
+ This cannot be undone.
+
-
- Reason
-
- Reason
+ setArchiveReason(e.target.value as ArchiveReason)}
- className="w-full px-2 py-1.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-gray-100"
>
{(Object.keys(REASON_LABELS) as ArchiveReason[]).map((r) => (
{REASON_LABELS[r]}
))}
-
-
-
- setArchiveReason(null)}
- disabled={archiving}
- className="px-4 py-2 rounded-lg text-sm font-medium border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
- >
- Cancel
-
-
- Delete audio
-
+
-
+
)}
)
diff --git a/backends/advanced/webui/src/pages/Finetuning.tsx b/backends/advanced/webui/src/pages/Finetuning.tsx
index 8fa841328..d83f44c8c 100644
--- a/backends/advanced/webui/src/pages/Finetuning.tsx
+++ b/backends/advanced/webui/src/pages/Finetuning.tsx
@@ -1,9 +1,13 @@
-import { useState, useEffect } from 'react'
-import { Zap, RefreshCw, AlertCircle, AlertTriangle, CheckCircle, Clock, Play, ToggleLeft, ToggleRight, Edit3, X, Check, Eye } from 'lucide-react'
-import cronstrue from 'cronstrue'
-import { finetuningApi } from '../services/api'
-import { useFinetuningStatus, useCronJobs, useToggleCronJob, useUpdateCronSchedule, useRunCronJob, useProcessAnnotations, useDeleteOrphanedAnnotations, useRetryFailedAnnotations, useDeleteFailedAnnotations } from '../hooks/useFinetuning'
-import EnrollmentCandidates from '../components/finetuning/EnrollmentCandidates'
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import {
+ Zap, RefreshCw, AlertTriangle, Play, Mic, FileAudio, Sparkles, Clock,
+ CheckCircle2, CircleDashed, ArrowUpRight,
+} from 'lucide-react'
+import { useFinetuningStatus, useCronJobs, useRunCronJob, useDeleteOrphanedAnnotations, useRetryFailedAnnotations, useDeleteFailedAnnotations } from '../hooks/useFinetuning'
+import { useExternalServices } from '../hooks/useSystem'
+import { useAuth } from '../contexts/AuthContext'
+import { Button, Alert } from '../components/ui'
interface AnnotationTypeCounts {
total: number
@@ -14,143 +18,118 @@ interface AnnotationTypeCounts {
failed: number
}
-function humanCron(expr: string): string {
- try {
- return cronstrue.toString(expr)
- } catch {
- return expr
- }
-}
-
-function formatTimestamp(iso: string | null): string {
- if (!iso) return 'Never'
+function formatTimestamp(iso: string | null | undefined): string {
+ if (!iso) return 'never run'
return new Date(iso).toLocaleString()
}
-const JOB_DISPLAY_NAMES: Record = {
- speaker_finetuning: 'Speaker Fine-tuning',
- asr_jargon_extraction: 'ASR Jargon Extraction',
- annotation_suggestions: 'Transcript Suggestion Detection',
-}
-
-const ANNOTATION_TYPE_DISPLAY: Record = {
- diarization: { label: 'Diarization', description: 'Speaker identification corrections' },
- entity: { label: 'Entity', description: 'Knowledge graph entity corrections' },
- transcript: { label: 'Transcript', description: 'Transcript text corrections' },
- memory: { label: 'Memory', description: 'Memory content corrections' },
- title: { label: 'Title', description: 'Conversation title corrections' },
- speech_suggestion_correction: { label: 'Speech Suggestion Correction', description: 'User-refined model suggestions (ASR training signal)' },
-}
-
-function getAnnotationDisplay(key: string): { label: string; description: string } {
- if (ANNOTATION_TYPE_DISPLAY[key]) return ANNOTATION_TYPE_DISPLAY[key]
- const label = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
- return { label, description: `${label} annotations` }
+// One card per model we can teach. Speaker recognition is instant kNN enrollment
+// (managed in Data Audit); ASR and prompts are real batch training triggered here.
+interface ModelTarget {
+ key: 'speaker' | 'asr' | 'prompts'
+ label: string
+ blurb: string
+ icon: any
+ /** Applied-but-not-trained human signal, grouped from annotation_counts. */
+ readyTypes: string[]
+ /** null = no batch trigger here (link out instead). */
+ cronJobId: string | null
+ runVerb: string
}
-const COLOR_CLASSES = {
- blue: 'text-blue-600 dark:text-blue-400',
- green: 'text-green-600 dark:text-green-400',
- default: 'text-gray-900 dark:text-gray-100',
-}
-
-function StatCard({ label, value, color, subtitle }: {
- label: string
- value: number
- color?: 'blue' | 'green'
- subtitle?: string
-}) {
- return (
-
-
{label}
-
- {value}
-
- {subtitle &&
{subtitle}
}
-
- )
+const MODEL_TARGETS: ModelTarget[] = [
+ {
+ key: 'speaker',
+ label: 'Speaker recognition',
+ blurb: 'Voiceprints built from your speaker relabels + enrollment.',
+ icon: Mic,
+ readyTypes: ['diarization'],
+ cronJobId: null, // instant kNN — enrolled deliberately in Data Audit
+ runVerb: 'Enroll',
+ },
+ {
+ key: 'asr',
+ label: 'ASR model (VibeVoice LoRA)',
+ blurb: 'Transcript corrections exported as fine-tuning data.',
+ icon: FileAudio,
+ readyTypes: ['transcript', 'speech_suggestion_correction', 'timing', 'insert', 'deletion'],
+ cronJobId: 'asr_finetuning',
+ runVerb: 'Export & train',
+ },
+ {
+ key: 'prompts',
+ label: 'LLM prompts',
+ blurb: 'Title & memory edits tune the extraction prompts.',
+ icon: Sparkles,
+ readyTypes: ['title', 'memory'],
+ cronJobId: 'prompt_optimization',
+ runVerb: 'Optimize prompts',
+ },
+]
+
+const TYPE_LABEL: Record = {
+ diarization: 'speaker relabels',
+ transcript: 'transcript corrections',
+ speech_suggestion_correction: 'suggestion-correction triples',
+ timing: 'timing edits',
+ insert: 'inserts',
+ deletion: 'deletions',
+ title: 'title edits',
+ memory: 'memory edits',
}
export default function Finetuning() {
- const { data: status = null, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useFinetuningStatus()
- const { data: cronJobs = [], isLoading: cronLoading, error: cronError, refetch: refetchCron } = useCronJobs()
-
- const loading = statusLoading || cronLoading
- const queryError = statusError?.message || cronError?.message || null
+ const { isAdmin } = useAuth()
+ const { data: externalServices } = useExternalServices(isAdmin, false)
+ const { data: status = null, isLoading: statusLoading, refetch: refetchStatus } = useFinetuningStatus()
+ const { data: cronJobs = [], isLoading: cronLoading, refetch: refetchCron } = useCronJobs()
+ const runJob = useRunCronJob()
+ const retryFailed = useRetryFailedAnnotations()
+ const deleteFailed = useDeleteFailedAnnotations()
+ const deleteOrphaned = useDeleteOrphanedAnnotations()
const [error, setError] = useState(null)
const [successMessage, setSuccessMessage] = useState(null)
- const [runningJobId, setRunningJobId] = useState(null)
- const [showOrphanPanel, setShowOrphanPanel] = useState(false)
+ const [runningKey, setRunningKey] = useState(null)
const [cleaningType, setCleaningType] = useState(null)
- const [editingSchedule, setEditingSchedule] = useState(null)
- const [scheduleInput, setScheduleInput] = useState('')
- const [autoShowSwipe, setAutoShowSwipe] = useState(() => {
- try { return localStorage.getItem('userloop-auto-show') === 'true' } catch { return false }
- })
-
- useEffect(() => {
- try { localStorage.setItem('userloop-auto-show', String(autoShowSwipe)) } catch {}
- }, [autoShowSwipe])
-
- const toggleJob = useToggleCronJob()
- const updateSchedule = useUpdateCronSchedule()
- const runJob = useRunCronJob()
- const processAnnotations = useProcessAnnotations()
- const deleteOrphaned = useDeleteOrphanedAnnotations()
- const retryFailed = useRetryFailedAnnotations()
- const deleteFailed = useDeleteFailedAnnotations()
- const loadAll = () => {
- refetchStatus()
- refetchCron()
- }
+ const loading = statusLoading || cronLoading
+ const counts = (status?.annotation_counts || {}) as Record
- const displayError = queryError || error
+ const cronFor = (jobId: string | null) => (jobId ? cronJobs.find((j: any) => j.job_id === jobId) : undefined)
- const handleProcessAnnotations = async () => {
+ const handleRun = async (t: ModelTarget) => {
+ if (!t.cronJobId) return
+ setError(null)
+ setSuccessMessage(null)
+ setRunningKey(t.key)
try {
- setError(null)
- setSuccessMessage(null)
- const data = await processAnnotations.mutateAsync('diarization')
- const totalProcessed = data.total_processed ?? 0
- const failedCount = data.failed_count ?? 0
-
- if (totalProcessed === 0 && failedCount === 0) {
- setError(data.message || 'No annotations ready for training')
- } else if (totalProcessed === 0 && failedCount > 0) {
- const errorDetail = data.errors?.length ? `: ${data.errors.join(', ')}` : ''
- setError(`All ${failedCount} annotations failed to process${errorDetail}. See "Failed" below to retry or discard them.`)
- } else if (failedCount > 0) {
- // Partial failure — surface it as an error (not a green success) so the
- // user knows some annotations are stuck and can act on them below.
- setError(`Processed ${totalProcessed} annotations, but ${failedCount} failed. See "Failed" below to retry or discard them.`)
- } else {
- setSuccessMessage(`Successfully processed ${totalProcessed} annotations for training`)
- }
+ const data = await runJob.mutateAsync(t.cronJobId)
+ if (data.error) setError(`${t.label}: ${data.error}`)
+ else if (data.processed === 0 && data.message) setError(`${t.label}: ${data.message}`)
+ else setSuccessMessage(`${t.label}: ${data.processed ?? 0} processed`)
+ refetchStatus(); refetchCron()
} catch (err: any) {
- setError(err.response?.data?.detail || err.message || 'Failed to process annotations')
+ setError(err.response?.data?.detail || err.message || 'Run failed')
+ } finally {
+ setRunningKey(null)
}
}
const handleRetryFailed = async () => {
+ setError(null); setSuccessMessage(null)
try {
- setError(null)
- setSuccessMessage(null)
const data = await retryFailed.mutateAsync('diarization')
- setSuccessMessage(`Reset ${data.reset_count ?? 0} failed annotations — they will be retried on the next training run`)
+ setSuccessMessage(`Reset ${data.reset_count ?? 0} failed annotations — retried on the next run`)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to reset annotations')
}
}
const handleDiscardFailed = async () => {
- if (!window.confirm('Discard all failed annotations? This permanently deletes annotations that keep failing to train. This cannot be undone.')) {
- return
- }
+ if (!window.confirm('Discard all failed annotations? This permanently deletes annotations that keep failing to train.')) return
+ setError(null); setSuccessMessage(null)
try {
- setError(null)
- setSuccessMessage(null)
const data = await deleteFailed.mutateAsync('diarization')
setSuccessMessage(`Discarded ${data.deleted_count ?? 0} failed annotations`)
} catch (err: any) {
@@ -159,17 +138,11 @@ export default function Finetuning() {
}
const handleCleanOrphaned = async (annotationType: string) => {
+ setCleaningType(annotationType)
+ setError(null); setSuccessMessage(null)
try {
- setCleaningType(annotationType)
- setError(null)
- setSuccessMessage(null)
const data = await deleteOrphaned.mutateAsync(annotationType)
- if (data.deleted_count > 0) {
- setSuccessMessage(`Cleaned up ${data.deleted_count} orphaned ${annotationType} annotations`)
- } else {
- setSuccessMessage('No orphaned annotations found')
- }
- setShowOrphanPanel(false)
+ setSuccessMessage(data.deleted_count > 0 ? `Cleaned ${data.deleted_count} orphaned ${annotationType} annotations` : 'No orphaned annotations found')
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to clean orphaned annotations')
} finally {
@@ -177,68 +150,6 @@ export default function Finetuning() {
}
}
- const handleReattach = async () => {
- try {
- await finetuningApi.reattachOrphanedAnnotations()
- } catch (err: any) {
- const detail = err.response?.data?.detail || 'Reattach functionality coming soon'
- setSuccessMessage(detail)
- }
- }
-
- const handleToggleJob = async (jobId: string, currentEnabled: boolean) => {
- try {
- setError(null)
- await toggleJob.mutateAsync({ jobId, enabled: !currentEnabled })
- } catch (err: any) {
- setError(err.response?.data?.detail || err.message || 'Failed to update job')
- }
- }
-
- const handleRunNow = async (jobId: string) => {
- try {
- setRunningJobId(jobId)
- setError(null)
- setSuccessMessage(null)
- const data = await runJob.mutateAsync(jobId)
- const jobName = JOB_DISPLAY_NAMES[jobId] || jobId
-
- if (data.error) {
- setError(`Job '${jobName}' failed: ${data.error}`)
- } else if (data.processed === 0 && data.message) {
- setError(`${jobName}: ${data.message}`)
- } else if (data.processed !== undefined) {
- const parts: string[] = []
- if (data.enrolled) parts.push(`${data.enrolled} new speakers enrolled`)
- if (data.appended) parts.push(`${data.appended} speakers updated`)
- if (data.failed) parts.push(`${data.failed} failed`)
- const detail = parts.length ? ` (${parts.join(', ')})` : ''
- setSuccessMessage(`${jobName}: ${data.processed} annotations processed${detail}`)
- } else {
- setSuccessMessage(`Job '${jobName}' completed successfully`)
- }
- } catch (err: any) {
- setError(err.response?.data?.detail || err.message || 'Failed to run job')
- } finally {
- setRunningJobId(null)
- }
- }
-
- const handleEditSchedule = (jobId: string, currentSchedule: string) => {
- setEditingSchedule(jobId)
- setScheduleInput(currentSchedule)
- }
-
- const handleSaveSchedule = async (jobId: string) => {
- try {
- setError(null)
- await updateSchedule.mutateAsync({ jobId, schedule: scheduleInput })
- setEditingSchedule(null)
- } catch (err: any) {
- setError(err.response?.data?.detail || err.message || 'Invalid cron expression')
- }
- }
-
if (loading) {
return (
@@ -248,335 +159,151 @@ export default function Finetuning() {
)
}
+ const totalOrphaned = Object.values(counts).reduce((s, c) => s + (c.orphaned || 0), 0)
+ const failedCount = status?.failed_annotation_count || 0
+ const speakerHealthUrl = externalServices?.services
+ ?.find(service => service.name === 'speaker-recognition')
+ ?.ui_url?.replace(/\/$/, '')
+
return (
{/* Header */}
-
+
-
Fine-tuning & Jobs
+
+
Training
+
Teach the models from what you've corrected. Schedules live in Settings → Automation.
+
-
-
- Refresh
-
+
{ refetchStatus(); refetchCron() }} icon={ }>Refresh
- {/* Error/Success Messages */}
- {displayError && (
-
+ {error && (
+
}>{error}
)}
-
{successMessage && (
-
-
- {successMessage}
-
+
}>{successMessage}
)}
- {/* Cron Jobs Section */}
-
Scheduled Jobs
-
- {cronJobs.map((job) => (
-
- {/* Job Header */}
-
-
- {JOB_DISPLAY_NAMES[job.job_id] || job.job_id}
-
- handleToggleJob(job.job_id, job.enabled)}
- title={job.enabled ? 'Disable' : 'Enable'}
- >
- {job.enabled ? (
-
- ) : (
-
- )}
-
-
-
- {/* Description */}
-
{job.description}
-
- {/* Schedule */}
-
-
- {editingSchedule === job.job_id ? (
-
-
setScheduleInput(e.target.value)}
- className="flex-1 text-sm font-mono px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
- onKeyDown={(e) => {
- if (e.key === 'Enter') handleSaveSchedule(job.job_id)
- if (e.key === 'Escape') setEditingSchedule(null)
- }}
- autoFocus
- />
-
handleSaveSchedule(job.job_id)}>
-
-
-
setEditingSchedule(null)}>
-
-
+ {/* Model cards */}
+
+ {MODEL_TARGETS.map((t) => {
+ const breakdown = t.readyTypes.map((ty) => ({ ty, count: counts[ty]?.applied || 0 }))
+ const ready = breakdown.reduce((s, b) => s + b.count, 0)
+ const trained = t.readyTypes.reduce((s, ty) => s + (counts[ty]?.trained || 0), 0)
+ const cron = cronFor(t.cronJobId)
+ const Icon = t.icon
+ const running = runningKey === t.key || cron?.running
+ return (
+
+
+
+
+
+
{t.label}
+
{t.blurb}
+
+ {breakdown.map((b) => (
+
+ {b.count} {TYPE_LABEL[b.ty] || b.ty}
+
+ ))}
+
+
+
+
+
{ready}
+
ready to teach
- ) : (
- <>
-
- {humanCron(job.schedule)}
-
-
({job.schedule})
-
handleEditSchedule(job.job_id, job.schedule)}>
-
-
- >
- )}
-
-
- {/* Last / Next Run */}
-
-
Last run: {formatTimestamp(job.last_run)}
-
Next run: {formatTimestamp(job.next_run)}
-
-
- {/* Error */}
- {job.last_error && (
-
- Error: {job.last_error}
- )}
- {/* Action Buttons */}
-
-
handleRunNow(job.job_id)}
- disabled={runningJobId === job.job_id || job.running}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
- >
- {runningJobId === job.job_id || job.running ? (
- <>
-
- Running...
- >
- ) : (
- <>
-
- Run Now
- >
- )}
-
+ {t.key === 'speaker' && (
+
+
Enrollment changes identification immediately; review its evidence after adding or relabeling clips.
+ {speakerHealthUrl && (
+
+ Check enrollment health
+
+ )}
+
+ )}
- {/* Review Suggestions button + auto-show toggle for annotation_suggestions job */}
- {job.job_id === 'annotation_suggestions' && (
- <>
-
window.dispatchEvent(new Event('open-swipe-ui'))}
- className="flex items-center space-x-2 px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
+
+
+ {cron?.enabled
+ ? auto-on
+ : {t.cronJobId ? 'manual' : 'instant'} }
+ {t.cronJobId && {formatTimestamp(cron?.last_run)} }
+ {trained} taught
+ {cron?.last_error && error }
+
+ {t.cronJobId ? (
+
handleRun(t)}
+ disabled={!!running || ready === 0}
+ icon={running ? : }
>
-
- Review
-
-
setAutoShowSwipe(!autoShowSwipe)}
- className="flex items-center space-x-1.5 px-3 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
- title={autoShowSwipe ? 'Auto-show enabled: modal opens automatically when suggestions exist' : 'Auto-show disabled: use Review button to open'}
+ {running ? 'Running…' : t.runVerb}
+
+ ) : (
+
- {autoShowSwipe ? (
-
- ) : (
-
- )}
-
Auto
-
- >
- )}
+ Review & enroll in Data Audit
+
+ )}
+
-
- ))}
+ )
+ })}
- {/* Annotation Statistics — All Types */}
- {(() => {
- const totalOrphaned = Object.values((status?.annotation_counts || {}) as Record
)
- .reduce((sum, c) => sum + (c.orphaned || 0), 0)
-
- return (
-
-
-
Annotations
- {totalOrphaned > 0 && (
-
setShowOrphanPanel(!showOrphanPanel)}
- className="flex items-center space-x-1.5 px-3 py-1.5 bg-amber-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-600 text-amber-700 dark:text-amber-400 rounded-lg hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors text-sm"
- >
-
- {totalOrphaned} orphaned
-
- )}
-
-
- {/* Orphan cleanup panel */}
- {showOrphanPanel && totalOrphaned > 0 && (
-
-
- These annotations reference conversations that no longer exist.
-
-
- {Object.entries((status?.annotation_counts || {}) as Record
).map(([key, counts]) => {
- const orphaned = counts.orphaned || 0
- if (orphaned === 0) return null
- const { label } = getAnnotationDisplay(key)
- return (
-
-
- {label}: {orphaned} orphaned
-
-
- handleCleanOrphaned(key)}
- disabled={cleaningType === key}
- className="px-3 py-1 bg-amber-600 text-white text-xs rounded hover:bg-amber-700 disabled:bg-gray-300 transition-colors"
- >
- {cleaningType === key ? 'Cleaning...' : 'Clean up'}
-
-
- Reattach
-
-
-
- )
- })}
+ {/* Maintenance — failed/orphaned annotation recovery (collapsed) */}
+ {(failedCount > 0 || totalOrphaned > 0) && (
+
+
+ Maintenance — {failedCount > 0 && `${failedCount} failed`}{failedCount > 0 && totalOrphaned > 0 && ', '}{totalOrphaned > 0 && `${totalOrphaned} orphaned`} annotation{failedCount + totalOrphaned === 1 ? '' : 's'}
+
+
+ {failedCount > 0 && (
+
+
+
{failedCount} annotation{failedCount === 1 ? '' : 's'} failed to train
+
+ {retryFailed.isPending ? 'Retrying…' : 'Retry'}
+ {deleteFailed.isPending ? 'Discarding…' : 'Discard'}
+
+ {status?.failed_annotation_errors?.length > 0 && (
+
+ {status.failed_annotation_errors.map((e: string, i: number) => {e} )}
+
+ )}
)}
-
- )
- })()}
- {status?.annotation_counts && (
-
- {Object.entries(status.annotation_counts! as Record
).map(([key, counts]) => {
- const { label, description } = getAnnotationDisplay(key)
- return (
-
-
-
{label}
- {description}
-
-
-
-
-
-
-
+ {totalOrphaned > 0 && (
+
+
These annotations reference conversations that no longer exist.
+ {Object.entries(counts).map(([key, c]) => c.orphaned > 0 && (
+
+ {key}: {c.orphaned} orphaned
+ handleCleanOrphaned(key)} disabled={cleaningType === key} className="px-3 py-1 bg-amber-600 text-white text-xs rounded hover:bg-amber-700 disabled:bg-gray-300">{cleaningType === key ? 'Cleaning…' : 'Clean up'}
+
+ ))}
- )
- })}
-
- )}
-
- {/* Fallback if annotation_counts not available */}
- {!status?.annotation_counts && (
-
-
-
-
-
- )}
-
- {/* Failed (stuck) annotations panel */}
- {(status?.failed_annotation_count || 0) > 0 && (
-
-
-
-
-
- {status?.failed_annotation_count} annotation{(status?.failed_annotation_count || 0) === 1 ? '' : 's'} failed to train
-
-
-
-
- {retryFailed.isPending ? 'Retrying...' : 'Retry'}
-
-
- {deleteFailed.isPending ? 'Discarding...' : 'Discard'}
-
-
+ )}
-
- These annotations keep failing (corrupt segment times, missing audio, or speaker-service errors).
- Retry re-attempts them on the next training run (fix the root cause first, e.g. reprocess the conversation);
- Discard permanently deletes them.
-
- {status?.failed_annotation_errors && status.failed_annotation_errors.length > 0 && (
-
- {status.failed_annotation_errors.map((e: string, i: number) => (
- {e}
- ))}
-
- )}
-
+
)}
-
- {/* Curated enrollment — the safe, quality-gated path (primary) */}
-
-
- {/* Legacy blast trigger — sends EVERY applied annotation with no gate. Kept
- behind a disclosure because it mismatched audio↔label and enrolled
- cross-talk/short scraps; use Curated Enrollment above instead. */}
-
-
- Advanced: legacy bulk training (sends all applied annotations, no quality gate)
-
-
-
-
-
- Processes every applied diarization annotation and enrolls it with no duration or
- cross-talk gating — this can contaminate voiceprints with overlap/short audio. Prefer Curated Enrollment.
-
-
-
- {processAnnotations.isPending ? (
- <>
-
- Processing...
- >
- ) : (
- <>
-
- Process {status?.applied_annotation_count || 0} Diarization Annotations
- >
- )}
-
-
-
)
}
diff --git a/backends/advanced/webui/src/pages/LiveRecord.tsx b/backends/advanced/webui/src/pages/LiveRecord.tsx
index 76bb24a18..ad2f958cc 100644
--- a/backends/advanced/webui/src/pages/LiveRecord.tsx
+++ b/backends/advanced/webui/src/pages/LiveRecord.tsx
@@ -1,5 +1,6 @@
import { Radio, Zap, Archive, Settings, Monitor, Mic } from 'lucide-react'
import { useRecording } from '../contexts/RecordingContext'
+import { Button } from '../components/ui'
import SimplifiedControls from '../components/audio/SimplifiedControls'
import StatusDisplay from '../components/audio/StatusDisplay'
import AudioVisualizer from '../components/audio/AudioVisualizer'
@@ -75,7 +76,7 @@ export default function LiveRecord() {
disabled={recording.isRecording}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
recording.audioSource === 'meeting'
- ? 'bg-purple-600 text-white shadow-sm'
+ ? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
@@ -88,7 +89,7 @@ export default function LiveRecord() {
disabled={recording.isRecording}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${
recording.audioSource === 'tab'
- ? 'bg-indigo-600 text-white shadow-sm'
+ ? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
@@ -100,11 +101,61 @@ export default function LiveRecord() {
{recording.audioSource === 'mic'
? 'Microphone only'
: recording.audioSource === 'meeting'
- ? 'Mic + tab audio (you\'ll be asked to select a tab)'
- : 'Browser tab audio only (no microphone)'}
+ ? recording.supportsDisplayAudio
+ ? 'Mic + tab audio (you\'ll be asked to select a tab)'
+ : 'Mic + system audio (captured from a monitor device)'
+ : recording.supportsDisplayAudio
+ ? 'Browser tab audio only (no microphone)'
+ : 'System audio only (captured from a monitor device)'}
+ {/* Firefox/Zen: getDisplayMedia can't deliver audio, so system audio comes
+ from a PipeWire/PulseAudio "Monitor of …" loopback input instead */}
+ {recording.audioSource !== 'mic' && !recording.supportsDisplayAudio && (() => {
+ const monitors = recording.availableDevices.filter(d => /monitor/i.test(d.label))
+ return (
+
+
+
+
+ System audio:
+
+ {monitors.length > 0 ? (
+ recording.setMonitorDeviceId(e.target.value || null)}
+ disabled={recording.isRecording}
+ className={`
+ flex-1 min-w-0 text-sm px-2 py-1.5 rounded-lg border
+ bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
+ border-gray-300 dark:border-gray-600
+ ${recording.isRecording ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
+ `}
+ >
+ Choose "Monitor of …" output device
+ {monitors.map((device) => (
+
+ {device.label}
+
+ ))}
+
+ ) : (
+ recording.requestDeviceAccess()}>
+ Load audio devices…
+
+ )}
+
+
+ Firefox-based browsers (like Zen) can't share tab audio, so system audio is recorded from a
+ PipeWire/PulseAudio "Monitor of …" loopback device — no share dialog will appear. Pick the
+ monitor of the output you're actually listening through (headphones vs speakers each have
+ their own); it captures everything playing through that output.
+
+
+ )
+ })()}
+
{/* Microphone Selector (hidden in tab-only mode) */}
{recording.audioSource !== 'tab' && recording.availableDevices.length > 1 && (
@@ -124,7 +175,10 @@ export default function LiveRecord() {
`}
>
System Default
- {recording.availableDevices.map((device) => (
+ {recording.availableDevices
+ // In Firefox meeting mode, monitor devices belong in the System audio selector
+ .filter(d => recording.supportsDisplayAudio || recording.audioSource === 'mic' || !/monitor/i.test(d.label))
+ .map((device) => (
{device.label || `Microphone (${device.deviceId.slice(0, 8)}...)`}
@@ -153,6 +207,28 @@ export default function LiveRecord() {
{/* Main Controls - Single START button */}
+ {/* System-audio capture health (meeting/tab mode) */}
+ {recording.isRecording && recording.audioSource !== 'mic' && (
+
+ System audio: {' '}
+ {recording.systemAudioLabel ?? 'not captured'}
+ {recording.systemAudioStatus === 'active' && (
+ — receiving audio ✓
+ )}
+ {recording.systemAudioStatus === 'silent' && (
+
+ {' '}— no signal detected. If something is playing, this is the wrong capture
+ device: pick the "Monitor of …" entry matching the output you're actually listening through
+ (headphones vs speakers each have their own monitor), then restart the recording.
+
+ )}
+
+ )}
+
{/* Status Display - Shows setup progress */}
@@ -191,11 +267,11 @@ export default function LiveRecord() {
{/* Instructions */}
-
-
+
+
📝 How it Works
-
+
• Choose your mode: Streaming for real-time or Batch for complete file processing
• One-click recording: Single button handles complete setup automatically
• Sequential process: Mic access → WebSocket connection → Audio session → Recording
diff --git a/backends/advanced/webui/src/pages/LoginPage.tsx b/backends/advanced/webui/src/pages/LoginPage.tsx
index 70717b001..a6f6f3ea0 100644
--- a/backends/advanced/webui/src/pages/LoginPage.tsx
+++ b/backends/advanced/webui/src/pages/LoginPage.tsx
@@ -3,6 +3,7 @@ import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { BACKEND_URL } from '../services/api'
import { Music, Eye, EyeOff } from 'lucide-react'
+import { Button } from '../components/ui'
export default function LoginPage() {
const [email, setEmail] = useState('')
@@ -118,17 +119,13 @@ export default function LoginPage() {
)}
-
+
{isLoading ? (
) : (
'Sign in'
)}
-
+
diff --git a/backends/advanced/webui/src/pages/MemoryLedger.tsx b/backends/advanced/webui/src/pages/MemoryLedger.tsx
index 5c2564550..48b4bb658 100644
--- a/backends/advanced/webui/src/pages/MemoryLedger.tsx
+++ b/backends/advanced/webui/src/pages/MemoryLedger.tsx
@@ -7,13 +7,15 @@ import {
import { useAuth } from '../contexts/AuthContext'
import { useMemoryLedger, useMemoryAuditDiff } from '../hooks/useMemoryLedger'
import type { MemoryAuditEntry } from '../services/api'
+import { Button, MetadataChip, StateBadge } from '../components/ui'
// ---- Provenance classification -------------------------------------------
// The ledger answers "who changed this memory and why". The backend owns the
// taxonomy (cause × actor → source_kind/source_label, see services/memory/
-// audit.py); the UI only maps the coarse `source_kind` to an icon + colour and
+// audit.py); the UI maps the coarse `source_kind` to a distinguishing icon and
// prints the precise `source_label` (e.g. "Speaker reprocess" vs "Transcript
-// reprocess" both share the cyan reprocess family).
+// reprocess" both share the reprocess family). The chip stays muted — the icon
+// and label carry the category; provenance is metadata, not a state signal.
type SourceKind = MemoryAuditEntry['source_kind']
@@ -21,31 +23,25 @@ interface SourceMeta {
kind: SourceKind
label: string
Icon: LucideIcon
- chip: string // tailwind classes for the chip
}
-const KIND_STYLE: Record = {
- extraction: { Icon: Sparkles, chip: 'text-blue-700 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300' },
- reprocess: { Icon: RefreshCw, chip: 'text-cyan-700 bg-cyan-50 dark:bg-cyan-900/30 dark:text-cyan-300' },
- human: { Icon: PenLine, chip: 'text-purple-700 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-300' },
- agent: { Icon: Bot, chip: 'text-amber-700 bg-amber-50 dark:bg-amber-900/30 dark:text-amber-300' },
- bulk: { Icon: Trash2, chip: 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-300' },
- other: { Icon: FileText, chip: 'text-gray-600 bg-gray-100 dark:bg-gray-700 dark:text-gray-300' },
+const KIND_ICON: Record = {
+ extraction: Sparkles,
+ reprocess: RefreshCw,
+ human: PenLine,
+ agent: Bot,
+ bulk: Trash2,
+ other: FileText,
}
function classifySource(e: MemoryAuditEntry): SourceMeta {
const kind = e.source_kind ?? 'other'
- const style = KIND_STYLE[kind] ?? KIND_STYLE.other
- return { kind, label: e.source_label || 'system', Icon: style.Icon, chip: style.chip }
+ return { kind, label: e.source_label || 'system', Icon: KIND_ICON[kind] ?? FileText }
}
-const OPERATION_CHIP: Record = {
- create: 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-300',
- update: 'text-blue-700 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-300',
- delete: 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-300',
- rename: 'text-amber-700 bg-amber-50 dark:bg-amber-900/30 dark:text-amber-300',
- delete_all: 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-300',
-}
+// Deletions are the safety-relevant operation to scan for in an audit log, so
+// they keep a restrained danger tint; every other operation is plain metadata.
+const isDestructiveOp = (op: string) => op === 'delete' || op === 'delete_all'
const SOURCE_FILTERS: { value: SourceKind | 'all'; label: string }[] = [
{ value: 'all', label: 'All sources' },
@@ -116,14 +112,16 @@ function LedgerRow({ entry }: { entry: MemoryAuditEntry }) {
{expandable ? (open ? : ) : null}
-
+
{src.label}
-
+
-
- {entry.operation}
-
+ {isDestructiveOp(entry.operation) ? (
+ {entry.operation}
+ ) : (
+ {entry.operation}
+ )}
{entry.note_path || (entry.operation === 'delete_all' ? `entire vault${entry.extra?.count ? ` (${String(entry.extra.count)} notes)` : ''}` : '—')}
@@ -164,20 +162,20 @@ function SummaryStrip({ entries }: { entries: MemoryAuditEntry[] }) {
return c
}, [entries])
- const cards: { label: string; value: number; cls: string }[] = [
- { label: 'Total changes', value: entries.length, cls: 'text-gray-900 dark:text-gray-100' },
- { label: 'AI extraction', value: counts.extraction, cls: 'text-blue-600 dark:text-blue-400' },
- { label: 'Memory agent', value: counts.agent, cls: 'text-amber-600 dark:text-amber-400' },
- { label: 'Reprocess', value: counts.reprocess, cls: 'text-cyan-600 dark:text-cyan-400' },
- { label: 'Human (Obsidian)', value: counts.human, cls: 'text-purple-600 dark:text-purple-400' },
- { label: 'Bulk delete', value: counts.bulk, cls: 'text-red-600 dark:text-red-400' },
+ const cards: { label: string; value: number }[] = [
+ { label: 'Total changes', value: entries.length },
+ { label: 'AI extraction', value: counts.extraction },
+ { label: 'Memory agent', value: counts.agent },
+ { label: 'Reprocess', value: counts.reprocess },
+ { label: 'Human (Obsidian)', value: counts.human },
+ { label: 'Bulk delete', value: counts.bulk },
]
return (
{cards.map(c => (
-
{c.value}
+
{c.value}
{c.label}
))}
@@ -234,14 +232,15 @@ export default function MemoryLedger() {
Memory Ledger
- refetch()}
disabled={isFetching}
- className="flex items-center gap-2 rounded-md bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 disabled:opacity-50 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
+ icon={ }
>
-
Refresh
-
+
@@ -358,9 +357,9 @@ function NoteGroup({ notePath, items }: { notePath: string; items: MemoryAuditEn
{open ? : }
{notePath}
-
+
{items.length} {items.length === 1 ? 'change' : 'changes'}
-
+
{open && (
diff --git a/backends/advanced/webui/src/pages/Network.tsx b/backends/advanced/webui/src/pages/Network.tsx
index a8c2e543a..626049315 100644
--- a/backends/advanced/webui/src/pages/Network.tsx
+++ b/backends/advanced/webui/src/pages/Network.tsx
@@ -3,6 +3,7 @@ import { Network as NetworkIcon, RefreshCw, CheckCircle, XCircle, Wifi, WifiOff,
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '../contexts/AuthContext'
import { systemApi, clientsApi } from '../services/api'
+import { Button } from '../components/ui'
interface DiscoveredService {
name: string
@@ -172,18 +173,15 @@ export default function Network() {
Last scan: {lastUpdated.toLocaleTimeString()}
)}
- : }
>
- {loading ? (
-
- ) : (
-
- )}
- {loading ? 'Scanning...' : 'Scan Network'}
-
+ {loading ? 'Scanning...' : 'Scan Network'}
+
diff --git a/backends/advanced/webui/src/pages/Plugins.tsx b/backends/advanced/webui/src/pages/Plugins.tsx
index fec527d2a..f62b9fd88 100644
--- a/backends/advanced/webui/src/pages/Plugins.tsx
+++ b/backends/advanced/webui/src/pages/Plugins.tsx
@@ -3,6 +3,7 @@ import { Code, Layout, Sparkles } from 'lucide-react'
import PluginSettings from '../components/PluginSettings'
import PluginSettingsForm from '../components/PluginSettingsForm'
import PluginAssistant from '../components/plugins/PluginAssistant'
+import { Tabs } from '../components/ui'
type ViewMode = 'form' | 'assistant' | 'yaml'
@@ -19,22 +20,12 @@ export default function Plugins() {
{/* View Mode Toggle */}
-
- {tabs.map((tab) => (
- setViewMode(tab.key)}
- className={`flex items-center space-x-2 px-4 py-2 text-sm transition-colors ${
- viewMode === tab.key
- ? 'bg-blue-600 text-white'
- : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600'
- }`}
- >
- {tab.icon}
- {tab.label}
-
- ))}
-
+
({ value: tab.key, label: tab.label, icon: tab.icon }))}
+ value={viewMode}
+ onChange={setViewMode}
+ variant="pill"
+ />
{/* Content */}
diff --git a/backends/advanced/webui/src/pages/Queue.tsx b/backends/advanced/webui/src/pages/Queue.tsx
index 32a541683..421cfeb3a 100644
--- a/backends/advanced/webui/src/pages/Queue.tsx
+++ b/backends/advanced/webui/src/pages/Queue.tsx
@@ -26,6 +26,7 @@ import {
import { useQueryClient } from '@tanstack/react-query';
import { useQueueDashboard } from '../hooks/useQueue';
import { queueApi, conversationsApi } from '../services/api';
+import { Button, Card, Checkbox, Modal, Select } from '../components/ui';
interface QueueStats {
total_jobs: number;
@@ -658,28 +659,30 @@ const Queue: React.FC = () => {
- }
onClick={() => setShowFlushModal(true)}
- className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
-
- Flush Jobs
-
-
+ }
onClick={invalidateQueue}
disabled={refreshing}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
-
- Refresh
-
+ Refresh
+
{/* Stats Cards */}
{stats && (
-
+
@@ -687,9 +690,9 @@ const Queue: React.FC = () => {
{stats.total_jobs}
-
+
-
+
@@ -697,9 +700,9 @@ const Queue: React.FC = () => {
{stats.queued_jobs}
-
+
-
+
0 ? 'animate-pulse' : ''}`} />
@@ -707,9 +710,9 @@ const Queue: React.FC = () => {
{stats.started_jobs}
-
+
-
+
@@ -717,9 +720,9 @@ const Queue: React.FC = () => {
{stats.finished_jobs}
-
+
-
+
@@ -727,9 +730,9 @@ const Queue: React.FC = () => {
{stats.failed_jobs}
-
+
-
+
@@ -737,9 +740,9 @@ const Queue: React.FC = () => {
{stats.canceled_jobs}
-
+
-
+
@@ -747,7 +750,7 @@ const Queue: React.FC = () => {
{stats.deferred_jobs}
-
+
)}
@@ -766,7 +769,11 @@ const Queue: React.FC = () => {
Cleanup Old Sessions
{streamingStatus?.stream_health && Object.keys(streamingStatus.stream_health).length > 0 && (
- }
+ title="Force remove ALL active streams"
onClick={async () => {
const streamCount = Object.keys(streamingStatus.stream_health).length;
if (!streamingStatus || !confirm(`Remove ALL ${streamCount} active streams? This will force-delete all streams including actively streaming ones.`)) return;
@@ -781,12 +788,9 @@ const Queue: React.FC = () => {
alert(`Failed to remove streams: ${error.response?.data?.error || error.message}`);
}
}}
- className="flex items-center space-x-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors text-sm"
- title="Force remove ALL active streams"
>
-
- Remove All Streams ({Object.keys(streamingStatus.stream_health).length})
-
+ Remove All Streams ({Object.keys(streamingStatus.stream_health).length})
+
)}
{
{/* Close Conversation Button - only for actively running conversations */}
{openConvJob && openConvJob.status === 'started' && (
-
}
+ title="Close the current active conversation"
onClick={async (e) => {
e.stopPropagation();
if (!confirm(`Close the active conversation for ${clientId}? This will end the current conversation and trigger post-processing.`)) return;
@@ -1120,12 +1128,9 @@ const Queue: React.FC = () => {
alert(`Failed to close conversation: ${error.response?.data?.error || error.message}`);
}
}}
- className="flex items-center space-x-1 px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors text-sm font-medium flex-shrink-0 ml-3"
- title="Close the current active conversation"
>
-
-
Close
-
+ Close
+
)}
@@ -1954,31 +1959,23 @@ const Queue: React.FC = () => {
Showing {startIndex + 1}-{Math.min(endIndex, totalConversations)} of {totalConversations} conversations
- setCompletedConvPage(Math.max(1, completedConvPage - 1))}
disabled={completedConvPage === 1}
- className={`px-3 py-1 text-xs rounded ${
- completedConvPage === 1
- ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
- : 'bg-blue-500 text-white hover:bg-blue-600'
- }`}
>
Previous
-
+
Page {completedConvPage} of {totalPages}
- setCompletedConvPage(Math.min(totalPages, completedConvPage + 1))}
disabled={completedConvPage === totalPages}
- className={`px-3 py-1 text-xs rounded ${
- completedConvPage === totalPages
- ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
- : 'bg-blue-500 text-white hover:bg-blue-600'
- }`}
>
Next
-
+
)}
@@ -2171,15 +2168,14 @@ const Queue: React.FC = () => {
{/* Filters */}
-
+
Filters
Status
- setFilters({ ...filters, status: e.target.value })}
- className="w-full border border-gray-300 rounded-md px-3 py-2"
>
All Statuses
Queued
@@ -2188,46 +2184,40 @@ const Queue: React.FC = () => {
Failed
Canceled
Deferred
-
+
Job Type
- setFilters({ ...filters, job_type: e.target.value })}
- className="w-full border border-gray-300 rounded-md px-3 py-2"
>
All Types
Audio File Processing
Single Audio File
Reprocess Transcript
Reprocess Memory
-
+
Priority
- setFilters({ ...filters, priority: e.target.value })}
- className="w-full border border-gray-300 rounded-md px-3 py-2"
>
All Priorities
High
Normal
Low
-
+
-
-
- Apply
-
+ } onClick={applyFilters}>
+ Apply
+
{
-
+
{/* Jobs Table */}
@@ -2383,15 +2373,18 @@ const Queue: React.FC = () => {
{/* Job Details Modal */}
{selectedJob && (
-
-
-
-
Job Details
- setSelectedJob(null)} className="text-gray-400 hover:text-gray-600">
-
-
-
-
+
setSelectedJob(null)}
+ title="Job Details"
+ maxWidthClassName="max-w-6xl"
+ className="max-h-[90vh] overflow-y-auto"
+ footer={
+ setSelectedJob(null)}>
+ Close
+
+ }
+ >
{loadingJobDetails ? (
@@ -2610,21 +2603,23 @@ const Queue: React.FC = () => {
)}
)}
-
-
+
)}
{/* Event Detail Modal */}
{selectedEvent && (
-
-
-
-
Event Details
- setSelectedEvent(null)} className="text-gray-400 hover:text-gray-600">
-
-
-
-
+
setSelectedEvent(null)}
+ title="Event Details"
+ maxWidthClassName="max-w-3xl"
+ className="max-h-[90vh] overflow-y-auto"
+ footer={
+ setSelectedEvent(null)}>
+ Close
+
+ }
+ >
@@ -2703,24 +2698,51 @@ const Queue: React.FC = () => {
)}
-
-
+
)}
{/* Flush Jobs Modal */}
{showFlushModal && (
-
-
-
-
-
- Flush Jobs
-
- setShowFlushModal(false)} className="text-gray-400 hover:text-gray-600">
-
-
-
-
+
{ setShowFlushModal(false); setFlushPreview(null); }}
+ title="Flush Jobs"
+ icon={ }
+ maxWidthClassName="max-w-lg"
+ className="max-h-[90vh] overflow-y-auto"
+ footer={
+ <>
+ { setShowFlushModal(false); setFlushPreview(null); }}
+ >
+ Cancel
+
+ : }
+ >
+ {previewing ? 'Previewing...' : 'Preview'}
+
+ : }
+ >
+ {flushing ? 'Flushing...' : flushSettings.flush_all ? 'Flush ALL Jobs' : 'Flush Selected Jobs'}
+
+ >
+ }
+ >
@@ -2746,10 +2768,9 @@ const Queue: React.FC = () => {
Remove jobs older than:
- setFlushSettings(prev => ({ ...prev, older_than_hours: parseInt(e.target.value) }))}
- className="w-full text-sm border border-gray-300 rounded px-2 py-1"
>
1 hour
6 hours
@@ -2757,34 +2778,31 @@ const Queue: React.FC = () => {
24 hours
3 days
1 week
-
+
@@ -2815,25 +2833,17 @@ const Queue: React.FC = () => {
-
- setFlushSettings(prev => ({ ...prev, include_failed: e.target.checked }))}
- className="text-red-600"
- />
- Also flush failed jobs
-
-
-
- setFlushSettings(prev => ({ ...prev, include_finished: e.target.checked }))}
- className="text-red-600"
- />
- Also flush finished jobs
-
+ setFlushSettings(prev => ({ ...prev, include_failed: e.target.checked }))}
+ label={Also flush failed jobs }
+ />
+
+ setFlushSettings(prev => ({ ...prev, include_finished: e.target.checked }))}
+ label={Also flush finished jobs }
+ />
)}
@@ -2874,56 +2884,8 @@ const Queue: React.FC = () => {
)}
)}
-
-
- { setShowFlushModal(false); setFlushPreview(null); }}
- className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
- >
- Cancel
-
-
- {previewing ? (
- <>
-
- Previewing...
- >
- ) : (
- <>
-
- Preview
- >
- )}
-
-
- {flushing ? (
- <>
-
- Flushing...
- >
- ) : (
- <>
-
- {flushSettings.flush_all ? 'Flush ALL Jobs' : 'Flush Selected Jobs'}
- >
- )}
-
-
-
-
+
)}
);
diff --git a/backends/advanced/webui/src/pages/Settings.tsx b/backends/advanced/webui/src/pages/Settings.tsx
index a7670e318..68e3fa251 100644
--- a/backends/advanced/webui/src/pages/Settings.tsx
+++ b/backends/advanced/webui/src/pages/Settings.tsx
@@ -6,6 +6,8 @@ import { useAuth } from '../contexts/AuthContext'
import { useDiarizationSettings, useLLMOperations, useMiscSettings, useModels, ModelView, ModelType } from '../hooks/useSystem'
import ExternalServices from '../components/ExternalServices'
import AsrContextSettings from '../components/AsrContextSettings'
+import AutomationSettings from '../components/AutomationSettings'
+import { Alert, Button, IconButton, Input, Modal, Select, Textarea } from '../components/ui'
interface DiarizationSettings {
diarization_source: 'provider' | 'pyannote'
@@ -38,7 +40,6 @@ export default function Settings() {
const [diarizationLoading, setDiarizationLoading] = useState(false)
const [miscSettings, setMiscSettings] = useState({
- always_persist_enabled: false,
per_segment_speaker_id: false,
streaming_fallback_timeout_seconds: 120,
always_batch_retranscribe: false,
@@ -166,41 +167,31 @@ export default function Settings() {
Your name
-
setDisplayName(e.target.value)}
placeholder="e.g. Ankush"
- className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
Assistant name
- setAssistantName(e.target.value)}
placeholder="e.g. Chronicle"
- className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
-
+
{identityLoading ? 'Saving...' : 'Save Identity'}
-
+
{identityMessage && (
-
+
)}
@@ -391,13 +382,15 @@ export default function Settings() {
{/* Save Button */}
-
{diarizationLoading ? 'Saving...' : 'Save Diarization Settings'}
-
+
@@ -410,38 +403,6 @@ export default function Settings() {
- {/* Always Persist Audio Toggle */}
-
-
-
- Always Persist Audio
-
-
- Create conversations for all audio sessions, even when no speech is detected
-
- {miscSettings.live_segmentation === 'off' && !miscSettings.always_persist_enabled && (
-
-
-
- Overridden to on for this backend: with Live Segmentation set to Off there is no live transcript to detect speech on, so audio must always be persisted — otherwise batch transcription at conversation end would have nothing to read. Your saved setting is unchanged and takes effect again if you enable a live transcript mode.
-
-
- )}
-
-
- setMiscSettings(prev => ({
- ...prev,
- always_persist_enabled: e.target.checked
- }))}
- className="sr-only peer"
- />
-
-
-
-
{/* Always Batch Re-Transcribe Toggle */}
@@ -554,24 +515,24 @@ export default function Settings() {
{/* Status Message */}
{miscMessage && (
-
+
)}
{/* Save Button */}
-
{miscLoading ? 'Saving...' : 'Save Processing Settings'}
-
+
@@ -590,6 +551,9 @@ export default function Settings() {
{/* Active Models — repoint which registry model each role uses */}
+ {/* Automation & schedules — when background jobs run (run-now lives on Training) */}
+
+
{/* ASR recognition hints (keyword boosting vs LLM context prompt) */}
@@ -791,24 +755,19 @@ function LLMOperationsCard({ data, onSaved }: { data: LLMOpsData; onSaved: () =>
{/* Status Message */}
{message && (
-
+
)}
{/* Save Button */}
-
+
{saving ? 'Saving...' : 'Save AI Model Settings'}
-
+
)
@@ -1051,13 +1010,9 @@ function SpeakerConfiguration({ user }: { user: any }) {
)}
-
+
{saving ? 'Saving...' : 'Save Configuration'}
-
+
{/* Wake Word Speaker Gate */}
@@ -1134,13 +1089,9 @@ function SpeakerConfiguration({ user }: { user: any }) {
)}
-
+
{gateSaving ? 'Saving...' : 'Save Wake Word Access'}
-
+
@@ -1246,22 +1197,17 @@ function ActiveModelsCard({ isAdmin }: { isAdmin: boolean }) {
})}
{message && (
-
+
)}
-
+
{saving ? 'Saving...' : 'Save Active Models'}
-
+
)
@@ -1408,13 +1354,9 @@ function ModelRegistryCard({ isAdmin }: { isAdmin: boolean }) {
Model Registry
-
-
- Add Model
-
+
} onClick={openAdd}>
+ Add Model
+
Provider/model definitions. Built-in templates (defaults.yml) are read-only; models
@@ -1451,14 +1393,14 @@ function ModelRegistryCard({ isAdmin }: { isAdmin: boolean }) {
{m.name}
{m.is_default && (
- default
+ default
)}
{m.model_provider}
{m.model_name}
{keyState(m)}
-
+
{isBuiltin ? 'built-in' : 'config'}
@@ -1478,21 +1420,17 @@ function ModelRegistryCard({ isAdmin }: { isAdmin: boolean }) {
{test?.latency ? `${test.latency}ms` : 'Test'}
)}
-
openEdit(m)}
- className="p-1.5 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
- title="Edit model"
- >
-
-
-
openEdit(m)}>
+
+
+ handleDelete(m)}
disabled={m.is_default || isBuiltin}
- className="p-1.5 rounded border border-gray-300 dark:border-gray-600 hover:bg-red-50 dark:hover:bg-red-900/30 disabled:opacity-30 disabled:cursor-not-allowed"
- title={m.is_default ? 'Active default — repoint first' : isBuiltin ? 'Built-in template (defaults.yml)' : 'Delete model'}
>
-
+
@@ -1531,59 +1469,61 @@ function ModelEditModal({
onSubmit: () => void
}) {
const set = (field: keyof ModelForm, value: string) => setForm({ ...form, [field]: value } as ModelForm)
- const input = 'w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
const label = 'block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1'
return (
-
-
e.stopPropagation()}
- >
-
-
- {isNew ? 'Add Model' : `Edit ${form.name}`}
-
-
-
-
-
-
+
+
+ Cancel
+
+
+ {saving ? 'Saving…' : 'Save Model'}
+
+ >
+ }
+ >
Name {isNew && * }
- set('name', e.target.value)} disabled={!isNew} placeholder="e.g. openai-llm" />
+ set('name', e.target.value)} disabled={!isNew} placeholder="e.g. openai-llm" />
Type
- set('model_type', e.target.value)} disabled={!isNew}>
+ set('model_type', e.target.value)} disabled={!isNew}>
{MODEL_TYPE_ORDER.map(t => {MODEL_TYPE_LABELS[t]} )}
-
+
Provider
- set('model_provider', e.target.value)} placeholder="openai, ollama, deepgram…" />
+ set('model_provider', e.target.value)} placeholder="openai, ollama, deepgram…" />
API family
- set('api_family', e.target.value)} placeholder="openai, http, websocket" />
+ set('api_family', e.target.value)} placeholder="openai, http, websocket" />
Model name
- set('model_name', e.target.value)} placeholder="provider-specific id" />
+ set('model_name', e.target.value)} placeholder="provider-specific id" />
Embedding dims
- set('embedding_dimensions', e.target.value)} placeholder="e.g. 1536" />
+ set('embedding_dimensions', e.target.value)} placeholder="e.g. 1536" />
Base URL
- set('model_url', e.target.value)} placeholder="https://api.openai.com/v1 (blank = Tailnet discovery)" />
+ set('model_url', e.target.value)} placeholder="https://api.openai.com/v1 (blank = Tailnet discovery)" />
API key
- set('api_key', e.target.value)}
@@ -1595,33 +1535,23 @@ function ModelEditModal({
Capabilities (comma-separated)
- set('capabilities', e.target.value)} placeholder="word_timestamps, segments, keyword_boosting…" />
+ set('capabilities', e.target.value)} placeholder="word_timestamps, segments, keyword_boosting…" />
Description
- set('description', e.target.value)} />
+ set('description', e.target.value)} />
Model params (JSON)
- set('model_params', e.target.value)} placeholder='{"temperature": 0.2, "max_tokens": 2000}' />
+ set('model_params', e.target.value)} placeholder='{"temperature": 0.2, "max_tokens": 2000}' />
{error && (
-
+
)}
-
-
-
- Cancel
-
-
- {saving ? 'Saving…' : 'Save Model'}
-
-
-
-
+
)
}
diff --git a/backends/advanced/webui/src/pages/System.tsx b/backends/advanced/webui/src/pages/System.tsx
index 5b1f53391..631bc4bb7 100644
--- a/backends/advanced/webui/src/pages/System.tsx
+++ b/backends/advanced/webui/src/pages/System.tsx
@@ -8,6 +8,7 @@ import { useSystemData, useRestartWorkers, useRestartBackend, useBackendVersion
import { systemApi } from '../services/api'
import ExternalServices from '../components/ExternalServices'
import RemoteControl from '../components/RemoteControl'
+import { Alert, Button, IconButton, Modal } from '../components/ui'
function getBackendHttpUrl(): string {
const { protocol, hostname, port } = window.location
@@ -299,24 +300,21 @@ export default function System() {
Last updated: {lastUpdated.toLocaleTimeString()}
)}
- }
onClick={loadSystemData}
disabled={loading}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
-
- Refresh
-
+ Refresh
+
{/* Three-dot menu */}
-
setMenuOpen(prev => !prev)}
- className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
- title="System actions"
- >
-
-
+
setMenuOpen(prev => !prev)}>
+
+
{menuOpen && (
setConfirmModal(null)}>
- e.stopPropagation()}
- >
- {confirmModal === 'workers' ? (
- <>
-
-
-
-
-
- Restart Workers
-
-
-
- Workers will finish their current jobs before restarting. This is safe to run at any time.
-
-
- Use this after changing plugin configuration or config.yml settings.
-
-
- setConfirmModal(null)}
- className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
- >
- Cancel
-
-
- Restart Workers
-
-
- >
+
setConfirmModal(null)}
+ title={
+ confirmModal === 'workers' ? 'Restart Workers'
+ : confirmModal === 'backend' ? 'Restart Backend'
+ : 'Restart Both'
+ }
+ icon={
+ confirmModal === 'workers' ? (
+
+
+
) : confirmModal === 'backend' ? (
- <>
-
-
-
- Restart Backend
-
-
-
- This will restart the entire backend process. The service will be briefly unavailable.
-
-
-
- Active WebSocket connections and streaming sessions will be dropped.
-
-
-
- setConfirmModal(null)}
- className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
- >
- Cancel
-
-
- Restart Backend
-
-
- >
+
) : (
- <>
-
-
-
-
-
- Restart Both
-
-
-
- This will restart workers and then the backend. The service will be briefly unavailable.
-
-
-
- Active WebSocket connections and streaming sessions will be dropped.
-
-
-
- setConfirmModal(null)}
- className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
- >
- Cancel
-
-
- Restart Both
-
-
- >
- )}
-
-
+
+
+
+ )
+ }
+ footer={
+ <>
+
setConfirmModal(null)}>
+ Cancel
+
+
+ {confirmModal === 'workers' ? 'Restart Workers'
+ : confirmModal === 'backend' ? 'Restart Backend'
+ : 'Restart Both'}
+
+ >
+ }
+ >
+ {confirmModal === 'workers' ? (
+ <>
+
+ Workers will finish their current jobs before restarting. This is safe to run at any time.
+
+
+ Use this after changing plugin configuration or config.yml settings.
+
+ >
+ ) : (
+ <>
+
+ {confirmModal === 'backend'
+ ? 'This will restart the entire backend process. The service will be briefly unavailable.'
+ : 'This will restart workers and then the backend. The service will be briefly unavailable.'}
+
+
+ Active WebSocket connections and streaming sessions will be dropped.
+
+ >
+ )}
+
)}
{/* Error Message */}
{error && (
-
+
+ {error}
+
)}
{/* Overall Health Status */}
@@ -565,19 +524,19 @@ export default function System() {
{/* Info */}
{configDiagnostics.info.map((info: any, idx: number) => (
-
+
-
+
-
+
{info.component}
-
+
INFO
-
+
{info.message}
@@ -629,7 +588,7 @@ export default function System() {
)}
{(status as any).provider && (
-
+
({(status as any).provider})
)}
@@ -708,11 +667,7 @@ export default function System() {
{worker.queues?.join(', ')}
-
+
{worker.state}
@@ -795,17 +750,13 @@ export default function System() {
{backendUrl}
-
+
{copied ? (
) : (
)}
-
+
diff --git a/backends/advanced/webui/src/pages/SystemEvents.tsx b/backends/advanced/webui/src/pages/SystemEvents.tsx
index d7e531137..5e0c33ef0 100644
--- a/backends/advanced/webui/src/pages/SystemEvents.tsx
+++ b/backends/advanced/webui/src/pages/SystemEvents.tsx
@@ -8,6 +8,7 @@ import {
import { useAuth } from '../contexts/AuthContext'
import { useSystemEvents, useSystemEventsSummary } from '../hooks/useSystemEvents'
import { systemEventsApi, type SystemEvent, type SystemEventsFilter } from '../services/api'
+import { Button, Alert, Checkbox } from '../components/ui'
// ---- Severity + category styling ------------------------------------------
@@ -323,14 +324,15 @@ export default function SystemEvents() {
{selected.size > 0 && (
<>
- : }
>
- {copied ? : }
{copied ? 'Copied!' : `Copy errors (${selected.size})`}
-
+
Acknowledge all
-
+
Clear acknowledged
-
-
+ refetch()}
disabled={isFetching}
- className="flex items-center gap-2 rounded-md bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 disabled:opacity-50 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
+ icon={ }
>
-
Refresh
-
+
@@ -374,9 +374,9 @@ export default function SystemEvents() {
{error && (
-
+
{(error as Error).message || 'Failed to load system events.'}
-
+
)}
@@ -416,21 +416,15 @@ export default function SystemEvents() {
{[50, 100, 200, 500].map(n => Last {n} )}
-
- setShowAcked(e.target.checked)} className="rounded" />
- Show acknowledged
-
+ setShowAcked(e.target.checked)} />
-
-
- Select all visible
-
+
{/* List */}
diff --git a/backends/advanced/webui/src/pages/Timeline.tsx b/backends/advanced/webui/src/pages/Timeline.tsx
index c6131837b..7a867e8e7 100644
--- a/backends/advanced/webui/src/pages/Timeline.tsx
+++ b/backends/advanced/webui/src/pages/Timeline.tsx
@@ -1,7 +1,8 @@
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Activity, AppWindow, CalendarDays, Copy, Image, Link2, Monitor, RefreshCw } from 'lucide-react'
import { deviceInputApi, DeviceInputItem } from '../services/api'
+import { Button, Card, IconButton } from '../components/ui'
function dayBounds(day: string) {
const start = new Date(`${day}T00:00:00`)
@@ -16,12 +17,89 @@ function ItemIcon({ item }: { item: DeviceInputItem }) {
return
}
+function TimelineThumbnail({ item }: { item: DeviceInputItem }) {
+ useQuery({
+ queryKey: ['device-input-thumbnail-request', item.id],
+ queryFn: async () => (await deviceInputApi.requestThumbnail(item.id)).data,
+ enabled: item.kind === 'activity' && item.metadata.thumbnail_available !== true,
+ staleTime: Infinity,
+ retry: false,
+ })
+ const thumbnail = useQuery({
+ queryKey: ['device-input-thumbnail', item.id],
+ queryFn: async () => (await deviceInputApi.getThumbnail(item.id)).data,
+ enabled: item.metadata.thumbnail_available === true,
+ staleTime: Infinity,
+ })
+ const url = useMemo(
+ () => thumbnail.data ? URL.createObjectURL(thumbnail.data) : null,
+ [thumbnail.data],
+ )
+ useEffect(() => () => { if (url) URL.revokeObjectURL(url) }, [url])
+ if (!url) return null
+ return
+}
+
+const AUDIO_SESSION_GAP_MS = 90_000
+const AUDIO_SESSION_MAX_MS = 30 * 60_000
+
+export function groupTimelineAudio(items: DeviceInputItem[]): DeviceInputItem[] {
+ const visible = items.filter(item => item.kind !== 'audio')
+ const bySource = new Map()
+ for (const item of items) {
+ if (item.kind !== 'audio') continue
+ bySource.set(item.source_id, [...(bySource.get(item.source_id) || []), item])
+ }
+
+ for (const [sourceId, sourceItems] of bySource) {
+ const ordered = sourceItems.sort((a, b) => Date.parse(a.captured_at) - Date.parse(b.captured_at))
+ let session: DeviceInputItem | null = null
+ for (const item of ordered) {
+ const itemStart = Date.parse(item.captured_at)
+ const itemEnd = Date.parse(item.ended_at || item.captured_at)
+ const sessionStart = session ? Date.parse(session.captured_at) : 0
+ const sessionEnd = session ? Date.parse(session.ended_at || session.captured_at) : 0
+ if (!session || itemStart - sessionEnd > AUDIO_SESSION_GAP_MS || itemStart - sessionStart >= AUDIO_SESSION_MAX_MS) {
+ session = {
+ ...item,
+ id: `audio-session:${sourceId}:${item.id}`,
+ metadata: {
+ chunk_count: 1,
+ directions: item.metadata.direction ? [item.metadata.direction] : [],
+ },
+ }
+ visible.push(session)
+ continue
+ }
+ session.ended_at = new Date(Math.max(sessionEnd, itemEnd)).toISOString()
+ session.metadata.chunk_count += 1
+ const direction = item.metadata.direction
+ if (direction && !session.metadata.directions.includes(direction)) {
+ session.metadata.directions.push(direction)
+ }
+ }
+ }
+ return visible.sort((a, b) => Date.parse(a.captured_at) - Date.parse(b.captured_at))
+}
+
+function formatDuration(item: DeviceInputItem) {
+ const seconds = Math.max(0, Math.round((Date.parse(item.ended_at || item.captured_at) - Date.parse(item.captured_at)) / 1000))
+ if (seconds < 60) return `${seconds}s`
+ const minutes = Math.floor(seconds / 60)
+ return `${minutes}m ${seconds % 60}s`
+}
+
export default function Timeline() {
const [day, setDay] = useState(() => new Date().toISOString().slice(0, 10))
const [start, end] = useMemo(() => dayBounds(day), [day])
- const timeline = useQuery({ queryKey: ['device-timeline', day], queryFn: async () => (await deviceInputApi.getTimeline(start, end)).data.items })
+ const timeline = useQuery({
+ queryKey: ['device-timeline', day],
+ queryFn: async () => (await deviceInputApi.getTimeline(start, end)).data.items,
+ refetchInterval: 10_000,
+ })
const sources = useQuery({ queryKey: ['device-input-sources'], queryFn: async () => (await deviceInputApi.getSources()).data.sources, refetchInterval: 30_000 })
const pairing = useMutation({ mutationFn: async () => (await deviceInputApi.createPairingCode()).data })
+ const visibleItems = useMemo(() => groupTimelineAudio(timeline.data || []), [timeline.data])
return (
@@ -36,20 +114,20 @@ export default function Timeline() {
Sources
- pairing.mutate()} className="inline-flex items-center gap-2 text-sm px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"> Pair ScreenPipe
+ pairing.mutate()} icon={ }>Pair ScreenPipe
{pairing.data && (
Pairing code {pairing.data.code} expires {new Date(pairing.data.expires_at).toLocaleTimeString()}.
- navigator.clipboard.writeText(pairing.data!.code)} className="ml-2">
+ navigator.clipboard.writeText(pairing.data!.code)} className="ml-2">
)}
{(sources.data || []).map(source => (
-
+
{source.name}
{source.provider} · {source.platform}
{source.status}{source.last_seen_at ? ` · ${new Date(source.last_seen_at).toLocaleTimeString()}` : ''}
-
+
))}
{!sources.isLoading && !sources.data?.length &&
No capture sources paired.
}
@@ -58,15 +136,18 @@ export default function Timeline() {
Activity {timeline.isFetching && }
- {(timeline.data || []).map(item => (
+ {visibleItems.map(item => (
{new Date(item.captured_at).toLocaleTimeString()} {item.ended_at && `– ${new Date(item.ended_at).toLocaleTimeString()}`}
- {item.metadata.app_name || item.metadata.window_name || item.metadata.text || item.kind}
+ {item.kind === 'audio' ? 'Audio capture' : item.metadata.app_name || item.metadata.window_name || (item.kind === 'activity' ? 'Screen change' : item.kind)}
+ {item.kind === 'audio' && {formatDuration(item)} · {item.metadata.chunk_count} chunks{item.metadata.directions?.length ? ` · ${item.metadata.directions.join(' + ')}` : ''}
}
{item.metadata.window_name && item.metadata.window_name !== item.metadata.app_name && {item.metadata.window_name}
}
+ {item.kind === 'activity' && item.metadata.text && {item.metadata.text}
}
+
))}
- {!timeline.isLoading && !timeline.data?.length &&
Nothing captured for this day.
}
+ {!timeline.isLoading && !visibleItems.length &&
Nothing captured for this day.
}
diff --git a/backends/advanced/webui/src/pages/Upload.tsx b/backends/advanced/webui/src/pages/Upload.tsx
index 38cb4ba79..33ade3b94 100644
--- a/backends/advanced/webui/src/pages/Upload.tsx
+++ b/backends/advanced/webui/src/pages/Upload.tsx
@@ -14,6 +14,7 @@ import {
import { useNavigate } from 'react-router-dom'
import { dataAuditApi, uploadApi } from '../services/api'
import { useAuth } from '../contexts/AuthContext'
+import { Button, IconButton, Input, Alert } from '../components/ui'
const SUPPORTED_EXTENSIONS = ['.wav', '.mp3', '.m4a', '.flac', '.ogg', '.mp4', '.webm']
const VIDEO_EXTENSIONS = ['.mp4', '.webm']
@@ -320,33 +321,29 @@ export default function Upload() {
- setGdriveFolderId(e.target.value)}
placeholder="1AbCdEfGhIjKlMnOpQrStUvWxYz123456"
- className="min-w-0 flex-1 px-3 py-2 border rounded-lg dark:bg-gray-800 dark:text-gray-100"
+ className="min-w-0 flex-1"
/>
-
{isUploading ? 'Submitting...' : annotationOnly ? 'Import for review' : 'Process folder'}
-
+
{gdriveUploadStatus.type && (
-
+
{gdriveUploadStatus.message}
-
+
)}
@@ -381,23 +378,16 @@ export default function Upload() {
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
- fileInputRef.current?.click()}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
- >
+ fileInputRef.current?.click()}>
Select files
-
+
{/* Video Warning */}
{videoWarning && (
-
-
-
- Video files detected — only the audio track will be extracted.
-
-
+ }>
+ Video files detected — only the audio track will be extracted.
+
)}
{/* File List */}
@@ -408,16 +398,14 @@ export default function Upload() {
Files ({files.length})
-
+
Clear Completed
-
-
+ f.status !== 'pending')}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isUploading
? 'Uploading...'
@@ -426,7 +414,7 @@ export default function Upload() {
? 'Import dataset'
: 'Add to annotation workspace'
: 'Process files'}
-
+
@@ -469,12 +457,9 @@ export default function Upload() {
{uploadFile.status === 'pending' && (
- removeFile(uploadFile.id)}
- className="p-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
- >
+ removeFile(uploadFile.id)}>
-
+
)}
diff --git a/backends/advanced/webui/src/pages/Users.tsx b/backends/advanced/webui/src/pages/Users.tsx
index f4db7adc9..817547d5d 100644
--- a/backends/advanced/webui/src/pages/Users.tsx
+++ b/backends/advanced/webui/src/pages/Users.tsx
@@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { Users as UsersIcon, Plus, Edit, Trash2, RefreshCw, Shield, User, Mail } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers'
+import { Button, IconButton, Input, Label, Checkbox, StateBadge } from '../components/ui'
interface User {
_id: string
@@ -126,20 +127,8 @@ export default function Users() {
-
refetch()}
- className="flex items-center space-x-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
- >
-
- Refresh
-
-
setShowCreateForm(true)}
- className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
- >
-
- Add User
-
+
refetch()} icon={ }>Refresh
+
setShowCreateForm(true)} icon={ }>Add User
@@ -165,79 +154,61 @@ export default function Users() {
-
+
Password {editingUser && "(leave blank to keep current password)"}
-
-
+ setFormData({ ...formData, password: e.target.value })}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
-
- setFormData({ ...formData, is_superuser: e.target.checked })}
- className="rounded border-gray-300"
- />
- Administrator
-
-
- setFormData({ ...formData, is_active: e.target.checked })}
- className="rounded border-gray-300"
- />
- Active
-
+ setFormData({ ...formData, is_superuser: e.target.checked })}
+ />
+ setFormData({ ...formData, is_active: e.target.checked })}
+ />
-
+
{editingUser ? 'Update User' : 'Create User'}
-
-
+
+
Cancel
-
+
@@ -293,38 +264,24 @@ export default function Users() {
{user.is_superuser && }
-
+
{user.is_superuser ? 'Admin' : 'User'}
-
+
-
+
{user.is_active ? 'Active' : 'Inactive'}
-
+
- handleEditUser(user)}
- className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
- >
+ handleEditUser(user)}>
-
- handleDeleteUser(user)}
- className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
- >
+
+ handleDeleteUser(user)}>
-
+
diff --git a/backends/advanced/webui/src/pages/WakeWordLab.tsx b/backends/advanced/webui/src/pages/WakeWordLab.tsx
index 098212e87..33a652e6a 100644
--- a/backends/advanced/webui/src/pages/WakeWordLab.tsx
+++ b/backends/advanced/webui/src/pages/WakeWordLab.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Mic, Radio, Trash2, Check, X, RefreshCw, Target, AlertTriangle, Square, Volume2, ShieldCheck, Eye, HelpCircle, CopyX, ArrowRightLeft } from 'lucide-react'
import { wakewordApi, WakeStream, WakeSample, WakeWordConfig, WakeStats } from '../services/api'
import { useAuth } from '../contexts/AuthContext'
+import { Alert, Button, Card, IconButton, StatCard, Tabs } from '../components/ui'
type Bucket = 'pending' | 'positive' | 'negative'
@@ -113,13 +114,13 @@ export default function WakeWordLab() {
Wake-Word Lab
- }
onClick={refreshAll}
- className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
>
-
Refresh
-
+
@@ -144,13 +145,13 @@ export default function WakeWordLab() {
{error && (
-
+ } className="mb-4">
+ {error}
+
)}
{/* Shared active-streams indicator */}
-
+
Active streams
@@ -174,7 +175,7 @@ export default function WakeWordLab() {
))}
)}
-
+
{/* One section per wake word */}
{words.length === 0 ? (
@@ -445,9 +446,9 @@ function WakeWordSection({
{primedMsg && (
-
- {primedMsg}
-
+
} className="mb-4 font-medium animate-pulse">
+ {primedMsg}
+
)}
{/* Stats */}
@@ -460,19 +461,12 @@ function WakeWordSection({
{/* Bucket tabs + labeling help */}
- {(Object.keys(BUCKET_LABELS) as Bucket[]).map((b) => (
-
setBucket(b)}
- className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
- bucket === b
- ? 'bg-blue-600 text-white'
- : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
- }`}
- >
- {BUCKET_LABELS[b]}
-
- ))}
+
({ value: b, label: BUCKET_LABELS[b] }))}
+ />
@@ -643,21 +637,6 @@ function LabelGuide({ word }: { word: string }) {
)
}
-function StatCard({ label, value, tone }: { label: string; value: number; tone: string }) {
- const tones: Record = {
- amber: 'text-amber-600 dark:text-amber-400',
- green: 'text-green-600 dark:text-green-400',
- red: 'text-red-600 dark:text-red-400',
- blue: 'text-blue-600 dark:text-blue-400',
- }
- return (
-
- )
-}
-
function ClipRow({
sample,
allWords,
@@ -766,13 +745,9 @@ function ClipRow({
>
Not
- onDelete(sample.id)}
- title="Delete clip"
- className="rounded-md p-1 text-gray-400 hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-700"
- >
+ onDelete(sample.id)}>
-
+
)
diff --git a/backends/advanced/webui/src/services/api.ts b/backends/advanced/webui/src/services/api.ts
index 39c5b5634..21f63cf9f 100644
--- a/backends/advanced/webui/src/services/api.ts
+++ b/backends/advanced/webui/src/services/api.ts
@@ -193,6 +193,12 @@ export const deviceInputApi = {
api.get<{ items: DeviceInputItem[] }>('/api/device-input/timeline', {
params: { start_at: startAt, end_at: endAt },
}),
+ getThumbnail: (itemId: string) =>
+ api.get
(`/api/device-input/items/${itemId}/thumbnail`, {
+ responseType: 'blob',
+ }),
+ requestThumbnail: (itemId: string) =>
+ api.post(`/api/device-input/items/${itemId}/request-thumbnail`),
getConversationContext: (conversationId: string) =>
api.get<{ items: DeviceInputItem[] }>(`/api/device-input/conversations/${conversationId}/context`),
requestConversationContext: (conversationId: string) =>
diff --git a/backends/advanced/webui/src/styles/slider.css b/backends/advanced/webui/src/styles/slider.css
index a8bbc03fb..f74ca6e89 100644
--- a/backends/advanced/webui/src/styles/slider.css
+++ b/backends/advanced/webui/src/styles/slider.css
@@ -21,7 +21,7 @@ input[type="range"].slider::-webkit-slider-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
- background: #3B82F6;
+ background: #d2694a;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
@@ -29,7 +29,7 @@ input[type="range"].slider::-webkit-slider-thumb {
input[type="range"].slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
- box-shadow: 0 2px 8px rgba(59, 130, 246, 0.5);
+ box-shadow: 0 2px 8px rgba(210, 105, 74, 0.5);
}
/* Firefox */
@@ -38,7 +38,7 @@ input[type="range"].slider::-moz-range-thumb {
height: 20px;
border: none;
border-radius: 50%;
- background: #3B82F6;
+ background: #d2694a;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
@@ -46,7 +46,7 @@ input[type="range"].slider::-moz-range-thumb {
input[type="range"].slider::-moz-range-thumb:hover {
transform: scale(1.1);
- box-shadow: 0 2px 8px rgba(59, 130, 246, 0.5);
+ box-shadow: 0 2px 8px rgba(210, 105, 74, 0.5);
}
/* Track styling for gradient effect */
@@ -66,13 +66,13 @@ input[type="range"].slider::-moz-range-track {
/* Dark mode adjustments */
.dark input[type="range"].slider {
- background: linear-gradient(to right, #3B82F6 var(--progress), #374151 var(--progress));
+ background: linear-gradient(to right, #d2694a var(--progress), #2c251d var(--progress));
}
.dark input[type="range"].slider::-webkit-slider-thumb {
- background: #60A5FA;
+ background: #e07856;
}
.dark input[type="range"].slider::-moz-range-thumb {
- background: #60A5FA;
+ background: #e07856;
}
diff --git a/backends/advanced/webui/tailwind.config.js b/backends/advanced/webui/tailwind.config.js
index 9121d4e4f..7a271c685 100644
--- a/backends/advanced/webui/tailwind.config.js
+++ b/backends/advanced/webui/tailwind.config.js
@@ -1,10 +1,13 @@
/** @type {import('tailwindcss').Config} */
+import espressoPreset from './chronicle-espresso-preset.js'
+
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
+ presets: [espressoPreset],
theme: {
extend: {},
},
diff --git a/extras/screenpipe-collector/chronicle_screenpipe/collector.py b/extras/screenpipe-collector/chronicle_screenpipe/collector.py
index 2e8119424..766e33f77 100644
--- a/extras/screenpipe-collector/chronicle_screenpipe/collector.py
+++ b/extras/screenpipe-collector/chronicle_screenpipe/collector.py
@@ -95,7 +95,36 @@ def activity_key(row: sqlite3.Row) -> tuple[str, str, str]:
return (row["app_name"] or "", row["window_name"] or "", row["browser_url"] or "")
-def build_activity_sessions(rows: Iterable[sqlite3.Row], debounce_seconds: float = 10.0) -> list[dict[str, Any]]:
+def text_excerpt(value: str | None, limit: int = 2000) -> str:
+ """Keep useful searchable context without mirroring ScreenPipe's full text."""
+ return " ".join((value or "").split())[:limit]
+
+
+def update_representative(current: dict[str, Any], row: sqlite3.Row) -> None:
+ text = text_excerpt(row["full_text"] if "full_text" in row.keys() else None)
+ if text:
+ current["text"] = text
+ current["text_source"] = (
+ row["text_source"] if "text_source" in row.keys() else None
+ )
+ current["representative_frame_id"] = row["id"]
+
+
+def activity_is_salient(
+ session: dict[str, Any], unknown_min_seconds: float = 10.0
+) -> bool:
+ """Keep named/textual changes immediately; gate unattributed visual noise."""
+ if any(session.get(key) for key in ("app_name", "window_name", "text")):
+ return True
+ duration = timestamp_seconds(session["ended_at"]) - timestamp_seconds(
+ session["captured_at"]
+ )
+ return session.get("frame_count", 0) >= 2 and duration >= unknown_min_seconds
+
+
+def build_activity_sessions(
+ rows: Iterable[sqlite3.Row], debounce_seconds: float = 10.0
+) -> list[dict[str, Any]]:
"""Collapse frame headers into transitions; OCR and pixels are intentionally ignored."""
sessions: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
@@ -106,6 +135,7 @@ def build_activity_sessions(rows: Iterable[sqlite3.Row], debounce_seconds: float
current["ended_at"] = captured
current["last_frame_id"] = row["id"]
current["frame_count"] += 1
+ update_representative(current, row)
continue
if current is not None:
sessions.append(current)
@@ -121,13 +151,17 @@ def build_activity_sessions(rows: Iterable[sqlite3.Row], debounce_seconds: float
"window_name": key[1],
"browser_url": key[2],
"capture_trigger": row["capture_trigger"] or "",
+ "representative_frame_id": row["id"],
}
+ update_representative(current, row)
if current is not None:
sessions.append(current)
return sessions
-def fold_activity_rows(rows: Iterable[sqlite3.Row], current: dict[str, Any] | None) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
+def fold_activity_rows(
+ rows: Iterable[sqlite3.Row], current: dict[str, Any] | None
+) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""Extend the open activity across poll boundaries and return closed sessions."""
closed: list[dict[str, Any]] = []
for row in rows:
@@ -137,6 +171,7 @@ def fold_activity_rows(rows: Iterable[sqlite3.Row], current: dict[str, Any] | No
current["ended_at"] = captured
current["last_frame_id"] = row["id"]
current["frame_count"] += 1
+ update_representative(current, row)
continue
if current is not None:
closed.append(current)
@@ -152,7 +187,9 @@ def fold_activity_rows(rows: Iterable[sqlite3.Row], current: dict[str, Any] | No
"window_name": key[1],
"browser_url": key[2],
"capture_trigger": row["capture_trigger"] or "",
+ "representative_frame_id": row["id"],
}
+ update_representative(current, row)
return closed, current
@@ -190,7 +227,16 @@ def collect_audio(self, connection: sqlite3.Connection) -> int:
columns = table_columns(connection, "audio_chunks")
required = {"id", "file_path", "timestamp"}
if not required <= columns:
- raise RuntimeError(f"unsupported ScreenPipe audio_chunks schema; missing {sorted(required - columns)}")
+ # ScreenPipe may expose a migration placeholder briefly before the
+ # first audio segment initializes the final schema.
+ count = connection.execute("SELECT COUNT(*) FROM audio_chunks").fetchone()[
+ 0
+ ]
+ if count == 0:
+ return 0
+ raise RuntimeError(
+ f"unsupported ScreenPipe audio_chunks schema; missing {sorted(required - columns)}"
+ )
cursor = self.checkpoints.get("audio")
rows = connection.execute(
"SELECT id, file_path, timestamp FROM audio_chunks WHERE id > ? AND timestamp IS NOT NULL ORDER BY id LIMIT 100",
@@ -202,17 +248,31 @@ def collect_audio(self, connection: sqlite3.Connection) -> int:
if not path.is_file():
captured = timestamp_seconds(iso_timestamp(row["timestamp"]))
if time.time() - captured < 120:
- logger.warning("audio chunk %s is not available yet: %s", row["id"], path)
+ logger.warning(
+ "audio chunk %s is not available yet: %s", row["id"], path
+ )
break
self.rejections_path.parent.mkdir(parents=True, exist_ok=True)
with self.rejections_path.open("a", encoding="utf-8") as rejected:
- rejected.write(json.dumps({"stream": "audio", "source_item_id": row["id"], "detail": "source media missing"}) + "\n")
+ rejected.write(
+ json.dumps(
+ {
+ "stream": "audio",
+ "source_item_id": row["id"],
+ "detail": "source media missing",
+ }
+ )
+ + "\n"
+ )
self.checkpoints.set("audio", row["id"])
continue
before = path.stat()
time.sleep(0.05)
after = path.stat()
- if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns:
+ if (
+ before.st_size != after.st_size
+ or before.st_mtime_ns != after.st_mtime_ns
+ ):
break
digest = hashlib.sha256(path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(path.name)[0] or "audio/wav"
@@ -235,7 +295,17 @@ def collect_audio(self, connection: sqlite3.Connection) -> int:
logger.error("audio chunk %s rejected: %s", row["id"], response.text)
self.rejections_path.parent.mkdir(parents=True, exist_ok=True)
with self.rejections_path.open("a", encoding="utf-8") as rejected:
- rejected.write(json.dumps({"stream": "audio", "source_item_id": row["id"], "status": response.status_code, "detail": response.text[:1000]}) + "\n")
+ rejected.write(
+ json.dumps(
+ {
+ "stream": "audio",
+ "source_item_id": row["id"],
+ "status": response.status_code,
+ "detail": response.text[:1000],
+ }
+ )
+ + "\n"
+ )
self.checkpoints.set("audio", row["id"])
sent += 1
return sent
@@ -244,11 +314,14 @@ def collect_activity(self, connection: sqlite3.Connection) -> int:
columns = table_columns(connection, "frames")
required = {"id", "timestamp", "app_name", "window_name"}
if not required <= columns:
- raise RuntimeError(f"unsupported ScreenPipe frames schema; missing {sorted(required - columns)}")
+ raise RuntimeError(
+ f"unsupported ScreenPipe frames schema; missing {sorted(required - columns)}"
+ )
optional = lambda name: name if name in columns else f"NULL AS {name}"
cursor = self.checkpoints.get("frames")
rows = connection.execute(
- f"SELECT id, timestamp, app_name, window_name, {optional('browser_url')}, {optional('capture_trigger')} "
+ f"SELECT id, timestamp, app_name, window_name, {optional('browser_url')}, "
+ f"{optional('capture_trigger')}, {optional('full_text')}, {optional('text_source')} "
"FROM frames WHERE id > ? ORDER BY id LIMIT 1000",
(cursor,),
).fetchall()
@@ -261,8 +334,9 @@ def collect_activity(self, connection: sqlite3.Connection) -> int:
sessions = [
session
for session in ([*closed, current] if current else closed)
- if timestamp_seconds(session["ended_at"]) - timestamp_seconds(session["captured_at"])
- >= self.config.activity_debounce_seconds
+ if activity_is_salient(
+ session, unknown_min_seconds=self.config.activity_debounce_seconds
+ )
]
if not sessions:
self.activity_path.parent.mkdir(parents=True, exist_ok=True)
@@ -277,7 +351,11 @@ def collect_activity(self, connection: sqlite3.Connection) -> int:
"source_item_id": session["source_item_id"],
"captured_at": session["captured_at"],
"ended_at": session["ended_at"],
- "metadata": {k: v for k, v in session.items() if k not in {"key", "source_item_id", "captured_at", "ended_at"}},
+ "metadata": {
+ k: v
+ for k, v in session.items()
+ if k not in {"key", "source_item_id", "captured_at", "ended_at"}
+ },
}
for session in sessions
]
@@ -298,18 +376,46 @@ def process_job(self) -> bool:
if not job:
return False
try:
+ if job["kind"] == "thumbnail":
+ frame_id = job.get("payload", {}).get("frame_id")
+ if frame_id is None:
+ raise RuntimeError("thumbnail job is missing frame_id")
+ headers = (
+ {"Authorization": f"Bearer {self.config.screenpipe_token}"}
+ if self.config.screenpipe_token
+ else None
+ )
+ thumbnail = httpx.get(
+ f"{self.config.screenpipe_url.rstrip('/')}/frames/{frame_id}/thumbnail",
+ params={"width": 640, "quality": 75},
+ headers=headers,
+ timeout=30,
+ )
+ thumbnail.raise_for_status()
+ done = self.client.post(
+ f"/api/device-input/jobs/{job['id']}/thumbnail",
+ files={
+ "file": (
+ f"screenpipe-frame-{frame_id}.jpg",
+ thumbnail.content,
+ thumbnail.headers.get("content-type", "image/jpeg"),
+ )
+ },
+ )
+ done.raise_for_status()
+ return True
raw_items = []
offset = 0
page_size = 500
while True:
params = {
"content_type": "ocr",
- "start_time": iso_timestamp(job["start_at"])
- if job.get("start_at")
- else None,
- "end_time": iso_timestamp(job["end_at"])
- if job.get("end_at")
- else None,
+ "start_time": (
+ iso_timestamp(job["start_at"]) if job.get("start_at") else None
+ ),
+ "end_time": (
+ iso_timestamp(job["end_at"]) if job.get("end_at") else None
+ ),
"limit": page_size,
"offset": offset,
}
@@ -336,21 +442,25 @@ def process_job(self) -> bool:
frame_id = content.get("frame_id")
if frame_id is None:
continue
- items.append({
- "source_item_id": f"frame:{frame_id}",
- "captured_at": content.get("timestamp"),
- "metadata": {
- "frame_id": frame_id,
- "app_name": content.get("app_name"),
- "window_name": content.get("window_name"),
- "browser_url": content.get("browser_url"),
- "text": content.get("text"),
- },
- })
+ items.append(
+ {
+ "source_item_id": f"frame:{frame_id}",
+ "captured_at": content.get("timestamp"),
+ "metadata": {
+ "frame_id": frame_id,
+ "app_name": content.get("app_name"),
+ "window_name": content.get("window_name"),
+ "browser_url": content.get("browser_url"),
+ "text": content.get("text"),
+ },
+ }
+ )
result = {"success": True, "items": items}
except Exception as exc:
result = {"success": False, "items": [], "error": str(exc)}
- done = self.client.post(f"/api/device-input/jobs/{job['id']}/complete", json=result)
+ done = self.client.post(
+ f"/api/device-input/jobs/{job['id']}/complete", json=result
+ )
done.raise_for_status()
return True
diff --git a/extras/screenpipe-collector/init.py b/extras/screenpipe-collector/init.py
new file mode 100644
index 000000000..8328b4e3a
--- /dev/null
+++ b/extras/screenpipe-collector/init.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""Guided host-native setup for a Chronicle ScreenPipe capture node."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import secrets
+import shutil
+import subprocess
+from pathlib import Path
+
+from rich.console import Console
+from rich.prompt import Confirm, Prompt
+
+
+console = Console()
+PROJECT = Path(__file__).resolve().parent
+SYSTEMD_USER_DIR = Path.home() / ".config/systemd/user"
+
+
+def screenpipe_command() -> str | None:
+ return shutil.which("screenpipe")
+
+
+def write_screenpipe_unit(binary: str, api_key: str) -> Path:
+ SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
+ path = SYSTEMD_USER_DIR / "screenpipe.service"
+ args = [
+ binary,
+ "record",
+ "--audio-transcription-engine", "disabled",
+ "--use-system-default-audio", "true",
+ "--use-all-monitors", "true",
+ "--use-pii-removal", "true",
+ "--disable-keyboard-capture",
+ "--disable-clipboard-capture",
+ "--prioritize-input-latency",
+ "--pause-on-drm-content",
+ "--disable-meeting-detector",
+ "--disable-telemetry",
+ "--video-quality", "balanced",
+ "--retention-days", "90",
+ "--retention-mode", "media",
+ "--api-auth", "true",
+ ]
+ path.write_text(
+ "[Unit]\nDescription=ScreenPipe local recorder for Chronicle\n"
+ "After=graphical-session.target\n\n[Service]\nType=simple\n"
+ f"Environment=SCREENPIPE_API_KEY={api_key}\n"
+ f"ExecStart={' '.join(args)}\nRestart=on-failure\nRestartSec=5\n\n"
+ "[Install]\nWantedBy=default.target\n",
+ encoding="utf-8",
+ )
+ path.chmod(0o600)
+ return path
+
+
+def run(*args: str) -> None:
+ subprocess.run(list(args), check=True)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Configure a Chronicle capture node")
+ parser.add_argument("--backend")
+ args = parser.parse_args()
+
+ console.print("\n🖥️ [bold cyan]Chronicle capture node[/bold cyan]")
+ binary = screenpipe_command()
+ if not binary:
+ console.print(
+ "[red]✗ ScreenPipe is not installed.[/red] Install it independently, verify "
+ "[cyan]screenpipe record --help[/cyan], then rerun this wizard."
+ )
+ raise SystemExit(1)
+ console.print(f"[green]✅[/green] ScreenPipe detected: [cyan]{binary}[/cyan]")
+
+ backend = Prompt.ask("Chronicle backend URL", default=args.backend or "http://127.0.0.1:8000")
+ console.print(
+ "Open Chronicle → Timeline → Sources and create a pairing code. "
+ "The code expires after 10 minutes."
+ )
+ code = Prompt.ask("Pairing code").strip()
+ if not code:
+ raise SystemExit("pairing code is required")
+
+ api_key = secrets.token_urlsafe(32)
+ run(
+ "uv", "run", "--project", str(PROJECT), "chronicle-screenpipe", "pair",
+ "--backend", backend,
+ "--code", code,
+ "--screenpipe-dir", str(Path.home() / ".screenpipe"),
+ "--screenpipe-url", "http://127.0.0.1:3030",
+ "--screenpipe-token", api_key,
+ )
+ write_screenpipe_unit(binary, api_key)
+ run("uv", "run", "--project", str(PROJECT), "chronicle-screenpipe", "install-service")
+ run("systemctl", "--user", "daemon-reload")
+ run("systemctl", "--user", "enable", "--now", "screenpipe.service")
+ run("systemctl", "--user", "restart", "chronicle-screenpipe.service")
+
+ if Confirm.ask("Check service status now?", default=True):
+ run(
+ "systemctl", "--user", "--no-pager", "--full", "status",
+ "screenpipe.service", "chronicle-screenpipe.service",
+ )
+ console.print(
+ "\n[green]✅ Capture node connected.[/green] Activity should appear in "
+ "Chronicle Timeline after ScreenPipe records a stable app/window span."
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/extras/screenpipe-collector/tests/test_collector.py b/extras/screenpipe-collector/tests/test_collector.py
index a6be91dd1..cb6bbb005 100644
--- a/extras/screenpipe-collector/tests/test_collector.py
+++ b/extras/screenpipe-collector/tests/test_collector.py
@@ -1,20 +1,37 @@
import sqlite3
from pathlib import Path
-from chronicle_screenpipe.collector import Checkpoints, audio_duration, build_activity_sessions, fold_activity_rows, infer_audio_direction
+from chronicle_screenpipe.collector import (
+ Checkpoints,
+ Collector,
+ activity_is_salient,
+ audio_duration,
+ build_activity_sessions,
+ fold_activity_rows,
+ infer_audio_direction,
+ text_excerpt,
+)
def test_activity_sessions_collapse_same_window():
db = sqlite3.connect(":memory:")
db.row_factory = sqlite3.Row
- db.execute("CREATE TABLE frames (id INTEGER, timestamp TEXT, app_name TEXT, window_name TEXT, browser_url TEXT, capture_trigger TEXT)")
- db.executemany("INSERT INTO frames VALUES (?, ?, ?, ?, ?, ?)", [
- (1, "2026-07-22T10:00:00", "Code", "chronicle", None, "AppSwitch"),
- (2, "2026-07-22T10:00:05", "Code", "chronicle", None, "Keystroke"),
- (3, "2026-07-22T10:01:00", "Game", "Game", None, "AppSwitch"),
- ])
+ db.execute(
+ "CREATE TABLE frames (id INTEGER, timestamp TEXT, app_name TEXT, window_name TEXT, browser_url TEXT, capture_trigger TEXT)"
+ )
+ db.executemany(
+ "INSERT INTO frames VALUES (?, ?, ?, ?, ?, ?)",
+ [
+ (1, "2026-07-22T10:00:00", "Code", "chronicle", None, "AppSwitch"),
+ (2, "2026-07-22T10:00:05", "Code", "chronicle", None, "Keystroke"),
+ (3, "2026-07-22T10:01:00", "Game", "Game", None, "AppSwitch"),
+ ],
+ )
sessions = build_activity_sessions(db.execute("SELECT * FROM frames ORDER BY id"))
- assert [(s["app_name"], s["frame_count"]) for s in sessions] == [("Code", 2), ("Game", 1)]
+ assert [(s["app_name"], s["frame_count"]) for s in sessions] == [
+ ("Code", 2),
+ ("Game", 1),
+ ]
def test_checkpoints_are_atomic(tmp_path: Path):
@@ -31,22 +48,83 @@ def test_audio_direction_from_screenpipe_filename():
def test_activity_session_survives_poll_boundary():
db = sqlite3.connect(":memory:")
db.row_factory = sqlite3.Row
- db.execute("CREATE TABLE frames (id INTEGER, timestamp TEXT, app_name TEXT, window_name TEXT, browser_url TEXT, capture_trigger TEXT)")
- db.executemany("INSERT INTO frames VALUES (?, ?, ?, ?, ?, ?)", [
- (1, "2026-07-22T10:00:00", "Game", "Game", None, "AppSwitch"),
- (2, "2026-07-22T10:00:05", "Game", "Game", None, "VisualChange"),
- (3, "2026-07-22T10:01:00", "Code", "chronicle", None, "AppSwitch"),
- ])
- closed, current = fold_activity_rows(db.execute("SELECT * FROM frames WHERE id <= 1"), None)
+ db.execute(
+ "CREATE TABLE frames (id INTEGER, timestamp TEXT, app_name TEXT, window_name TEXT, browser_url TEXT, capture_trigger TEXT)"
+ )
+ db.executemany(
+ "INSERT INTO frames VALUES (?, ?, ?, ?, ?, ?)",
+ [
+ (1, "2026-07-22T10:00:00", "Game", "Game", None, "AppSwitch"),
+ (2, "2026-07-22T10:00:05", "Game", "Game", None, "VisualChange"),
+ (3, "2026-07-22T10:01:00", "Code", "chronicle", None, "AppSwitch"),
+ ],
+ )
+ closed, current = fold_activity_rows(
+ db.execute("SELECT * FROM frames WHERE id <= 1"), None
+ )
assert closed == []
- closed, current = fold_activity_rows(db.execute("SELECT * FROM frames WHERE id > 1"), current)
+ closed, current = fold_activity_rows(
+ db.execute("SELECT * FROM frames WHERE id > 1"), current
+ )
assert closed[0]["source_item_id"] == "activity:1"
assert closed[0]["frame_count"] == 2
assert current["app_name"] == "Code"
+def test_activity_uses_bounded_text_and_its_frame_as_representative():
+ db = sqlite3.connect(":memory:")
+ db.row_factory = sqlite3.Row
+ db.execute(
+ "CREATE TABLE frames (id INTEGER, timestamp TEXT, app_name TEXT, "
+ "window_name TEXT, browser_url TEXT, capture_trigger TEXT, "
+ "full_text TEXT, text_source TEXT)"
+ )
+ db.executemany(
+ "INSERT INTO frames VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ [
+ (1, "2026-07-22T10:00:00", "Game", "Game", None, "AppSwitch", None, None),
+ (
+ 2,
+ "2026-07-22T10:00:05",
+ "Game",
+ "Game",
+ None,
+ "VisualChange",
+ " score\n 10 ",
+ "accessibility",
+ ),
+ ],
+ )
+ session = build_activity_sessions(db.execute("SELECT * FROM frames"))[0]
+ assert session["text"] == "score 10"
+ assert session["text_source"] == "accessibility"
+ assert session["representative_frame_id"] == 2
+ assert len(text_excerpt("x" * 3000)) == 2000
+
+
+def test_named_or_textual_changes_are_not_lost_to_time_debounce():
+ base = {
+ "captured_at": "2026-07-22T10:00:00Z",
+ "ended_at": "2026-07-22T10:00:00Z",
+ "frame_count": 1,
+ "app_name": "",
+ "window_name": "",
+ }
+ assert activity_is_salient({**base, "window_name": "A short-lived tab"})
+ assert activity_is_salient({**base, "text": "terminal output"})
+ assert not activity_is_salient(base)
+ assert activity_is_salient(
+ {
+ **base,
+ "ended_at": "2026-07-22T10:00:12Z",
+ "frame_count": 2,
+ }
+ )
+
+
def test_wav_duration_is_read_from_media(tmp_path: Path):
import wave
+
target = tmp_path / "sample.wav"
with wave.open(str(target), "wb") as audio:
audio.setnchannels(1)
@@ -54,3 +132,11 @@ def test_wav_duration_is_read_from_media(tmp_path: Path):
audio.setframerate(16000)
audio.writeframes(b"\0\0" * 8000)
assert audio_duration(target) == 0.5
+
+
+def test_collect_audio_accepts_screenpipe_startup_schema():
+ db = sqlite3.connect(":memory:")
+ db.row_factory = sqlite3.Row
+ db.execute("CREATE TABLE audio_chunks (placeholder TEXT)")
+ collector = object.__new__(Collector)
+ assert collector.collect_audio(db) == 0
diff --git a/extras/screenpipe-collector/tests/test_init.py b/extras/screenpipe-collector/tests/test_init.py
new file mode 100644
index 000000000..8ecafb45c
--- /dev/null
+++ b/extras/screenpipe-collector/tests/test_init.py
@@ -0,0 +1,19 @@
+import importlib.util
+from pathlib import Path
+
+
+INIT_PATH = Path(__file__).parents[1] / "init.py"
+SPEC = importlib.util.spec_from_file_location("screenpipe_collector_init", INIT_PATH)
+MODULE = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(MODULE)
+
+
+def test_screenpipe_unit_uses_privacy_defaults_and_api_auth(tmp_path, monkeypatch):
+ monkeypatch.setattr(MODULE, "SYSTEMD_USER_DIR", tmp_path)
+ path = MODULE.write_screenpipe_unit("/usr/bin/screenpipe", "local-key")
+ text = path.read_text()
+ assert "--audio-transcription-engine disabled" in text
+ assert "--disable-keyboard-capture" in text
+ assert "--disable-clipboard-capture" in text
+ assert "--api-auth true" in text
+ assert "Environment=SCREENPIPE_API_KEY=local-key" in text
diff --git a/extras/speaker-recognition/webui/chronicle-espresso-preset.js b/extras/speaker-recognition/webui/chronicle-espresso-preset.js
new file mode 100644
index 000000000..9f2456a87
--- /dev/null
+++ b/extras/speaker-recognition/webui/chronicle-espresso-preset.js
@@ -0,0 +1,107 @@
+/**
+ * Chronicle "Espresso" design-system preset (Tailwind v3).
+ *
+ * Source of truth: the Chronicle Design System project (claude.ai/design),
+ * tokens/*.css. Warm espresso neutrals + a terracotta brand ramp + a
+ * forest-green status family. Dark is the default surface.
+ *
+ * Applied by REMAPPING Tailwind's default color NAMES to the Espresso ramps,
+ * so the thousands of existing `bg-gray-800 / text-blue-600 / dark:*` utilities
+ * reskin with no per-component edits. Light->dark ordering is preserved, so
+ * every hand-paired `x-50 dark:x-900` keeps working.
+ *
+ * gray -> espresso neutrals blue -> terracotta (brand accent)
+ * green -> forest (success/verifier) red -> danger amber/yellow -> warning
+ * purple-> suggest orange -> clay sky/cyan -> info-blue
+ * aliases: emerald/teal/lime->forest, zinc/slate/neutral/stone->espresso,
+ * indigo/violet->purple, pink/rose->red
+ *
+ * IMPORTANT: keep this file identical to the copy in
+ * extras/speaker-recognition/webui/chronicle-espresso-preset.js.
+ * The two web UIs build in separate Docker contexts, so the preset is
+ * duplicated rather than shared via a package.
+ */
+
+// warm espresso neutrals (DS gray scale, extended to 950)
+const espresso = {
+ 50: '#f7f3ea', 100: '#f2ece2', 200: '#ddd5c6', 300: '#c9bfae', 400: '#948976',
+ 500: '#6b5f4f', 600: '#42392f', 700: '#2c251d', 800: '#211b15', 900: '#191410', 950: '#120d0a',
+}
+
+// brand terracotta (DS terracotta ramp; 50/100/200/800/950 interpolated)
+const terracotta = {
+ 50: '#fbeee7', 100: '#f7dccd', 200: '#f0c3ac', 300: '#ecab93', 400: '#e07856',
+ 500: '#d2694a', 600: '#c2551f', 700: '#a8471f', 800: '#7c351a', 900: '#3a1f14', 950: '#241009',
+}
+
+// forest green — positive / verifier / secondary (part of the palette)
+const forest = {
+ 50: '#eaf3ec', 100: '#d1e7d5', 200: '#a9cfb0', 300: '#8fc79a', 400: '#6f9a5f',
+ 500: '#4f7d54', 600: '#3f6b47', 700: '#34614a', 800: '#294a39', 900: '#1f3729', 950: '#132218',
+}
+
+// danger red (DS red ramp)
+const danger = {
+ 50: '#fbeae7', 100: '#f7d5cf', 200: '#f0b3a8', 300: '#eda093', 400: '#e8735f',
+ 500: '#dc4a3a', 600: '#c53a2b', 700: '#a32d20', 800: '#7d2419', 900: '#5a1a12', 950: '#360e09',
+}
+
+// warning amber (DS amber ramp) — shared by both `amber` and `yellow`
+const amber = {
+ 50: '#fcf4e1', 100: '#f8e7bb', 200: '#f2d488', 300: '#f0c674', 400: '#e6ad3f',
+ 500: '#d99521', 600: '#b8781a', 700: '#925c15', 800: '#6d4513', 900: '#4a2f0f', 950: '#2b1b08',
+}
+
+// warm clay (DS clay/apricot/ochre) — for `orange`
+const clay = {
+ 50: '#fbeee4', 100: '#f6dcc6', 200: '#efc39c', 300: '#e5a86f', 400: '#e59b52',
+ 500: '#d9822f', 600: '#c26a20', 700: '#9c521c', 800: '#743e18', 900: '#4c2911', 950: '#2c170a',
+}
+
+// suggest purple (DS purple)
+const suggest = {
+ 50: '#f4eef8', 100: '#e7d9ef', 200: '#d4bce2', 300: '#c2a0d4', 400: '#a986c4',
+ 500: '#9169b0', 600: '#775394', 700: '#5f4278', 800: '#48335c', 900: '#312340', 950: '#1d1526',
+}
+
+// muted info-blue (DS --info-fg family) — keeps a real "info" hue distinct from the brand
+const info = {
+ 50: '#eef3f7', 100: '#d7e3ec', 200: '#b5cbdd', 300: '#8fb0c9', 400: '#6b93b0',
+ 500: '#4f7896', 600: '#3f6079', 700: '#344e61', 800: '#2a3d4b', 900: '#1f2c37', 950: '#141d24',
+}
+
+/** @type {import('tailwindcss').Config} */
+export default {
+ theme: {
+ extend: {
+ colors: {
+ // neutrals
+ gray: espresso, zinc: espresso, slate: espresso, neutral: espresso, stone: espresso,
+ // brand
+ blue: terracotta, terracotta,
+ // status
+ green: forest, emerald: forest, teal: forest, lime: forest, forest,
+ red: danger, rose: danger, pink: danger,
+ amber, yellow: amber,
+ orange: clay, clay,
+ purple: suggest, violet: suggest, indigo: suggest,
+ sky: info, cyan: info, info,
+ },
+ fontFamily: {
+ sans: ['system-ui', '-apple-system', '"Segoe UI"', 'Roboto', 'Helvetica', 'Arial', 'sans-serif'],
+ mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', '"Liberation Mono"', 'monospace'],
+ },
+ borderRadius: {
+ sm: '0.25rem', // DS chips (4px)
+ },
+ boxShadow: {
+ // warm-tinted ambient elevation for the espresso surfaces
+ sm: '0 1px 2px 0 rgba(20,12,6,.40)',
+ DEFAULT: '0 2px 4px -1px rgba(20,12,6,.45)',
+ md: '0 6px 14px -4px rgba(20,12,6,.50)',
+ lg: '0 12px 28px -6px rgba(20,12,6,.55), 0 4px 10px -4px rgba(20,12,6,.45)',
+ xl: '0 14px 36px rgba(20,12,6,.60)',
+ },
+ },
+ },
+}
diff --git a/extras/speaker-recognition/webui/src/App.css b/extras/speaker-recognition/webui/src/App.css
deleted file mode 100644
index 4d216cad7..000000000
--- a/extras/speaker-recognition/webui/src/App.css
+++ /dev/null
@@ -1,98 +0,0 @@
-/* Theme variables */
-:root {
- /* Light mode colors */
- --color-bg-primary: #ffffff;
- --color-bg-secondary: #f9fafb;
- --color-bg-tertiary: #f3f4f6;
- --color-bg-hover: #f9fafb;
- --color-bg-disabled: #f9fafb;
-
- --color-text-primary: #111827;
- --color-text-secondary: #4b5563;
- --color-text-muted: #6b7280;
- --color-text-disabled: #9ca3af;
-
- --color-border: #e5e7eb;
- --color-border-hover: #d1d5db;
- --color-border-focus: #3b82f6;
-
- /* Special colors */
- --color-blue-bg: #eff6ff;
- --color-blue-border: #93c5fd;
-}
-
-.dark {
- /* Dark mode colors */
- --color-bg-primary: #1f2937;
- --color-bg-secondary: #111827;
- --color-bg-tertiary: #374151;
- --color-bg-hover: #374151;
- --color-bg-disabled: #111827;
-
- --color-text-primary: #f9fafb;
- --color-text-secondary: #d1d5db;
- --color-text-muted: #9ca3af;
- --color-text-disabled: #6b7280;
-
- --color-border: #4b5563;
- --color-border-hover: #6b7280;
- --color-border-focus: #3b82f6;
-
- /* Special colors */
- --color-blue-bg: #1e3a8a;
- --color-blue-border: #3b82f6;
-}
-
-/* Utility classes using CSS variables */
-.bg-primary { background-color: var(--color-bg-primary); }
-.bg-secondary { background-color: var(--color-bg-secondary); }
-.bg-tertiary { background-color: var(--color-bg-tertiary); }
-.bg-hover { background-color: var(--color-bg-hover); }
-.bg-disabled { background-color: var(--color-bg-disabled); }
-
-.text-primary { color: var(--color-text-primary); }
-.text-secondary { color: var(--color-text-secondary); }
-.text-muted { color: var(--color-text-muted); }
-.text-disabled { color: var(--color-text-disabled); }
-
-.border-default { border-color: var(--color-border); }
-.border-hover { border-color: var(--color-border-hover); }
-.border-focus { border-color: var(--color-border-focus); }
-
-/* Special backgrounds */
-.bg-blue-light { background-color: var(--color-blue-bg); }
-.border-blue { border-color: var(--color-blue-border); }
-
-/* Card styles */
-.card {
- background-color: var(--color-bg-primary);
- border: 1px solid var(--color-border);
- border-radius: 0.5rem;
-}
-
-.card-secondary {
- background-color: var(--color-bg-secondary);
- border-radius: 0.5rem;
-}
-
-/* Interactive styles */
-.hover-bg:hover { background-color: var(--color-bg-hover); }
-
-/* Typography */
-.heading-lg {
- font-size: 1.5rem;
- font-weight: 700;
- color: var(--color-text-primary);
-}
-
-.heading-md {
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--color-text-primary);
-}
-
-.heading-sm {
- font-size: 1.125rem;
- font-weight: 500;
- color: var(--color-text-primary);
-}
diff --git a/extras/speaker-recognition/webui/src/App.tsx b/extras/speaker-recognition/webui/src/App.tsx
index 1a2dfb705..378cda0fd 100644
--- a/extras/speaker-recognition/webui/src/App.tsx
+++ b/extras/speaker-recognition/webui/src/App.tsx
@@ -9,7 +9,6 @@ import Speakers from './pages/Speakers'
import Inference from './pages/Inference'
import InferLive from './pages/InferLive'
import InferLiveSimplified from './pages/InferLiveSimplified'
-import './App.css'
import { ThemeProvider } from './contexts/ThemeContext'
function App() {
diff --git a/extras/speaker-recognition/webui/src/components/AudioRecordingControls.tsx b/extras/speaker-recognition/webui/src/components/AudioRecordingControls.tsx
index adce39bd8..0384b5017 100644
--- a/extras/speaker-recognition/webui/src/components/AudioRecordingControls.tsx
+++ b/extras/speaker-recognition/webui/src/components/AudioRecordingControls.tsx
@@ -5,7 +5,7 @@
*/
import React from 'react'
-import { Mic, MicOff, Square, AlertCircle, CheckCircle, Clock } from 'lucide-react'
+import { Mic, MicOff, Square, AlertCircle, CheckCircle } from 'lucide-react'
import { UseAudioRecordingReturn } from '../hooks/useAudioRecording'
import { formatDuration } from '../utils/audioUtils'
@@ -74,7 +74,7 @@ export const AudioRecordingControls: React.FC = ({
if (!processedAudio?.quality) return null
const { level, snr } = processedAudio.quality
- const colors = {
+ const colors: Record = {
excellent: 'bg-green-100 text-green-800',
good: 'bg-blue-100 text-blue-800',
fair: 'bg-yellow-100 text-yellow-800',
diff --git a/extras/speaker-recognition/webui/src/components/EmbeddingPlot.tsx b/extras/speaker-recognition/webui/src/components/EmbeddingPlot.tsx
index 4241e0ec6..b258b5c40 100644
--- a/extras/speaker-recognition/webui/src/components/EmbeddingPlot.tsx
+++ b/extras/speaker-recognition/webui/src/components/EmbeddingPlot.tsx
@@ -47,6 +47,8 @@ interface AnalysisData {
enrolled_speakers?: number
expected_speakers?: number
analysis_type?: string
+ unique_speakers?: string[]
+ total_duration?: number
}
smart_suggestion?: {
suggested_threshold: number
@@ -102,7 +104,6 @@ export default function EmbeddingPlot({
compact = false,
title,
autoAnalyze = true,
- onRefresh,
onAnalysisComplete
}: EmbeddingPlotProps) {
const plotRef = useRef(null)
diff --git a/extras/speaker-recognition/webui/src/components/LiveAudioCapture.tsx b/extras/speaker-recognition/webui/src/components/LiveAudioCapture.tsx
index 50974df3e..451c10b11 100644
--- a/extras/speaker-recognition/webui/src/components/LiveAudioCapture.tsx
+++ b/extras/speaker-recognition/webui/src/components/LiveAudioCapture.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useRef, useCallback, useEffect } from 'react'
+import { useState, useRef, useCallback, useEffect } from 'react'
import { Mic, MicOff, Square, Play, Pause, Volume2, Settings } from 'lucide-react'
interface AudioCaptureConfig {
@@ -378,11 +378,11 @@ export default function LiveAudioCapture({
setAudioLevel(average / 255)
// Draw waveform
- ctx.fillStyle = '#f3f4f6'
+ ctx.fillStyle = '#f2ece2'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.lineWidth = 2
- ctx.strokeStyle = status === 'recording' ? '#3b82f6' : '#6b7280'
+ ctx.strokeStyle = status === 'recording' ? '#d2694a' : '#6b5f4f'
ctx.beginPath()
const sliceWidth = canvas.width / bufferLength
@@ -515,7 +515,8 @@ export default function LiveAudioCapture({
{status === 'idle' || status === 'error' ? (
diff --git a/extras/speaker-recognition/webui/src/components/SettingsPanel.tsx b/extras/speaker-recognition/webui/src/components/SettingsPanel.tsx
index 4c3e1d394..7318e2f4e 100644
--- a/extras/speaker-recognition/webui/src/components/SettingsPanel.tsx
+++ b/extras/speaker-recognition/webui/src/components/SettingsPanel.tsx
@@ -5,7 +5,7 @@
*/
import React, { useState } from 'react'
-import { Settings, Eye, EyeOff, Key, Volume2, Users, AlertCircle, Info } from 'lucide-react'
+import { Settings, Eye, EyeOff, Key, Volume2, Users, Info } from 'lucide-react'
export interface SettingsPanelProps {
// Confidence threshold
diff --git a/extras/speaker-recognition/webui/src/components/SpeakerResultsDisplay.tsx b/extras/speaker-recognition/webui/src/components/SpeakerResultsDisplay.tsx
index 08996ad2c..2b4a765bd 100644
--- a/extras/speaker-recognition/webui/src/components/SpeakerResultsDisplay.tsx
+++ b/extras/speaker-recognition/webui/src/components/SpeakerResultsDisplay.tsx
@@ -5,7 +5,7 @@
*/
import React from 'react'
-import { Users, Clock, Download, Play, Pause, CheckCircle, AlertTriangle, Info } from 'lucide-react'
+import { Users, Clock, Download, Play, AlertTriangle } from 'lucide-react'
import { ProcessingResult, SpeakerSegment } from '../services/speakerIdentification'
import { TranscriptSegment } from '../hooks/useDeepgramIntegration'
@@ -83,9 +83,9 @@ export const SpeakerResultsDisplay: React.FC = ({
const isLiveSegment = 'isInterim' in segment
// Extract common fields
- const start = segment.start || 0
+ const start = (segment as SpeakerSegment).start || 0
const end = isProcessingSegment
- ? segment.end
+ ? (segment as SpeakerSegment).end
: start // For live segments, we don't have a proper end time yet
const duration = isProcessingSegment ? (end - start) : 0 // Only calculate duration for processed segments
const text = isProcessingSegment ? (segment as SpeakerSegment).text : (segment as TranscriptSegment).text
diff --git a/extras/speaker-recognition/webui/src/components/WaveformPlot.tsx b/extras/speaker-recognition/webui/src/components/WaveformPlot.tsx
index 4b299ce9e..1ec45d5db 100644
--- a/extras/speaker-recognition/webui/src/components/WaveformPlot.tsx
+++ b/extras/speaker-recognition/webui/src/components/WaveformPlot.tsx
@@ -45,7 +45,7 @@ export default function WaveformPlot({
type: 'scatter',
mode: 'lines',
name: 'Waveform',
- line: { color: '#1f77b4', width: 1 },
+ line: { color: '#c2551f', width: 1 },
hovertemplate: 'Time: %{x:.2f}s Amplitude: %{y:.3f} '
})
diff --git a/extras/speaker-recognition/webui/src/components/live-inference/ApiKeyConfiguration.tsx b/extras/speaker-recognition/webui/src/components/live-inference/ApiKeyConfiguration.tsx
index f78a0d349..f6754ad54 100644
--- a/extras/speaker-recognition/webui/src/components/live-inference/ApiKeyConfiguration.tsx
+++ b/extras/speaker-recognition/webui/src/components/live-inference/ApiKeyConfiguration.tsx
@@ -2,7 +2,6 @@
* Component for API key configuration when manual entry is required
*/
-import React from 'react'
import { AlertCircle } from 'lucide-react'
export interface ApiKeyConfigurationProps {
diff --git a/extras/speaker-recognition/webui/src/components/live-inference/ErrorDisplay.tsx b/extras/speaker-recognition/webui/src/components/live-inference/ErrorDisplay.tsx
index e62eb6fd1..87ed57f13 100644
--- a/extras/speaker-recognition/webui/src/components/live-inference/ErrorDisplay.tsx
+++ b/extras/speaker-recognition/webui/src/components/live-inference/ErrorDisplay.tsx
@@ -2,7 +2,6 @@
* Component for displaying session errors with helpful tips
*/
-import React from 'react'
import { AlertCircle } from 'lucide-react'
export interface ErrorDisplayProps {
diff --git a/extras/speaker-recognition/webui/src/components/live-inference/LiveTranscript.tsx b/extras/speaker-recognition/webui/src/components/live-inference/LiveTranscript.tsx
index 198b20ba6..dd0845188 100644
--- a/extras/speaker-recognition/webui/src/components/live-inference/LiveTranscript.tsx
+++ b/extras/speaker-recognition/webui/src/components/live-inference/LiveTranscript.tsx
@@ -2,7 +2,7 @@
* Component for displaying live transcript with speaker identification
*/
-import React, { useRef, useEffect } from 'react'
+import { useRef, useEffect } from 'react'
import { Mic } from 'lucide-react'
export interface SpeakerPart {
diff --git a/extras/speaker-recognition/webui/src/components/live-inference/RecordingControls.tsx b/extras/speaker-recognition/webui/src/components/live-inference/RecordingControls.tsx
index 501b57468..f0d5e19fb 100644
--- a/extras/speaker-recognition/webui/src/components/live-inference/RecordingControls.tsx
+++ b/extras/speaker-recognition/webui/src/components/live-inference/RecordingControls.tsx
@@ -2,7 +2,6 @@
* Component for recording controls and status display
*/
-import React from 'react'
import { Mic } from 'lucide-react'
export type DeepgramStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
diff --git a/extras/speaker-recognition/webui/src/components/live-inference/SessionStats.tsx b/extras/speaker-recognition/webui/src/components/live-inference/SessionStats.tsx
index 7014dea37..948dee386 100644
--- a/extras/speaker-recognition/webui/src/components/live-inference/SessionStats.tsx
+++ b/extras/speaker-recognition/webui/src/components/live-inference/SessionStats.tsx
@@ -2,7 +2,6 @@
* Component for displaying live session statistics
*/
-import React from 'react'
import { Clock, Volume2, Users } from 'lucide-react'
import { formatDuration } from '../../utils/common'
diff --git a/extras/speaker-recognition/webui/src/hooks/useAudioRecording.ts b/extras/speaker-recognition/webui/src/hooks/useAudioRecording.ts
index 3e500053b..6c5991c91 100644
--- a/extras/speaker-recognition/webui/src/hooks/useAudioRecording.ts
+++ b/extras/speaker-recognition/webui/src/hooks/useAudioRecording.ts
@@ -39,7 +39,6 @@ export const useAudioRecording = (options: UseAudioRecordingOptions = {}): UseAu
const {
sampleRate = 16000,
channels = 1,
- bufferSize = 4096,
autoProcess = true,
maxDuration = 300, // 5 minutes default
onAudioProcessed,
@@ -184,13 +183,13 @@ export const useAudioRecording = (options: UseAudioRecordingOptions = {}): UseAu
await processRecordingBlob(blob)
}
} catch (error) {
- const errorMsg = `Failed to process recording: ${error.message}`
+ const errorMsg = `Failed to process recording: ${error instanceof Error ? error.message : String(error)}`
setRecordingState(prev => ({ ...prev, status: 'error', error: errorMsg }))
onError?.(errorMsg)
}
}
- mediaRecorder.onerror = (event) => {
+ mediaRecorder.onerror = (_event) => {
const errorMsg = 'Recording failed. Please try again.'
setRecordingState(prev => ({ ...prev, status: 'error', error: errorMsg }))
onError?.(errorMsg)
@@ -212,7 +211,7 @@ export const useAudioRecording = (options: UseAudioRecordingOptions = {}): UseAu
return processed
} catch (error) {
- const errorMsg = `Failed to process recording: ${error.message}`
+ const errorMsg = `Failed to process recording: ${error instanceof Error ? error.message : String(error)}`
setRecordingState(prev => ({ ...prev, status: 'error', error: errorMsg }))
onError?.(errorMsg)
return null
diff --git a/extras/speaker-recognition/webui/src/hooks/useDeepgramIntegration.ts b/extras/speaker-recognition/webui/src/hooks/useDeepgramIntegration.ts
index f07fd3909..f8665a986 100644
--- a/extras/speaker-recognition/webui/src/hooks/useDeepgramIntegration.ts
+++ b/extras/speaker-recognition/webui/src/hooks/useDeepgramIntegration.ts
@@ -175,7 +175,7 @@ export const useDeepgramIntegration = (
// Speaker identification for utterances
const identifyUtteranceSpeaker = useCallback(async (
- utteranceBuffer: Float32Array,
+ _utteranceBuffer: Float32Array,
utteranceStartTime: number,
utteranceEndTime: number
): Promise => {
@@ -459,7 +459,7 @@ export const useDeepgramIntegration = (
}, [])
// Send audio data
- const sendAudio = useCallback((audioData: ArrayBuffer, sampleRate?: number) => {
+ const sendAudio = useCallback((audioData: ArrayBuffer, _sampleRate?: number) => {
if (deepgramRef.current && isConnected && isStreaming) {
deepgramRef.current.sendAudio(audioData)
diff --git a/extras/speaker-recognition/webui/src/hooks/useSpeakerIdentification.ts b/extras/speaker-recognition/webui/src/hooks/useSpeakerIdentification.ts
index 572bf84d0..9fd654079 100644
--- a/extras/speaker-recognition/webui/src/hooks/useSpeakerIdentification.ts
+++ b/extras/speaker-recognition/webui/src/hooks/useSpeakerIdentification.ts
@@ -49,7 +49,7 @@ export interface UseSpeakerIdentificationReturn {
// Controls
setProcessingMode: (mode: ProcessingMode) => void
setConfidenceThreshold: (threshold: number) => void
- processAudio: (audio: ProcessedAudio, mode?: ProcessingMode) => Promise
+ processAudio: (audio: ProcessedAudio, modeOrOptions?: ProcessingMode | Partial) => Promise
selectResult: (result: ProcessingResult | null) => void
clearResults: () => void
exportResult: (result: ProcessingResult) => void
diff --git a/extras/speaker-recognition/webui/src/hooks/useSpeakerWebSocket.ts b/extras/speaker-recognition/webui/src/hooks/useSpeakerWebSocket.ts
index 79c6f2364..0685ddb89 100644
--- a/extras/speaker-recognition/webui/src/hooks/useSpeakerWebSocket.ts
+++ b/extras/speaker-recognition/webui/src/hooks/useSpeakerWebSocket.ts
@@ -210,7 +210,7 @@ export const useSpeakerWebSocket = (
// Start streaming
const startStreaming = useCallback(() => {
- const serviceConnected = wsServiceRef.current?.connectionStatus === 'connected'
+ const serviceConnected = wsServiceRef.current?.status === 'connected'
console.log(`🎙️ [WS] Attempting to start streaming - React isConnected: ${isConnected}, Service connected: ${serviceConnected}`)
// Check WebSocket service state directly to avoid React state timing issues
diff --git a/extras/speaker-recognition/webui/src/index.css b/extras/speaker-recognition/webui/src/index.css
index 333be7bdf..ca126564d 100644
--- a/extras/speaker-recognition/webui/src/index.css
+++ b/extras/speaker-recognition/webui/src/index.css
@@ -2,7 +2,7 @@
@layer base {
html {
- font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
diff --git a/extras/speaker-recognition/webui/src/pages/Annotation.tsx b/extras/speaker-recognition/webui/src/pages/Annotation.tsx
index 3751c8356..88b374345 100644
--- a/extras/speaker-recognition/webui/src/pages/Annotation.tsx
+++ b/extras/speaker-recognition/webui/src/pages/Annotation.tsx
@@ -14,17 +14,11 @@ import {
} from '../utils/audioUtils'
import { databaseService } from '../services/database'
import { apiService, type Annotation } from '../services/api'
-import {
- transcribeWithDeepgram,
- convertToAnnotationSegments,
- DEFAULT_DEEPGRAM_OPTIONS
-} from '../services/deepgram'
import { speakerIdentificationService } from '../services/speakerIdentification'
import FileUploader from '../components/FileUploader'
import WaveformPlot from '../components/WaveformPlot'
import EmbeddingPlot from '../components/EmbeddingPlot'
import ProcessingModeSelector from '../components/ProcessingModeSelector'
-import { audioProcessingService } from '../services/audioProcessing'
import { useSpeakerIdentification } from '../hooks/useSpeakerIdentification'
interface AudioData {
@@ -60,7 +54,6 @@ export default function Annotation() {
// Add speaker processing hook for unified processing modes
const speakerProcessing = useSpeakerIdentification({
defaultMode: 'speaker-identification',
- userId: user?.id,
onError: (error) => console.error('Processing error:', error),
})
const [availableSpeakers, setAvailableSpeakers] = useState([
@@ -72,7 +65,7 @@ export default function Annotation() {
const [playingSegmentId, setPlayingSegmentId] = useState(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [enrollingSpeaker, setEnrollingSpeaker] = useState(null)
- const [deepgramResponse, setDeepgramResponse] = useState(null)
+ const [deepgramResponse] = useState(null)
const [showJsonOutput, setShowJsonOutput] = useState(false)
const [minSpeakers, setMinSpeakers] = useState(1)
const [maxSpeakers, setMaxSpeakers] = useState(4)
@@ -196,19 +189,6 @@ export default function Annotation() {
if (!audioData || !user) return
try {
- // Convert audioData to ProcessedAudio format expected by speaker processing
- const processedAudio = {
- file: audioData.file, // <-- This was missing!
- filename: audioData.file.name,
- buffer: {
- samples: audioData.samples,
- sampleRate: audioData.buffer.sampleRate,
- channels: audioData.buffer.numberOfChannels,
- duration: audioData.buffer.duration
- },
- quality: null // Not needed for annotation processing
- }
-
// Validate requirements for diarize-identify-match mode
if (mode === 'diarize-identify-match' && !uploadedJson) {
alert('Please upload a transcript JSON file first to use Transcript + Diarize mode')
@@ -258,7 +238,7 @@ export default function Annotation() {
if (uniqueSpeakerLabels.size > 0) {
setAvailableSpeakers(prev => [
...prev,
- ...Array.from(uniqueSpeakerLabels).map(label => ({ label }))
+ ...Array.from(uniqueSpeakerLabels).map(label => ({ label: label as string }))
])
}
@@ -323,7 +303,7 @@ export default function Annotation() {
if (uniqueSpeakerLabels.size > 0) {
setAvailableSpeakers(prev => [
...prev,
- ...Array.from(uniqueSpeakerLabels).map(label => ({ label }))
+ ...Array.from(uniqueSpeakerLabels).map(label => ({ label: label as string }))
])
}
}
@@ -823,6 +803,7 @@ export default function Annotation() {
onModeChange={speakerProcessing.setProcessingMode}
onProcessAudio={handleProcessAudio}
audioData={{
+ file: audioData.file,
filename: audioData.file.name,
buffer: {
samples: audioData.samples,
@@ -885,24 +866,11 @@ export default function Annotation() {
+ {/* WaveformPlot renders the waveform only; segment overlay/selection is
+ handled by the segment list below (these props were never consumed). */}
s.id === selectedSegmentId) ?
- [segments.find(s => s.id === selectedSegmentId)!.start, segments.find(s => s.id === selectedSegmentId)!.end] : null
- : null}
- onSegmentSelect={(segment) => {
- // Find closest segment to selection
- const midpoint = (segment[0] + segment[1]) / 2
- const closest = segments.reduce((prev, curr) => {
- const prevDist = Math.abs((prev.start + prev.end) / 2 - midpoint)
- const currDist = Math.abs((curr.start + curr.end) / 2 - midpoint)
- return prevDist < currDist ? prev : curr
- })
- setSelectedSegmentId(closest?.id || null)
- }}
/>
diff --git a/extras/speaker-recognition/webui/src/pages/AudioViewer.tsx b/extras/speaker-recognition/webui/src/pages/AudioViewer.tsx
index c97943055..772db329b 100644
--- a/extras/speaker-recognition/webui/src/pages/AudioViewer.tsx
+++ b/extras/speaker-recognition/webui/src/pages/AudioViewer.tsx
@@ -1,5 +1,5 @@
import { useState, useRef, useCallback, useEffect } from 'react'
-import { Play, Pause, Download, Volume2 } from 'lucide-react'
+import { Play, Pause, Download } from 'lucide-react'
import { useUser } from '../contexts/UserContext'
import { calculateFileHash, isAudioFile } from '../utils/fileHash'
import {
diff --git a/extras/speaker-recognition/webui/src/pages/Enrollment.tsx b/extras/speaker-recognition/webui/src/pages/Enrollment.tsx
index 07e4ab3c0..dc180b367 100644
--- a/extras/speaker-recognition/webui/src/pages/Enrollment.tsx
+++ b/extras/speaker-recognition/webui/src/pages/Enrollment.tsx
@@ -1,7 +1,6 @@
import { useState, useRef, useCallback, useEffect } from 'react'
-import { Mic, MicOff, Upload, Play, Pause, Save, Trash2, CheckCircle, AlertCircle } from 'lucide-react'
+import { Mic, MicOff, Play, Pause, Save, Trash2, CheckCircle, AlertCircle } from 'lucide-react'
import { useUser } from '../contexts/UserContext'
-import { calculateFileHash, isAudioFile } from '../utils/fileHash'
import {
loadAudioBuffer,
createAudioContext,
@@ -9,7 +8,6 @@ import {
extractAudioSamples,
calculateSNR,
formatDuration,
- createAudioBlob,
convertBlobToWav
} from '../utils/audioUtils'
import { apiService } from '../services/api'
@@ -202,12 +200,13 @@ export default function Enrollment() {
recordingIntervalRef.current = null
}
+ const err = error as DOMException
let errorMessage = 'Failed to access microphone. '
- if (error.name === 'NotAllowedError') {
+ if (err.name === 'NotAllowedError') {
errorMessage += 'Please allow microphone access and try again.'
- } else if (error.name === 'NotFoundError') {
+ } else if (err.name === 'NotFoundError') {
errorMessage += 'No microphone found. Please check your device.'
- } else if (error.name === 'NotSupportedError') {
+ } else if (err.name === 'NotSupportedError') {
errorMessage += 'Recording not supported in this browser.'
} else {
errorMessage += 'Please check permissions and try again.'
@@ -297,12 +296,13 @@ export default function Enrollment() {
} catch (error) {
console.error('Failed to process recording:', error)
+ const err = error as DOMException
let errorMessage = 'Failed to process recording. '
- if (error.name === 'EncodingError' || error.message.includes('decode')) {
+ if (err.name === 'EncodingError' || err.message.includes('decode')) {
errorMessage += 'Audio format not supported. Try using a different browser or check your microphone settings.'
- } else if (error.message.includes('context')) {
+ } else if (err.message.includes('context')) {
errorMessage += 'Audio processing failed. Please try again.'
- } else if (error.message.includes('conversion')) {
+ } else if (err.message.includes('conversion')) {
errorMessage += 'Audio conversion failed. Please try again or use a different browser.'
} else {
errorMessage += 'Please try again or refresh the page.'
diff --git a/extras/speaker-recognition/webui/src/pages/EnrollmentHealth.tsx b/extras/speaker-recognition/webui/src/pages/EnrollmentHealth.tsx
index ee46ca9a8..613ebdba0 100644
--- a/extras/speaker-recognition/webui/src/pages/EnrollmentHealth.tsx
+++ b/extras/speaker-recognition/webui/src/pages/EnrollmentHealth.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import {
ShieldCheck, ShieldAlert, ChevronDown, ChevronRight,
RefreshCw, Archive, ArrowRightLeft, HelpCircle, Database,
+ Check, Undo2,
} from 'lucide-react'
import { useUser } from '../contexts/UserContext'
import { apiService } from '../services/api'
@@ -16,6 +17,8 @@ interface Clip {
self_score: number | null
best_other: BestOther | null
flags: string[]
+ heuristic_flags: string[]
+ review_state: 'confirmed_correct' | null
suggested: Suggested | null
}
interface SpeakerHealth {
@@ -169,6 +172,18 @@ export default function EnrollmentHealth() {
}
}
+ const reviewFlag = async (segmentId: number, decision: 'confirmed_correct' | 'reset') => {
+ setBusy(segmentId)
+ try {
+ await apiService.post(`/enrollment/segments/${segmentId}/audit-review`, { decision })
+ await load()
+ } catch (e: any) {
+ alert(e?.response?.data?.detail || 'Could not save enrollment review')
+ } finally {
+ setBusy(null)
+ }
+ }
+
const totalFlagged = report?.speakers.reduce((a, s) => a + s.n_flagged, 0) ?? 0
const contaminated = report?.speakers.filter(s => s.verdict === 'contaminated').length ?? 0
@@ -182,7 +197,7 @@ export default function EnrollmentHealth() {
Finds mislabeled, contaminated, and junk enrolled clips from per-clip embeddings.
- Relabel a clip to who it really sounds like, or delete it — the speaker voiceprint is recomputed.
+ Relabel or quarantine bad evidence, or mark a correct clip so the same heuristic warning stays resolved.
(
{f}
))}
+ {clip.review_state === 'confirmed_correct' && (
+
+ confirmed correct
+
+ )}
self
@@ -297,6 +317,26 @@ export default function EnrollmentHealth() {
src={`/api/enrollment/segments/${clip.segment_id}/audio`} />
+ {clip.flags.length > 0 && (
+ reviewFlag(clip.segment_id, 'confirmed_correct')}
+ className="text-xs flex items-center gap-1 px-2 py-1 border border-green-300 dark:border-green-700 text-green-700 dark:text-green-300 rounded hover:bg-green-50 dark:hover:bg-green-900/20 disabled:opacity-50"
+ title={`Keep this clip assigned to ${spk.name} and resolve its warning`}
+ >
+ Looks correct
+
+ )}
+ {clip.review_state === 'confirmed_correct' && (
+ reviewFlag(clip.segment_id, 'reset')}
+ className="text-xs flex items-center gap-1 px-2 py-1 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
+ title="Restore automatic enrollment-health warnings for this clip"
+ >
+ Undo review
+
+ )}
{clip.suggested && (
console.error('Recording error:', error),
- onRecordingStart: () => setUploadedAudio(null), // Clear uploaded audio when recording starts
})
const speakerProcessing = useSpeakerIdentification({
defaultMode: 'speaker-identification',
- userId: user?.id,
onError: (error) => console.error('Processing error:', error),
})
diff --git a/extras/speaker-recognition/webui/src/pages/Speakers.tsx b/extras/speaker-recognition/webui/src/pages/Speakers.tsx
index 76be2d2e6..d36ef79d6 100644
--- a/extras/speaker-recognition/webui/src/pages/Speakers.tsx
+++ b/extras/speaker-recognition/webui/src/pages/Speakers.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
-import { Search, Download, Trash2, Eye, BarChart3, User, Clock, CheckCircle, XCircle, Upload, FileJson } from 'lucide-react'
+import { Search, Download, Trash2, Eye, User, Clock, CheckCircle, XCircle, Upload, FileJson } from 'lucide-react'
import { useUser } from '../contexts/UserContext'
import { apiService } from '../services/api'
import { formatDuration } from '../utils/audioUtils'
@@ -80,8 +80,8 @@ export default function Speakers() {
// Calculate stats from filtered speakers
const stats = {
total_speakers: userSpeakers.length,
- total_audio_samples: userSpeakers.reduce((sum, s) => sum + (s.audio_sample_count || 0), 0),
- total_duration: userSpeakers.reduce((sum, s) => sum + (s.total_audio_duration || 0), 0),
+ total_audio_samples: userSpeakers.reduce((sum: number, s: any) => sum + (s.audio_sample_count || 0), 0),
+ total_duration: userSpeakers.reduce((sum: number, s: any) => sum + (s.total_audio_duration || 0), 0),
average_quality: 0, // Not available from backend yet
speakers_by_status: {
pending: 0,
@@ -124,8 +124,8 @@ export default function Speakers() {
})
filtered.sort((a, b) => {
- let aValue = a[sortBy]
- let bValue = b[sortBy]
+ let aValue: any = a[sortBy]
+ let bValue: any = b[sortBy]
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase()
@@ -610,15 +610,15 @@ export default function Speakers() {
Created
-
{new Date(selectedSpeaker.created_at).toLocaleString()}
+
{new Date(selectedSpeaker.created_at as string).toLocaleString()}
Last Updated
-
{new Date(selectedSpeaker.updated_at).toLocaleString()}
+
{new Date(selectedSpeaker.updated_at as string).toLocaleString()}
Last Enrollment
-
{new Date(selectedSpeaker.last_enrollment).toLocaleString()}
+
{new Date(selectedSpeaker.last_enrollment as string).toLocaleString()}
diff --git a/extras/speaker-recognition/webui/src/services/audioProcessing.ts b/extras/speaker-recognition/webui/src/services/audioProcessing.ts
index df03aeb2d..a496eff6f 100644
--- a/extras/speaker-recognition/webui/src/services/audioProcessing.ts
+++ b/extras/speaker-recognition/webui/src/services/audioProcessing.ts
@@ -4,9 +4,10 @@
* used across Inference, InferLive, and Speakers pages
*/
-import { createWAVHeader, createWAVBlob, concatenateAudioBuffers, extractAudioSegmentFromBuffers } from '../utils/audioUtils'
+import { createWAVBlob, extractAudioSegmentFromBuffers } from '../utils/audioUtils'
-export interface AudioBuffer {
+// Named distinctly to avoid shadowing the DOM `AudioBuffer` lib type used below.
+export interface ProcessedAudioBuffer {
samples: Float32Array
sampleRate: number
channels: number
@@ -25,7 +26,7 @@ export interface AudioSegmentInfo {
export interface ProcessedAudio {
file: File | Blob
filename: string
- buffer: AudioBuffer
+ buffer: ProcessedAudioBuffer
quality?: {
snr: number
level: string
@@ -52,8 +53,6 @@ export class AudioProcessingService {
// Audio processing constants
private readonly SUPPORTED_FORMATS = ['audio/wav', 'audio/webm', 'audio/mp4']
private readonly TARGET_SAMPLE_RATE = 16000
- private readonly BUFFER_DURATION_MS = 256 // Each buffer represents ~256ms
- private readonly MAX_BUFFERS = 750 // 30 seconds at 4096 samples per 250ms
static getInstance(): AudioProcessingService {
if (!AudioProcessingService.instance) {
@@ -97,7 +96,7 @@ export class AudioProcessingService {
return processed
} catch (error) {
- throw new Error(`Failed to process audio file: ${error.message}`)
+ throw new Error(`Failed to process audio file: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -116,7 +115,7 @@ export class AudioProcessingService {
const file = new File([processedBlob], `${filename}.wav`, { type: 'audio/wav' })
return await this.processAudioFile(file)
} catch (error) {
- throw new Error(`Failed to process recording: ${error.message}`)
+ throw new Error(`Failed to process recording: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -134,7 +133,7 @@ export class AudioProcessingService {
audioBuffers: Float32Array[],
utteranceStartTime: number,
utteranceEndTime: number,
- streamStartTime?: number,
+ _streamStartTime?: number,
sampleRate: number = this.TARGET_SAMPLE_RATE
): UtteranceExtractionResult {
try {
@@ -201,7 +200,7 @@ export class AudioProcessingService {
audioBuffer: new Float32Array(0),
duration: 0,
isValid: false,
- error: `Audio extraction failed: ${error.message}`
+ error: `Audio extraction failed: ${error instanceof Error ? error.message : String(error)}`
}
}
}
diff --git a/extras/speaker-recognition/webui/src/services/deepgram.ts b/extras/speaker-recognition/webui/src/services/deepgram.ts
index 14c7840e7..d8d0ef40d 100644
--- a/extras/speaker-recognition/webui/src/services/deepgram.ts
+++ b/extras/speaker-recognition/webui/src/services/deepgram.ts
@@ -595,7 +595,7 @@ export class DeepgramStreaming {
/**
* Utility function to convert audio samples to the format expected by Deepgram
*/
-export function convertAudioForDeepgram(audioBuffer: Float32Array, sampleRate: number = 16000): ArrayBuffer {
+export function convertAudioForDeepgram(audioBuffer: Float32Array, _sampleRate: number = 16000): ArrayBuffer {
// Convert float32 samples to int16
const int16Array = new Int16Array(audioBuffer.length)
diff --git a/extras/speaker-recognition/webui/src/services/speakerIdentification.ts b/extras/speaker-recognition/webui/src/services/speakerIdentification.ts
index 5c1bb4d04..cf830237b 100644
--- a/extras/speaker-recognition/webui/src/services/speakerIdentification.ts
+++ b/extras/speaker-recognition/webui/src/services/speakerIdentification.ts
@@ -111,19 +111,20 @@ export class SpeakerIdentificationService {
} catch (error) {
// Provide more helpful error messages based on the error type
- let errorMessage = `Processing failed: ${error instanceof Error ? error.message : 'Unknown error'}`
+ const rawMessage = error instanceof Error ? error.message : String(error)
+ let errorMessage = `Processing failed: ${rawMessage}`
- if (error.message?.includes('500') || error.message?.includes('Internal Server Error')) {
+ if (rawMessage.includes('500') || rawMessage.includes('Internal Server Error')) {
errorMessage = `Server error during ${options.mode} processing. This might be due to a backend issue. Please try again or contact support.`
- } else if (error.message?.includes('404') || error.message?.includes('Not Found')) {
+ } else if (rawMessage.includes('404') || rawMessage.includes('Not Found')) {
errorMessage = `Processing endpoint not available. The ${options.mode} mode might not be fully implemented yet.`
- } else if (error.message?.includes('400') || error.message?.includes('Bad Request')) {
+ } else if (rawMessage.includes('400') || rawMessage.includes('Bad Request')) {
errorMessage = `Bad request during ${options.mode} processing. This might be due to invalid audio format or missing transcript data. Please check your input files.`
- } else if (error.message?.includes('timeout')) {
+ } else if (rawMessage.includes('timeout')) {
errorMessage = `Processing timed out. The audio file might be too large or the server is busy. Please try a shorter audio file.`
- } else if (error.message?.includes('transcript data is required')) {
+ } else if (rawMessage.includes('transcript data is required')) {
errorMessage = `Transcript data is required for ${options.mode} mode. Please upload a Deepgram JSON file first.`
- } else if (error.message?.includes('Failed to transform Deepgram data')) {
+ } else if (rawMessage.includes('Failed to transform Deepgram data')) {
errorMessage = `Invalid Deepgram JSON format. Please ensure you've uploaded a valid Deepgram API response file.`
}
@@ -216,7 +217,7 @@ export class SpeakerIdentificationService {
deepgram_response: deepgramResponse
}
} catch (error) {
- throw new Error(`Deepgram processing failed: ${error.message}`)
+ throw new Error(`Deepgram processing failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -308,7 +309,7 @@ export class SpeakerIdentificationService {
deepgram_response: deepgramResponse
}
} catch (error) {
- throw new Error(`Hybrid processing failed: ${error.message}`)
+ throw new Error(`Hybrid processing failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -368,7 +369,7 @@ export class SpeakerIdentificationService {
}
}
} catch (error) {
- throw new Error(`Diarization-only processing failed: ${error.message}`)
+ throw new Error(`Diarization-only processing failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -442,7 +443,7 @@ export class SpeakerIdentificationService {
}
}
} catch (error) {
- throw new Error(`Diarization processing failed: ${error.message}`)
+ throw new Error(`Diarization processing failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -529,7 +530,7 @@ export class SpeakerIdentificationService {
}
}
} catch (error) {
- throw new Error(`Diarize-identify-match processing failed: ${error.message}`)
+ throw new Error(`Diarize-identify-match processing failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
diff --git a/extras/speaker-recognition/webui/src/services/speakerWebSocket.ts b/extras/speaker-recognition/webui/src/services/speakerWebSocket.ts
index 676eb51e1..eb41e2966 100644
--- a/extras/speaker-recognition/webui/src/services/speakerWebSocket.ts
+++ b/extras/speaker-recognition/webui/src/services/speakerWebSocket.ts
@@ -90,7 +90,6 @@ export class SpeakerWebSocketService {
private options: SpeakerWebSocketOptions
private connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error' = 'disconnected'
private baseUrl: string
- private fallbackUrls: string[] = []
constructor(options: SpeakerWebSocketOptions = {}) {
this.options = options
diff --git a/extras/speaker-recognition/webui/src/styles/components.css b/extras/speaker-recognition/webui/src/styles/components.css
index 9ffd258e0..aad0bb213 100644
--- a/extras/speaker-recognition/webui/src/styles/components.css
+++ b/extras/speaker-recognition/webui/src/styles/components.css
@@ -2,17 +2,11 @@
@tailwind components;
@tailwind utilities;
-:root {
- --bg: #fff;
- --text: #222;
-}
-[data-theme="dark"] {
- --bg: #222;
- --text: #fff;
-}
-body {
- background: var(--bg);
- color: var(--text);
+@layer base {
+ /* espresso page surface — driven by the `.dark` class the app actually sets */
+ body {
+ @apply bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100;
+ }
}
@layer components {
diff --git a/extras/speaker-recognition/webui/src/utils/index.ts b/extras/speaker-recognition/webui/src/utils/index.ts
index 1cfe8bff8..023cc2a33 100644
--- a/extras/speaker-recognition/webui/src/utils/index.ts
+++ b/extras/speaker-recognition/webui/src/utils/index.ts
@@ -5,3 +5,7 @@
export * from './logger'
export * from './audioUtils'
export * from './common'
+
+// Both audioUtils and common export `formatDuration` (audioUtils' is @deprecated).
+// Explicitly re-export the intended (common) version to resolve the star-export ambiguity.
+export { formatDuration } from './common'
diff --git a/extras/speaker-recognition/webui/tailwind.config.js b/extras/speaker-recognition/webui/tailwind.config.js
index 6fa4d2616..bb9412185 100644
--- a/extras/speaker-recognition/webui/tailwind.config.js
+++ b/extras/speaker-recognition/webui/tailwind.config.js
@@ -1,10 +1,13 @@
/** @type {import('tailwindcss').Config} */
+import espressoPreset from './chronicle-espresso-preset.js'
+
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
+ presets: [espressoPreset],
theme: {
extend: {},
},
diff --git a/extras/vault-sync/README.md b/extras/vault-sync/README.md
index 46610d823..0d5da42f5 100644
--- a/extras/vault-sync/README.md
+++ b/extras/vault-sync/README.md
@@ -1,6 +1,6 @@
-# Chronicle Vault Sync (macOS)
+# Chronicle Desktop Tray and Vault Sync
-A menu bar app that keeps your Chronicle Obsidian vault
+A macOS menu bar / Linux system tray app that keeps your Chronicle Obsidian vault
(`data/conversation_docs/{your_user}` on the server) synced to a folder on your Mac, so
you can open it in **Obsidian** with full backlinks, graph view, etc.
@@ -17,17 +17,27 @@ Server Syncthing ◀──── sync protocol :22000 (over Tailscale) ──
└─────────────── Mac authenticates with its JWT ─────────── Obsidian
```
-## Prerequisites (Mac)
+## Prerequisites
```bash
brew install syncthing # the sync engine
# uv (if you don't have it): curl -LsSf https://astral.sh/uv/install.sh | sh
```
+On Arch/CachyOS:
+
+```bash
+sudo pacman -S obsidian syncthing
+```
+
+The Linux tray also shows local ScreenPipe frame/audio counts and storage use, and
+provides start, stop, and restart controls for `screenpipe.service` and
+`chronicle-screenpipe.service`.
+
You also need the Chronicle **server** side running with vault sync enabled — see
[Server setup](#server-setup-once) below.
-## Setup (Mac)
+## Setup
```bash
cd extras/vault-sync
@@ -70,6 +80,10 @@ into `~/ChronicleVault` (or `LOCAL_VAULT_DIR`). From the menu:
./start.sh uninstall
```
+On macOS this installs a launchd agent. On Linux it installs
+`chronicle-desktop.service` as a systemd user service attached to the graphical
+session.
+
## Server setup (once)
On the machine running the advanced backend:
diff --git a/extras/vault-sync/main.py b/extras/vault-sync/main.py
index 4afda1526..94df37fb8 100644
--- a/extras/vault-sync/main.py
+++ b/extras/vault-sync/main.py
@@ -1,6 +1,7 @@
-"""Chronicle Vault Sync — entry point with service-management subcommands."""
+"""Chronicle desktop tray — entry point with service-management subcommands."""
import argparse
+import sys
from service import install, kickstart, logs, status, uninstall
@@ -11,8 +12,8 @@ def cli() -> None:
parser = argparse.ArgumentParser(description="Chronicle Vault Sync")
sub = parser.add_subparsers(dest="command")
sub.add_parser("menu", help="Launch the menu bar app (default)")
- sub.add_parser("install", help="Install as a macOS login item")
- sub.add_parser("uninstall", help="Remove the macOS login item")
+ sub.add_parser("install", help="Install as a desktop login service")
+ sub.add_parser("uninstall", help="Remove the desktop login service")
sub.add_parser("kickstart", help="Relaunch the menu bar app")
sub.add_parser("status", help="Show service status")
sub.add_parser("logs", help="Tail service logs")
@@ -21,8 +22,12 @@ def cli() -> None:
command = args.command or "menu"
if command == "menu":
- # Lazy import: macOS-only (rumps, darwin-only per pyproject.toml)
- from menu_vault import main as menu_main
+ if sys.platform == "darwin":
+ from menu_vault import main as menu_main
+ elif sys.platform.startswith("linux"):
+ from menu_linux import main as menu_main
+ else:
+ raise SystemExit(f"unsupported desktop platform: {sys.platform}")
menu_main()
elif command == "install":
diff --git a/extras/vault-sync/menu_linux.py b/extras/vault-sync/menu_linux.py
new file mode 100644
index 000000000..7050efc3a
--- /dev/null
+++ b/extras/vault-sync/menu_linux.py
@@ -0,0 +1,241 @@
+"""KDE/Linux system tray for Chronicle capture and vault sync."""
+
+import logging
+import sqlite3
+import subprocess
+import sys
+import threading
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+from urllib.parse import quote
+
+import httpx
+from dotenv import load_dotenv
+from PySide6.QtCore import QTimer
+from PySide6.QtGui import QAction, QDesktopServices, QIcon
+from PySide6.QtWidgets import QApplication, QFileDialog, QMenu, QSystemTrayIcon
+from PySide6.QtCore import QUrl
+
+from syncthing_manager import SyncthingManager
+from vault_core import VaultSyncConfig, broker_pair, get_jwt_token, save_vault_dir
+
+logger = logging.getLogger(__name__)
+SCREENPIPE_DB = Path.home() / ".screenpipe/db.sqlite"
+load_dotenv(Path(__file__).resolve().parent / ".env")
+
+
+@dataclass
+class SharedState:
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
+ status: str = "idle"
+ error: Optional[str] = None
+ connected: bool = False
+ completion: Optional[float] = None
+ folder_error: Optional[str] = None
+ folder_id: Optional[str] = None
+ vault_dir: str = ""
+
+ def snapshot(self) -> dict:
+ with self._lock:
+ return {
+ key: value for key, value in vars(self).items() if key != "_lock"
+ }
+
+ def update(self, **values) -> None:
+ with self._lock:
+ for key, value in values.items():
+ setattr(self, key, value)
+
+
+class VaultSyncManager:
+ def __init__(self, state: SharedState) -> None:
+ self.state = state
+ self.config = VaultSyncConfig.from_env()
+ self.syncthing = SyncthingManager()
+ self.state.update(vault_dir=self.config.local_vault_dir)
+ self._lock = threading.Lock()
+
+ def pair_async(self) -> None:
+ threading.Thread(target=self._pair, daemon=True).start()
+
+ def _pair(self) -> None:
+ if not self._lock.acquire(blocking=False):
+ return
+ try:
+ cfg = self.config
+ if not cfg.auth_username or not cfg.auth_password:
+ self.state.update(status="error", error="set Chronicle login in .env")
+ return
+ self.state.update(status="starting", error=None)
+ self.syncthing.start()
+ self.state.update(status="pairing")
+ token = get_jwt_token(cfg.auth_username, cfg.auth_password, cfg.backend_url)
+ if not token:
+ self.state.update(status="error", error="backend authentication failed")
+ return
+ info = broker_pair(
+ cfg.backend_url, token, self.syncthing.device_id(), cfg.device_name
+ )
+ self.syncthing.ensure_server_device(
+ info["server_device_id"],
+ "Chronicle Server",
+ [info["sync_address"]] if info.get("sync_address") else ["dynamic"],
+ )
+ self.syncthing.ensure_folder(
+ info["folder_id"], cfg.local_vault_dir,
+ info.get("folder_label", "Chronicle Vault"),
+ info["server_device_id"], self.syncthing.device_id(),
+ )
+ self.state.update(status="syncing", folder_id=info["folder_id"], error=None)
+ except (OSError, httpx.HTTPError, RuntimeError) as error:
+ logger.exception("Vault pairing failed")
+ self.state.update(status="error", error=str(error))
+ finally:
+ self._lock.release()
+
+ def set_vault_dir(self, path: str) -> None:
+ save_vault_dir(path)
+ self.config.local_vault_dir = path
+ self.state.update(vault_dir=path)
+ self.pair_async()
+
+ def refresh_status(self) -> None:
+ if not self.syncthing.is_running():
+ return
+ snap = self.state.snapshot()
+ status = (
+ self.syncthing.folder_status(snap["folder_id"])
+ if snap["folder_id"] else {}
+ )
+ self.state.update(
+ connected=self.syncthing.connection_count() > 0,
+ completion=status.get("completion"),
+ folder_error=status.get("error"),
+ )
+
+ def shutdown(self) -> None:
+ self.syncthing.stop()
+
+
+def _unit_state(name: str) -> str:
+ result = subprocess.run(
+ ["systemctl", "--user", "is-active", name], capture_output=True, text=True
+ )
+ return result.stdout.strip() or "unknown"
+
+
+def _screenpipe_stats() -> str:
+ if not SCREENPIPE_DB.exists():
+ return "No local database"
+ try:
+ uri = f"file:{SCREENPIPE_DB.resolve()}?mode=ro"
+ with sqlite3.connect(uri, uri=True, timeout=2) as db:
+ tables = {
+ row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")
+ }
+ frames = (
+ db.execute("SELECT count(*) FROM frames").fetchone()[0]
+ if "frames" in tables
+ else 0
+ )
+ audio = (
+ db.execute("SELECT count(*) FROM audio_chunks").fetchone()[0]
+ if "audio_chunks" in tables
+ else 0
+ )
+ size = sum(p.stat().st_size for p in SCREENPIPE_DB.parent.rglob("*") if p.is_file())
+ return f"{frames:,} frames · {audio:,} audio chunks · {size / 1024**3:.1f} GiB"
+ except (OSError, sqlite3.Error) as error:
+ return f"Stats unavailable: {error}"
+
+
+class ChronicleTray(QSystemTrayIcon):
+ def __init__(self, state: SharedState, manager: VaultSyncManager) -> None:
+ icon = QIcon.fromTheme("view-calendar-timeline", QIcon.fromTheme("folder-sync"))
+ super().__init__(icon)
+ self.state = state
+ self.manager = manager
+ menu = QMenu()
+ self.capture_status = menu.addAction("ScreenPipe: checking…")
+ self.collector_status = menu.addAction("Chronicle collector: checking…")
+ self.stats = menu.addAction("Stats: checking…")
+ for item in (self.capture_status, self.collector_status, self.stats):
+ item.setEnabled(False)
+ menu.addSeparator()
+ self._service_actions(menu, "ScreenPipe", "screenpipe.service")
+ self._service_actions(menu, "Collector", "chronicle-screenpipe.service")
+ menu.addSeparator()
+ self.sync_status = menu.addAction("Vault sync: starting…")
+ self.sync_status.setEnabled(False)
+ menu.addAction("Open vault in Obsidian", self.open_obsidian)
+ menu.addAction("Choose vault folder…", self.choose_folder)
+ menu.addAction("Sync now / re-pair", manager.pair_async)
+ menu.addSeparator()
+ menu.addAction(
+ "Open Chronicle",
+ lambda: QDesktopServices.openUrl(QUrl(manager.config.backend_url)),
+ )
+ menu.addAction("Quit tray", QApplication.quit)
+ self.setContextMenu(menu)
+ self.setToolTip("Chronicle")
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.refresh)
+ self.timer.start(5000)
+ self.refresh()
+
+ def _service_actions(self, menu: QMenu, label: str, unit: str) -> None:
+ submenu = menu.addMenu(label)
+ for title, verb in (("Start", "start"), ("Stop", "stop"), ("Restart", "restart")):
+ action = QAction(title, submenu)
+ action.triggered.connect(lambda _checked=False, v=verb, u=unit: self.service(v, u))
+ submenu.addAction(action)
+
+ def service(self, verb: str, unit: str) -> None:
+ subprocess.run(["systemctl", "--user", verb, unit], check=False)
+ QTimer.singleShot(500, self.refresh)
+
+ def refresh(self) -> None:
+ self.manager.refresh_status()
+ capture = _unit_state("screenpipe.service")
+ collector = _unit_state("chronicle-screenpipe.service")
+ self.capture_status.setText(f"ScreenPipe: {capture}")
+ self.collector_status.setText(f"Chronicle collector: {collector}")
+ self.stats.setText(_screenpipe_stats())
+ snap = self.state.snapshot()
+ if snap["status"] == "error":
+ sync = f"error — {snap['error']}"
+ elif snap["completion"] is not None:
+ sync = f"{snap['completion']:.0f}%"
+ else:
+ sync = snap["status"]
+ self.sync_status.setText(f"Vault sync: {sync}")
+ self.setToolTip(f"Chronicle\nScreenPipe: {capture}\nCollector: {collector}\nVault: {sync}")
+
+ def open_obsidian(self) -> None:
+ vault = self.state.snapshot()["vault_dir"]
+ Path(vault).mkdir(parents=True, exist_ok=True)
+ QDesktopServices.openUrl(QUrl(f"obsidian://open?path={quote(vault)}"))
+
+ def choose_folder(self) -> None:
+ current = self.state.snapshot()["vault_dir"]
+ chosen = QFileDialog.getExistingDirectory(None, "Choose Chronicle vault", current)
+ if chosen:
+ self.manager.set_vault_dir(chosen)
+
+
+def main() -> None:
+ logging.basicConfig(level=logging.INFO)
+ app = QApplication(sys.argv)
+ app.setQuitOnLastWindowClosed(False)
+ if not QSystemTrayIcon.isSystemTrayAvailable():
+ raise SystemExit("No system tray is available in this desktop session")
+ state = SharedState()
+ manager = VaultSyncManager(state)
+ manager.pair_async()
+ tray = ChronicleTray(state, manager)
+ tray.show()
+ try:
+ sys.exit(app.exec())
+ finally:
+ manager.shutdown()
diff --git a/extras/vault-sync/pyproject.toml b/extras/vault-sync/pyproject.toml
index 2b1e41697..eded17581 100644
--- a/extras/vault-sync/pyproject.toml
+++ b/extras/vault-sync/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "chronicle-vault-sync"
version = "0.1.0"
-description = "macOS menu bar app that syncs your Chronicle Obsidian vault via Syncthing."
+description = "Desktop tray app that syncs your Chronicle Obsidian vault via Syncthing."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
@@ -9,5 +9,6 @@ dependencies = [
"python-dotenv>=1.0.0",
"rumps>=0.4.0; sys_platform == 'darwin'",
"pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
+ "PySide6>=6.8.0; sys_platform == 'linux'",
"minidisc-python>=0.1.0",
]
diff --git a/extras/vault-sync/service.py b/extras/vault-sync/service.py
index 2d37eb5aa..0d3584652 100644
--- a/extras/vault-sync/service.py
+++ b/extras/vault-sync/service.py
@@ -1,4 +1,4 @@
-"""launchd service management for Chronicle Vault Sync on macOS."""
+"""Desktop service management for Chronicle Vault Sync."""
import os
import plistlib
@@ -112,6 +112,9 @@ def _build_plist() -> dict:
def install() -> None:
+ if sys.platform.startswith("linux"):
+ _linux_install()
+ return
LOG_DIR.mkdir(parents=True, exist_ok=True)
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -145,6 +148,9 @@ def install() -> None:
def uninstall() -> None:
+ if sys.platform.startswith("linux"):
+ _linux_uninstall()
+ return
if not PLIST_PATH.exists():
print(f"No plist found at {PLIST_PATH}")
return
@@ -165,6 +171,9 @@ def uninstall() -> None:
def kickstart() -> None:
+ if sys.platform.startswith("linux"):
+ _linux_systemctl("restart")
+ return
if not PLIST_PATH.exists():
print("Service not installed. Run './start.sh install' first.")
return
@@ -180,6 +189,9 @@ def kickstart() -> None:
def status() -> None:
+ if sys.platform.startswith("linux"):
+ _linux_systemctl("status")
+ return
if not PLIST_PATH.exists():
print(f"Service not installed (no plist at {PLIST_PATH})")
return
@@ -200,6 +212,11 @@ def status() -> None:
def logs(follow: bool = True) -> None:
+ if sys.platform.startswith("linux"):
+ args = ["journalctl", "--user", "-u", "chronicle-desktop.service"]
+ args += ["-f"] if follow else ["-n", "100", "--no-pager"]
+ subprocess.run(args, check=False)
+ return
if not LOG_FILE.exists():
print(f"No log file at {LOG_FILE}")
return
@@ -211,3 +228,43 @@ def logs(follow: bool = True) -> None:
pass
else:
print(LOG_FILE.read_text()[-5000:])
+
+
+def _linux_unit_path() -> Path:
+ return Path.home() / ".config/systemd/user/chronicle-desktop.service"
+
+
+def _linux_install() -> None:
+ uv = _find_uv()
+ unit = _linux_unit_path()
+ unit.parent.mkdir(parents=True, exist_ok=True)
+ unit.write_text(
+ "[Unit]\nDescription=Chronicle desktop tray\n"
+ "After=graphical-session.target network-online.target\n\n"
+ "[Service]\nType=simple\n"
+ f"WorkingDirectory={PROJECT_DIR}\n"
+ f"ExecStart={uv} run --project {PROJECT_DIR} python {PROJECT_DIR / 'main.py'} menu\n"
+ "Restart=on-failure\nRestartSec=5\n\n"
+ "[Install]\nWantedBy=graphical-session.target\n"
+ )
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
+ subprocess.run(
+ ["systemctl", "--user", "enable", "--now", unit.name], check=True
+ )
+ print(f"Installed and started {unit.name}")
+
+
+def _linux_uninstall() -> None:
+ unit = _linux_unit_path()
+ subprocess.run(
+ ["systemctl", "--user", "disable", "--now", unit.name], check=False
+ )
+ unit.unlink(missing_ok=True)
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
+ print(f"Removed {unit}")
+
+
+def _linux_systemctl(action: str) -> None:
+ subprocess.run(
+ ["systemctl", "--user", action, "chronicle-desktop.service"], check=False
+ )
diff --git a/extras/vault-sync/syncthing_manager.py b/extras/vault-sync/syncthing_manager.py
index 44bf0174c..ebbb0845a 100644
--- a/extras/vault-sync/syncthing_manager.py
+++ b/extras/vault-sync/syncthing_manager.py
@@ -11,6 +11,7 @@
import secrets
import shutil
import subprocess
+import sys
import time
from pathlib import Path
from typing import List, Optional
@@ -21,6 +22,9 @@
APP_SUPPORT = (
Path.home() / "Library" / "Application Support" / "Chronicle" / "vault-sync"
+ if sys.platform == "darwin"
+ else Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
+ / "chronicle-vault-sync"
)
SYNCTHING_HOME = APP_SUPPORT / "syncthing" # config, keys, index db
APIKEY_FILE = APP_SUPPORT / "apikey"
@@ -33,18 +37,19 @@
def _find_binary() -> str:
- """Locate the syncthing binary, preferring PATH then common Homebrew locations."""
+ """Locate the syncthing binary, preferring PATH then common install locations."""
exe = shutil.which("syncthing")
if exe:
return exe
for candidate in (
Path("/opt/homebrew/bin/syncthing"),
Path("/usr/local/bin/syncthing"),
+ Path("/usr/bin/syncthing"),
):
if candidate.exists():
return str(candidate)
raise FileNotFoundError(
- "syncthing not found. Install it with: brew install syncthing"
+ "syncthing not found. Install it with your system package manager"
)
diff --git a/extras/vault-sync/vault_core.py b/extras/vault-sync/vault_core.py
index 24f2e9865..c1296f074 100644
--- a/extras/vault-sync/vault_core.py
+++ b/extras/vault-sync/vault_core.py
@@ -26,6 +26,9 @@
# Persisted local vault directory (set via the "Choose Vault Folder…" menu item).
APP_SUPPORT = (
Path.home() / "Library" / "Application Support" / "Chronicle" / "vault-sync"
+ if sys.platform == "darwin"
+ else Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
+ / "chronicle-vault-sync"
)
VAULT_DIR_FILE = APP_SUPPORT / "vault_dir.txt"
diff --git a/tests/configs/mock-services.yml b/tests/configs/mock-services.yml
index c2e12934f..c670d5c1d 100644
--- a/tests/configs/mock-services.yml
+++ b/tests/configs/mock-services.yml
@@ -3,6 +3,12 @@ chat:
memories and conversation history.
defaults:
embedding: mock-embed
+ # Without a fallback the memory agent's note-guarantee recovery pass
+ # (force_fallback=True) raises "No fallback LLM is configured" and the whole
+ # memory job fails before the source-preserving fallback note can be written.
+ # Must be a DIFFERENT model name than defaults.llm — a fallback identical to
+ # the primary is rejected as pointless (llm_client._get_fallback_model_def).
+ fallback_llm: mock-llm-fallback
llm: mock-llm
stt: mock-stt
stt_stream: mock-stt-stream
@@ -25,6 +31,19 @@ models:
model_type: llm
model_url: http://host.docker.internal:11435/v1
name: mock-llm
+- api_family: openai
+ api_key: dummy-key-not-used
+ description: Same mock LLM server under a distinct name, so it passes the
+ fallback!=primary check and the recovery pass actually runs in tests
+ model_name: gpt-4o-mini
+ model_output: json
+ model_params:
+ max_tokens: 2000
+ temperature: 0.2
+ model_provider: openai
+ model_type: llm
+ model_url: http://host.docker.internal:11435/v1
+ name: mock-llm-fallback
- api_family: openai
api_key: dummy-key-not-used
description: Mock embedding server for testing (local)
diff --git a/tests/unit/test_wizard_defaults.py b/tests/unit/test_wizard_defaults.py
index fef0c0047..e349a3bde 100644
--- a/tests/unit/test_wizard_defaults.py
+++ b/tests/unit/test_wizard_defaults.py
@@ -41,6 +41,7 @@ def _load_wizard():
get_existing_stt_provider = _wizard.get_existing_stt_provider
get_existing_stream_provider = _wizard.get_existing_stream_provider
select_llm_provider = _wizard.select_llm_provider
+select_setup_type = _wizard.select_setup_type
# ---------------------------------------------------------------------------
@@ -150,3 +151,11 @@ def test_select_llm_provider_none_config():
"""Treats None config_yml as empty dict (defaults to openai)."""
result = _select_llm_with_eof(None)
assert result == "openai"
+
+
+@pytest.mark.parametrize(
+ "choice, expected", [("1", "main"), ("2", "join"), ("3", "capture")]
+)
+def test_select_setup_type(choice, expected):
+ with patch.object(_wizard.Prompt, "ask", return_value=choice):
+ assert select_setup_type() == expected
diff --git a/wizard.py b/wizard.py
index 60a28ea32..406519ea1 100755
--- a/wizard.py
+++ b/wizard.py
@@ -1606,9 +1606,43 @@ def select_setup_type():
console.print(
" advertises it to an existing backend on your Tailnet (no backend here)"
)
+ console.print(
+ " 3) Capture node — run ScreenPipe + the Chronicle companion (no containers)"
+ )
console.print()
choice = Prompt.ask("Enter choice", default="1")
- return "join" if choice.strip() == "2" else "main"
+ if choice.strip() == "2":
+ return "join"
+ if choice.strip() == "3":
+ return "capture"
+ return "main"
+
+
+def setup_capture_node():
+ """Delegate ScreenPipe capture-node setup to its separate companion."""
+ init_script = Path("extras/screenpipe-collector/init.py")
+ if not init_script.exists():
+ console.print(f"[red]✗ Capture-node setup is missing: {init_script}[/red]")
+ return False
+
+ backend_url = discovery.discover_service(discovery.CHRONICLE_BACKEND)
+ cmd = [
+ "uv",
+ "run",
+ "--with-requirements",
+ "../../setup-requirements.txt",
+ "python",
+ "init.py",
+ ]
+ if backend_url:
+ console.print(f"[green]✅[/green] Found Chronicle at [cyan]{backend_url}[/cyan]")
+ cmd.extend(["--backend", backend_url])
+ try:
+ subprocess.run(cmd, cwd=init_script.parent, check=True)
+ return True
+ except (OSError, subprocess.CalledProcessError) as exc:
+ console.print(f"[red]✗ Capture-node setup failed: {exc}[/red]")
+ return False
def join_cluster():
@@ -1848,11 +1882,6 @@ def main():
config_mgr = ConfigManager()
config_mgr.ensure_config_yml()
- # Container-engine prereq — everything below runs in containers, so bail early
- # (with a clear reason) rather than failing deep in a build/start step.
- if not check_container_engine():
- return
-
# Setup git hooks first
setup_git_hooks()
@@ -1862,9 +1891,17 @@ def main():
# Read existing config.yml once — used as defaults for ALL wizard questions below
config_yml = config_mgr.get_full_config()
- # Fork: a service-only node joining an existing cluster takes a separate, much
- # shorter path (no backend / LLM / memory setup here) and returns.
- if select_setup_type() == "join":
+ # Capture nodes are host-native and intentionally do not require containers.
+ setup_type = select_setup_type()
+ if setup_type == "capture":
+ setup_capture_node()
+ return
+
+ # All hub and compute-node services below run in containers.
+ if not check_container_engine():
+ return
+
+ if setup_type == "join":
join_cluster()
return