From ae40af03d78138e265b0830a33b98dcd8d8c8f9b Mon Sep 17 00:00:00 2001 From: bergeouss Date: Fri, 1 May 2026 13:43:10 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20P3=20improvements=20=E2=80=94=20ins?= =?UTF-8?q?ights=20panel,=20rollback=20UI,=20voice=20mode,=20subagent=20tr?= =?UTF-8?q?ee,=20api=20redact=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #464 Insights panel: usage analytics dashboard with session/message/token stats, model breakdown, activity by day/hour charts, token breakdown (GET /api/insights) - #466 Rollback UI: checkpoint list, diff viewer, restore confirmation (api/rollback.py, GET /api/rollback/{list,diff}, POST /api/rollback/restore) - #1333 Voice mode: turn-based STT→send→TTS loop using Web Speech API, progressive enhancement with pulsing indicator and auto-resume - #494 Subagent session tree: parent→children grouping in sidebar with expand/collapse chevrons, child count badges, localStorage persistence - #1396 API redact toggle: Settings checkbox to disable forced redaction for self-hosted users (lazy check at call-time, default ON) - #1385 Closed: compact tool activity toggle already exists in Settings - #497 Commented: proposed shared-file bridge for cross-process gateway approvals - i18n: tab_insights added to all 8 locales, voice/checkpoint keys to EN+RU --- api/config.py | 2 + api/helpers.py | 13 ++- api/rollback.py | 282 +++++++++++++++++++++++++++++++++++++++++++++ api/routes.py | 149 +++++++++++++++++++++++- static/boot.js | 265 ++++++++++++++++++++++++++++++++++++++++++ static/i18n.js | 58 ++++++++++ static/index.html | 48 ++++++++ static/panels.js | 231 ++++++++++++++++++++++++++++++++++++- static/sessions.js | 71 +++++++++++- static/style.css | 67 ++++++++++- 10 files changed, 1175 insertions(+), 11 deletions(-) create mode 100644 api/rollback.py diff --git a/api/config.py b/api/config.py index 90b788a6..a4ca8ff4 100644 --- a/api/config.py +++ b/api/config.py @@ -2232,6 +2232,7 @@ _SETTINGS_DEFAULTS = { "notifications_enabled": False, # browser notification when tab is in background "show_thinking": True, # show/hide thinking/reasoning blocks in chat view "simplified_tool_calling": True, # group tools/thinking into one quiet activity disclosure + "api_redact_enabled": True, # redact sensitive data (API keys, secrets) from API responses "sidebar_density": "compact", # compact | detailed "auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges "busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer @@ -2349,6 +2350,7 @@ _SETTINGS_BOOL_KEYS = { "notifications_enabled", "show_thinking", "simplified_tool_calling", + "api_redact_enabled", } # Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr') _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$") diff --git a/api/helpers.py b/api/helpers.py index 130b37eb..677342b5 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -170,7 +170,18 @@ def _build_redact_fn(): return _combined_redact -_redact_text = _build_redact_fn() +_redact_fn_cached = _build_redact_fn() + + +def _redact_text(text: str) -> str: + """Redact sensitive text from API responses. Respects api_redact_enabled setting.""" + if not isinstance(text, str) or not text: + return text + from api.config import load_settings + settings = load_settings() + if not settings.get("api_redact_enabled", True): + return text + return _redact_fn_cached(text) def _redact_value(v): diff --git a/api/rollback.py b/api/rollback.py new file mode 100644 index 00000000..1834bccf --- /dev/null +++ b/api/rollback.py @@ -0,0 +1,282 @@ +""" +Hermes Web UI -- Filesystem checkpoint (rollback) API. + +Provides endpoints to list, diff, and restore filesystem checkpoints +created by the Hermes agent's CheckpointManager. Checkpoints live at +``{hermes_home}/checkpoints//`` as shadow git repositories. +""" + +import hashlib +import json +import logging +import os +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def _hermes_home() -> Path: + """Return the active Hermes home directory.""" + try: + from api.profiles import get_active_hermes_home + return Path(get_active_hermes_home()) + except Exception: + return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser() + + +def _workspace_hash(workspace: str) -> str: + """Derive the checkpoint directory name from a workspace path. + + Matches the agent's CheckpointManager._get_checkpoint_dir logic: + SHA-256 of the canonical workspace path. + """ + try: + canonical = os.path.realpath(workspace) + except (OSError, ValueError): + canonical = workspace + return hashlib.sha256(canonical.encode()).hexdigest()[:12] + + +def _checkpoint_root() -> Path: + return _hermes_home() / "checkpoints" + + +def _resolve_workspace(workspace: str) -> str: + """Validate and return the canonical workspace path.""" + if not workspace or not isinstance(workspace, str): + raise ValueError("workspace is required") + # Basic path validation + resolved = os.path.realpath(workspace) + if not os.path.isdir(resolved): + raise ValueError(f"Workspace does not exist: {workspace}") + return resolved + + +def _find_git() -> str: + """Return the path to the git binary.""" + return shutil.which("git") or "git" + + +# ── Public API functions (called from routes.py) ──────────────────────────── + + +def list_checkpoints(workspace: str) -> dict[str, Any]: + """List all checkpoints for a workspace. + + Returns a dict with: + checkpoints: list of checkpoint objects + workspace: resolved workspace path + checkpoint_dir: the checkpoint directory path + """ + resolved = _resolve_workspace(workspace) + ws_hash = _workspace_hash(resolved) + ckpt_dir = _checkpoint_root() / ws_hash + + checkpoints = [] + if not ckpt_dir.is_dir(): + return {"checkpoints": [], "workspace": resolved, "checkpoint_dir": str(ckpt_dir)} + + # Each checkpoint is a git repo in // + git = _find_git() + for entry in sorted(ckpt_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.is_dir() else 0, reverse=True): + if not entry.is_dir(): + continue + ckpt_info = _inspect_checkpoint(entry, git) + if ckpt_info: + checkpoints.append(ckpt_info) + + return { + "checkpoints": checkpoints, + "workspace": resolved, + "checkpoint_dir": str(ckpt_dir), + } + + +def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None: + """Extract metadata from a single checkpoint directory.""" + git_dir = ckpt_path / ".git" + if not git_dir.is_dir(): + return None + + name = ckpt_path.name + try: + result = subprocess.run( + [git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + + lines = result.stdout.strip().split("\n") + commit_hash = lines[0] if len(lines) > 0 else name + message = lines[1] if len(lines) > 1 else "checkpoint" + date_str = lines[2] if len(lines) > 2 else "" + + # Parse date for display + date_display = "" + if date_str: + try: + dt = datetime.fromisoformat(date_str) + date_display = dt.strftime("%Y-%m-%d %H:%M") + except (ValueError, TypeError): + date_display = date_str + + # Count files + files_result = subprocess.run( + [git, "-C", str(ckpt_path), "ls-files"], + capture_output=True, text=True, timeout=5, + ) + file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0 + + return { + "id": name, + "commit": commit_hash[:12], + "message": message, + "date": date_str, + "date_display": date_display, + "files": file_count, + "path": str(ckpt_path), + } + except (subprocess.TimeoutExpired, OSError, Exception) as e: + logger.debug("Failed to inspect checkpoint %s: %s", ckpt_path, e) + return None + + +def get_checkpoint_diff(workspace: str, checkpoint: str) -> dict[str, Any]: + """Show the diff between a checkpoint and the current workspace state. + + Returns a dict with: + diff: unified diff text + files_changed: list of changed file paths + """ + resolved = _resolve_workspace(workspace) + ws_hash = _workspace_hash(resolved) + ckpt_dir = _checkpoint_root() / ws_hash / checkpoint + + if not ckpt_dir.is_dir(): + raise ValueError(f"Checkpoint not found: {checkpoint}") + + git = _find_git() + + # Get list of files in the checkpoint + ls_result = subprocess.run( + [git, "-C", str(ckpt_dir), "ls-files"], + capture_output=True, text=True, timeout=10, + ) + if ls_result.returncode != 0: + raise ValueError("Failed to list checkpoint files") + + ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f] + files_changed = [] + diff_lines = [] + + for rel_path in ckpt_files: + ckpt_file = ckpt_dir / rel_path + ws_file = Path(resolved) / rel_path + + if not ckpt_file.is_file(): + continue + + # Read checkpoint version + try: + ckpt_content = ckpt_file.read_text(errors="replace") + except OSError: + continue + + # Read workspace version (if exists) + if ws_file.is_file(): + try: + ws_content = ws_file.read_text(errors="replace") + except OSError: + ws_content = "" + else: + ws_content = None # File was deleted in workspace + + if ws_content is None: + # File exists in checkpoint but not in workspace (deleted) + files_changed.append({"file": rel_path, "status": "deleted"}) + diff_lines.append(f"--- a/{rel_path}") + diff_lines.append(f"+++ /dev/null") + diff_lines.append("@@ -1,{lines} +0,0 @@".format(lines=len(ckpt_content.splitlines()))) + for line in ckpt_content.splitlines(): + diff_lines.append(f"-{line}") + elif ckpt_content != ws_content: + # File changed + import difflib + ckpt_lines = ckpt_content.splitlines(keepends=True) + ws_lines = ws_content.splitlines(keepends=True) + diff = list(difflib.unified_diff(ckpt_lines, ws_lines, fromfile=f"a/{rel_path}", tofile=f"b/{rel_path}", lineterm="")) + if diff: + files_changed.append({"file": rel_path, "status": "modified"}) + diff_lines.extend(diff) + + # Check for new files in workspace that aren't in checkpoint + # (skip for performance — diff is primarily for seeing what the checkpoint captures) + + return { + "checkpoint": checkpoint, + "workspace": resolved, + "diff": "\n".join(diff_lines) if diff_lines else "", + "files_changed": files_changed, + "total_changes": len(files_changed), + } + + +def restore_checkpoint(workspace: str, checkpoint: str) -> dict[str, Any]: + """Restore a checkpoint by copying files back to the workspace. + + Only restores files that exist in the checkpoint. Does NOT delete + files that were added after the checkpoint was created. + + Returns a dict with: + ok: True + files_restored: list of restored file paths + """ + resolved = _resolve_workspace(workspace) + ws_hash = _workspace_hash(resolved) + ckpt_dir = _checkpoint_root() / ws_hash / checkpoint + + if not ckpt_dir.is_dir(): + raise ValueError(f"Checkpoint not found: {checkpoint}") + + git = _find_git() + + # Get list of files in the checkpoint + ls_result = subprocess.run( + [git, "-C", str(ckpt_dir), "ls-files"], + capture_output=True, text=True, timeout=10, + ) + if ls_result.returncode != 0: + raise ValueError("Failed to list checkpoint files") + + ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f] + restored = [] + errors = [] + + for rel_path in ckpt_files: + ckpt_file = ckpt_dir / rel_path + ws_file = Path(resolved) / rel_path + + if not ckpt_file.is_file(): + continue + + try: + ws_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(ckpt_file), str(ws_file)) + restored.append(rel_path) + except OSError as e: + errors.append({"file": rel_path, "error": str(e)}) + logger.warning("Failed to restore %s: %s", rel_path, e) + + return { + "ok": True, + "checkpoint": checkpoint, + "workspace": resolved, + "files_restored": restored, + "files_restored_count": len(restored), + "errors": errors, + } diff --git a/api/routes.py b/api/routes.py index 6abd5a2b..26a605e7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -842,6 +842,102 @@ button:hover{background:rgba(124,185,255,.25)} """ +# ── Insights endpoint ────────────────────────────────────────────────────────── + +def _handle_insights(handler, parsed) -> bool: + """Return usage analytics from local WebUI session data.""" + import collections + import time as _time + + query = parse_qs(parsed.query) + try: + days = min(max(int(query.get("days", ["30"])[0]), 1), 365) + except (ValueError, TypeError): + days = 30 + + now = _time.time() + cutoff = now - (days * 86400) + + # Walk session index (fast, no full JSON parse) + sessions_data = [] + idx_path = SESSION_DIR / "_index.json" + if idx_path.exists(): + try: + idx = json.loads(idx_path.read_text(encoding="utf-8")) + except Exception: + idx = [] + else: + idx = [] + + for entry in idx: + created = entry.get("created_at", 0) or 0 + updated = entry.get("updated_at", 0) or 0 + # Session is relevant if it was created or updated within the window + if max(created, updated) < cutoff: + continue + sessions_data.append(entry) + + # Aggregate + total_sessions = len(sessions_data) + total_messages = 0 + total_input_tokens = 0 + total_output_tokens = 0 + total_cost = 0.0 + model_counts = collections.Counter() + # Activity by day of week (0=Mon .. 6=Sun) + dow_activity = collections.Counter() + # Activity by hour of day (0-23) + hod_activity = collections.Counter() + + for s in sessions_data: + total_messages += max(s.get("message_count", 0) or 0, 0) + total_input_tokens += max(s.get("input_tokens", 0) or 0, 0) + total_output_tokens += max(s.get("output_tokens", 0) or 0, 0) + cost = s.get("estimated_cost") + if cost is not None: + try: + total_cost += float(cost) + except (ValueError, TypeError): + pass + model = s.get("model") or "unknown" + if model: + model_counts[model] += 1 + # Activity patterns + ts = s.get("updated_at", s.get("created_at", 0)) or 0 + if ts: + try: + dt = _time.localtime(ts) + dow_activity[dt.tm_wday] += 1 + hod_activity[dt.tm_hour] += 1 + except Exception: + pass + + # Build model breakdown + models_breakdown = [] + for model, count in model_counts.most_common(): + models_breakdown.append({"model": model, "sessions": count}) + + # Day-of-week labels + dow_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + dow_data = [{"day": dow_labels[i], "sessions": dow_activity.get(i, 0)} for i in range(7)] + + # Hour-of-day data + hod_data = [{"hour": h, "sessions": hod_activity.get(h, 0)} for h in range(24)] + + return j(handler, { + "period_days": days, + "total_sessions": total_sessions, + "total_messages": total_messages, + "total_input_tokens": total_input_tokens, + "total_output_tokens": total_output_tokens, + "total_tokens": total_input_tokens + total_output_tokens, + "total_cost": round(total_cost, 6), + "models": models_breakdown, + "activity_by_day": dow_data, + "activity_by_hour": hod_data, + }) + + # ── GET routes ──────────────────────────────────────────────────────────────── @@ -944,6 +1040,10 @@ def handle_get(handler, parsed) -> bool: handler.end_headers() return True + # ── Insights ── + if parsed.path == "/api/insights": + return _handle_insights(handler, parsed) + if parsed.path == "/health": with STREAMS_LOCK: n_streams = len(STREAMS) @@ -1428,10 +1528,40 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/mcp/servers": return _handle_mcp_servers_list(handler) + # ── Checkpoints / Rollback (GET) ── + if parsed.path == "/api/rollback/list": + qs = parse_qs(parsed.query) + workspace = qs.get("workspace", [""])[0] + if not workspace: + return bad(handler, "workspace query parameter is required") + try: + from api.rollback import list_checkpoints + return j(handler, list_checkpoints(workspace)) + except ValueError as e: + return bad(handler, str(e)) + except Exception as e: + logger.exception("rollback/list failed") + return bad(handler, str(e), status=500) + + if parsed.path == "/api/rollback/diff": + qs = parse_qs(parsed.query) + workspace = qs.get("workspace", [""])[0] + checkpoint = qs.get("checkpoint", [""])[0] + if not workspace or not checkpoint: + return bad(handler, "workspace and checkpoint query parameters are required") + try: + from api.rollback import get_checkpoint_diff + return j(handler, get_checkpoint_diff(workspace, checkpoint)) + except ValueError as e: + return bad(handler, str(e)) + except Exception as e: + logger.exception("rollback/diff failed") + return bad(handler, str(e), status=500) + return False # 404 -# ── POST routes ─────────────────────────────────────────────────────────────── +# ── GET route helpers def handle_post(handler, parsed) -> bool: @@ -2289,6 +2419,23 @@ def handle_post(handler, parsed) -> bool: handler.wfile.write(json.dumps({"ok": True}).encode()) return True + # ── Checkpoints / Rollback (POST) ── + if parsed.path == "/api/rollback/restore": + if not body: + return bad(handler, "request body is required") + workspace = body.get("workspace", "") + checkpoint = body.get("checkpoint", "") + if not workspace or not checkpoint: + return bad(handler, "workspace and checkpoint are required") + try: + from api.rollback import restore_checkpoint + return j(handler, restore_checkpoint(workspace, checkpoint)) + except ValueError as e: + return bad(handler, str(e)) + except Exception as e: + logger.exception("rollback/restore failed") + return bad(handler, str(e), status=500) + return False # 404 # ── GET route helpers ───────────────────────────────────────────────────────── diff --git a/static/boot.js b/static/boot.js index f3cf8ea6..a97755cf 100644 --- a/static/boot.js +++ b/static/boot.js @@ -199,6 +199,12 @@ $('btnSend').onclick=()=>{ _stopMic(); return; } + // Turn-based voice mode: let the voice mode system handle the send flow + if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){ + // Immediately send whatever is in the textarea + if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend(); + return; + } send(); }; $('btnAttach').onclick=()=>$('fileInput').click(); @@ -403,6 +409,265 @@ $('btnAttach').onclick=()=>$('fileInput').click(); })(); window._micActive=window._micActive||false; window._micPendingSend=window._micPendingSend||false; + +// ── Turn-based voice mode (#1333) ──────────────────────────────────────── +// Chained flow: listen → send → (agent processes) → TTS response → listen again +(function(){ + const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition; + const hasSTT=!(!SpeechRecognition); + const hasTTS=!!('speechSynthesis' in window); + + // Need both STT and TTS for turn-based voice mode + if(!hasSTT||!hasTTS) return; + + const modeBtn=$('btnVoiceMode'); + const bar=$('voiceModeBar'); + const indicator=$('voiceModeIndicator'); + const label=$('voiceModeLabel'); + const micBtn=$('btnMic'); + const ta=$('msg'); + + if(!modeBtn||!bar||!indicator||!label) return; + + // Show the voice mode button — browser supports both STT and TTS + modeBtn.style.display=''; + + let _voiceModeActive=false; + let _voiceModeState='idle'; // idle | listening | thinking | speaking + let _recognition=null; + let _silenceTimer=null; + const SILENCE_MS=1800; // auto-send after 1.8s silence + + function _setState(state){ + _voiceModeState=state; + indicator.className='voice-mode-indicator '+state; + label.textContent=state==='listening'?t('voice_listening') + :state==='speaking'?t('voice_speaking') + :state==='thinking'?t('voice_thinking') + :''; + bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none'; + } + + function _startListening(){ + if(!_voiceModeActive) return; + _setState('listening'); + + _recognition=new SpeechRecognition(); + _recognition.continuous=false; + _recognition.interimResults=true; + _recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US'; + + let _finalText=''; + + _recognition.onstart=()=>{ _finalText=''; }; + + _recognition.onresult=(event)=>{ + // Reset silence timer on any result + clearTimeout(_silenceTimer); + let interim=''; + let final=_finalText; + for(let i=event.resultIndex;i{ + _voiceModeSend(); + },SILENCE_MS); + } + }; + + _recognition.onend=()=>{ + clearTimeout(_silenceTimer); + // If we have text and haven't sent yet, send it + if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){ + _voiceModeSend(); + } else if(_voiceModeActive&&_voiceModeState==='listening'){ + // No speech detected — restart listening + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500); + } + }; + + _recognition.onerror=(event)=>{ + clearTimeout(_silenceTimer); + if(event.error==='no-speech'||event.error==='aborted'){ + // Restart if still active + if(_voiceModeActive){ + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800); + } + return; + } + if(event.error==='not-allowed'){ + _deactivate(); + showToast(t('mic_denied')); + return; + } + // Other errors — try to restart + if(_voiceModeActive){ + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500); + } + }; + + try{ _recognition.start(); }catch(e){ + // Already started or other error — retry shortly + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000); + } + } + + function _voiceModeSend(){ + if(!_voiceModeActive) return; + const text=(ta.value||'').trim(); + if(!text){ + ta.value=''; + setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300); + return; + } + _setState('thinking'); + try{ if(_recognition) _recognition.abort(); }catch(_){} + _recognition=null; + // send() is global from boot.js + if(typeof send==='function') send(); + } + + function _speakResponse(){ + if(!_voiceModeActive) return; + _setState('speaking'); + + // Find last assistant message + const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]'); + if(!rows.length){ _startListening(); return; } + const last=rows[rows.length-1]; + const rawText=last.dataset.rawText||''; + if(!rawText.trim()){ _startListening(); return; } + + // Strip for TTS (reuse existing helper if available) + let clean=rawText; + if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText); + else{ + // Basic strip: remove code blocks, images, links + clean=clean.replace(/```[\s\S]*?```/g,' code block ') + .replace(/`([^`]*)`/g,'$1') + .replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g,'$1') + .replace(/#{1,6}\s/g,'') + .replace(/[*_~]+/g,'') + .replace(/\n{2,}/g,'. ') + .replace(/\n/g,' ') + .trim(); + } + if(!clean){ _startListening(); return; } + + const utter=new SpeechSynthesisUtterance(clean); + + // Apply saved voice preferences + const savedVoice=localStorage.getItem('hermes-tts-voice'); + const voices=speechSynthesis.getVoices(); + if(savedVoice&&voices.length){ + const match=voices.find(v=>v.name===savedVoice); + if(match) utter.voice=match; + } + const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate')); + if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate)); + const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch')); + if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch)); + + utter.onend=()=>{ + // After speaking, go back to listening + if(_voiceModeActive) setTimeout(()=>_startListening(),500); + }; + utter.onerror=()=>{ + if(_voiceModeActive) setTimeout(()=>_startListening(),1000); + }; + + speechSynthesis.speak(utter); + } + + // Hook into response completion — observe when the agent finishes + // We patch setComposerStatus to detect when a response completes + const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null; + + window._voiceModeOnResponseComplete=function(){ + if(_voiceModeActive&&_voiceModeState==='thinking'){ + // Small delay to let DOM render the final message + setTimeout(()=>{ + if(_voiceModeActive&&_voiceModeState==='thinking'){ + _speakResponse(); + } + },400); + } + }; + + // Observe S.busy changes to detect response completion + // The existing code calls setBusy(false) when response completes + const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null; + if(_origSetBusy){ + // We use a MutationObserver-style approach via polling S.busy + // Actually, we'll use a simpler approach: hook into the message stream completion + } + + // Most reliable hook: use the existing autoReadLastAssistant call site. + // We override autoReadLastAssistant so that if voice mode is active, we use our + // own speak-and-resume flow instead of the default auto-read. + const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null; + window.autoReadLastAssistant=function(){ + if(_voiceModeActive&&_voiceModeState==='thinking'){ + _speakResponse(); + return; + } + if(_origAutoRead) _origAutoRead.apply(this,arguments); + }; + + function _activate(){ + _voiceModeActive=true; + modeBtn.classList.add('active'); + modeBtn.title=t('voice_mode_active'); + showToast(t('voice_mode_active'),1500); + // If the agent is busy, wait — state will be 'thinking' and we'll detect completion + if(typeof S!=='undefined'&&S.busy){ + _setState('thinking'); + return; + } + // Cancel any existing TTS + if(typeof stopTTS==='function') stopTTS(); + _startListening(); + } + + function _deactivate(){ + _voiceModeActive=false; + _voiceModeState='idle'; + modeBtn.classList.remove('active'); + modeBtn.title=t('voice_toggle'); + bar.style.display='none'; + clearTimeout(_silenceTimer); + try{ if(_recognition) _recognition.abort(); }catch(_){} + _recognition=null; + if(typeof stopTTS==='function') stopTTS(); + // Restore original autoReadLastAssistant + if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead; + // Clear textarea if it was only voice input + ta.value=''; + autoResize(); + } + + modeBtn.onclick=()=>{ + if(_voiceModeActive){ + _deactivate(); + showToast(t('voice_mode_off'),1500); + }else{ + _activate(); + } + }; + + // Expose for external use + window._voiceModeActive=()=>_voiceModeActive; + window._voiceModeDeactivate=_deactivate; + window._voiceModeImmediateSend=_voiceModeSend; +})(); $('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';}; $('btnNewChat').onclick=async()=>{ // If the current session has no messages, just focus the composer rather than diff --git a/static/i18n.js b/static/i18n.js index 4c6f1b2d..ac528be5 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -15,6 +15,14 @@ const LOCALES = { mic_no_speech: 'No speech detected. Try again.', mic_network: 'Speech recognition unavailable.', mic_error: 'Voice input error: ', + // Turn-based voice mode (#1333) + voice_toggle: 'Voice input', + voice_listening: 'Listening…', + voice_speaking: 'Speaking…', + voice_thinking: 'Thinking…', + voice_error: 'Voice not supported in this browser', + voice_mode_active: 'Voice mode on', + voice_mode_off: 'Voice mode off', session_imported: 'Session imported', import_failed: 'Import failed: ', import_invalid_json: 'Invalid JSON', @@ -185,6 +193,7 @@ const LOCALES = { branch_failed:'Fork failed: ', fork_from_here:'Fork from here', forked_from:'Forked from', + subagent_children:'Subagent sessions', btw_asking:'Asking side question...', btw_label:'Side question — not in history', btw_done:'Side question answered', @@ -419,6 +428,7 @@ const LOCALES = { tab_workspaces: 'Spaces', tab_profiles: 'Profiles', tab_todos: 'Todos', + tab_insights: 'Insights', tab_settings: 'Settings', new_conversation: 'New conversation', filter_conversations: 'Filter conversations...', @@ -439,6 +449,22 @@ const LOCALES = { new_skill: 'New skill', personal_memory: 'Personal memory', current_task_list: 'Current task list', + // Insights + insights_title: 'Usage Analytics', + insights_sessions: 'Sessions', + insights_messages: 'Messages', + insights_tokens: 'Tokens', + insights_cost: 'Estimated Cost', + insights_no_cost: 'N/A', + insights_models: 'Models', + insights_activity_by_day: 'Activity by Day', + insights_activity_by_hour: 'Activity by Hour', + insights_peak_hour: 'Peak: {hour}', + insights_token_breakdown: 'Token Breakdown', + insights_input_tokens: 'Input', + insights_output_tokens: 'Output', + insights_total: 'Total', + insights_footer: 'Showing data from the last {days} days', workspace_desc: 'Add and switch workspaces for your sessions.', session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`, new_profile: 'New profile', @@ -462,6 +488,8 @@ const LOCALES = { settings_label_notifications: 'Browser notifications', settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.', settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.', + settings_label_api_redact: 'Redact sensitive data in API responses', + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', settings_sidebar_density_compact: 'Compact', settings_sidebar_density_detailed: 'Detailed', settings_desc_sidebar_density: 'Controls how much metadata the session list shows in the left sidebar.', @@ -809,6 +837,22 @@ const LOCALES = { excalidraw_empty: 'Empty diagram', excalidraw_render_error: 'Failed to render diagram', excalidraw_simplified: 'Simplified SVG preview — not pixel-identical to Excalidraw canvas', + // ── Checkpoints / Rollback ── + checkpoint_title: 'Checkpoints', + checkpoint_empty: 'No checkpoints found for this workspace.', + checkpoint_loading: 'Loading checkpoints…', + checkpoint_error: 'Failed to load checkpoints', + checkpoint_date: 'Date', + checkpoint_message: 'Message', + checkpoint_files: 'Files', + checkpoint_view_diff: 'View diff', + checkpoint_restore: 'Restore', + checkpoint_restore_confirm_title: 'Restore checkpoint?', + checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`, + checkpoint_restored: 'Checkpoint restored', + checkpoint_diff_title: 'Changes in checkpoint', + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, }, ru: { @@ -821,6 +865,13 @@ const LOCALES = { mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.', mic_network: 'Распознавание речи недоступно.', mic_error: 'Ошибка ввода речи: ', + voice_toggle: 'Голосовой ввод', + voice_listening: 'Слушаю…', + voice_speaking: 'Говорю…', + voice_thinking: 'Думаю…', + voice_error: 'Голосовой ввод не поддерживается в этом браузере', + voice_mode_active: 'Голосовой режим включён', + voice_mode_off: 'Голосовой режим выключен', session_imported: 'Сеанс импортирован', import_failed: 'Не удалось импортировать: ', import_invalid_json: 'Неверный JSON', @@ -1072,6 +1123,7 @@ const LOCALES = { tab_workspaces: 'Рабочие пространства', tab_profiles: 'Профили', tab_todos: 'Список дел', + tab_insights: 'Аналитика', tab_settings: 'Настройки', new_conversation: 'Новая беседа', filter_conversations: 'Фильтр бесед...', @@ -1829,6 +1881,7 @@ const LOCALES = { tab_workspaces: 'Espacios', tab_profiles: 'Perfiles', tab_todos: 'Todos', + tab_insights: 'Analíticas', tab_settings: 'Ajustes', new_conversation: 'Nueva conversación', filter_conversations: 'Filtrar conversaciones...', @@ -2576,6 +2629,7 @@ const LOCALES = { tab_workspaces: 'Spaces', tab_profiles: 'Profile', tab_todos: 'Todos', + tab_insights: 'Statistiken', tab_settings: 'Einstellungen', new_conversation: 'Neuer Chat', filter_conversations: 'Chats filtern...', @@ -3318,6 +3372,7 @@ const LOCALES = { tab_skills: '技能', tab_tasks: '任务', tab_todos: '待办', + tab_insights: '统计', tab_workspaces: '工作区', tab_profiles: '配置', tab_settings: '设置', @@ -4093,6 +4148,7 @@ const LOCALES = { tab_skills: '\u6280\u80fd', tab_tasks: '\u4efb\u52d9', tab_todos: '待辦', + tab_insights: '統計', tab_workspaces: '\u5de5\u4f5c\u5340', new_conversation: '新對話', filter_conversations: '篩選對話', @@ -4981,6 +5037,7 @@ const LOCALES = { tab_workspaces: 'Spaces', tab_profiles: 'Perfis', tab_todos: 'Todos', + tab_insights: 'Estatísticas', tab_settings: 'Configurações', new_conversation: 'Nova conversa', filter_conversations: 'Filtrar conversas...', @@ -5694,6 +5751,7 @@ const LOCALES = { tab_workspaces: '공간', tab_profiles: 'Agent 프로필', tab_todos: 'Todos', + tab_insights: '통계', tab_settings: '설정', new_conversation: '새 대화', filter_conversations: '대화 필터…', diff --git a/static/index.html b/static/index.html index 502c7541..04864a2e 100644 --- a/static/index.html +++ b/static/index.html @@ -88,6 +88,7 @@ +
@@ -101,6 +102,7 @@ + @@ -153,6 +155,23 @@
+ +
+
+ Insights +
+ +
+
+
+ +
+
@@ -359,6 +378,10 @@
+ +
+
+
Usage Analytics
+
+
+
Loading...
+
+
@@ -767,6 +808,13 @@
Group thinking and tool calls into one collapsed activity section per assistant turn.
+
+ +
Self-hosted users can disable for transparency (not recommended for shared instances).
+