diff --git a/api/config.py b/api/config.py index b46e95eb..4e7f8ad5 100644 --- a/api/config.py +++ b/api/config.py @@ -2282,6 +2282,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 @@ -2399,6 +2400,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 50574eba..291e5a1e 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -175,7 +175,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..16da9af3 --- /dev/null +++ b/api/rollback.py @@ -0,0 +1,298 @@ +""" +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. + + Security: workspace must match a known configured workspace + (from workspaces.json or session-attached workspaces). + """ + 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}") + # Security: confirm workspace is in the known list + try: + from api.workspace import load_workspaces + known_paths = set() + for ws in load_workspaces(): + p = ws.get("path", "") + if p: + known_paths.add(os.path.realpath(p)) + if resolved not in known_paths: + raise ValueError(f"Workspace not in configured list: {workspace}") + except ImportError: + logger.warning("Could not load workspace list for rollback validation") + 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 6ae462f2..5440a108 100644 --- a/api/routes.py +++ b/api/routes.py @@ -978,6 +978,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 ──────────────────────────────────────────────────────────────── @@ -1080,6 +1176,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) @@ -1571,10 +1671,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: @@ -2449,6 +2579,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 0a0fdce5..32ba8b63 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'||event.error==='service-not-allowed'||event.error==='audio-capture'){ + _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 b06cbd08..73c765d2 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', @@ -186,6 +194,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', @@ -420,6 +429,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...', @@ -440,6 +450,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', @@ -463,6 +489,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.', @@ -810,6 +838,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: { @@ -822,6 +866,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', @@ -1074,6 +1125,7 @@ const LOCALES = { tab_workspaces: 'Рабочие пространства', tab_profiles: 'Профили', tab_todos: 'Список дел', + tab_insights: 'Аналитика', tab_settings: 'Настройки', new_conversation: 'Новая беседа', filter_conversations: 'Фильтр бесед...', @@ -1561,6 +1613,39 @@ const LOCALES = { settings_label_tts_rate: 'Скорость речи', settings_label_tts_pitch: 'Тон речи', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, es: { @@ -1832,6 +1917,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...', @@ -2306,6 +2392,46 @@ const LOCALES = { settings_desc_tts_voice: 'Seleccionar voz para síntesis de voz', settings_label_tts_rate: 'Velocidad de voz', settings_label_tts_pitch: 'Tono de voz', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, de: { @@ -2579,6 +2705,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...', @@ -3055,6 +3182,46 @@ const LOCALES = { settings_label_tts_rate: 'Sprechgeschwindigkeit', settings_label_tts_pitch: 'Tonhöhe', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, zh: { @@ -3323,6 +3490,7 @@ const LOCALES = { tab_skills: '技能', tab_tasks: '任务', tab_todos: '待办', + tab_insights: '统计', tab_workspaces: '工作区', tab_profiles: '配置', tab_settings: '设置', @@ -3798,6 +3966,46 @@ const LOCALES = { settings_desc_tts_voice: '选择语音合成声音', settings_label_tts_rate: '语速', settings_label_tts_pitch: '音调', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, // Traditional Chinese (zh-Hant) @@ -4098,6 +4306,7 @@ const LOCALES = { tab_skills: '\u6280\u80fd', tab_tasks: '\u4efb\u52d9', tab_todos: '待辦', + tab_insights: '統計', tab_workspaces: '\u5de5\u4f5c\u5340', new_conversation: '新對話', filter_conversations: '篩選對話', @@ -4649,6 +4858,46 @@ const LOCALES = { settings_label_tts_rate: '語速', settings_label_tts_pitch: '音調', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, pt: { @@ -4988,6 +5237,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...', @@ -5312,6 +5562,46 @@ const LOCALES = { settings_desc_tts_voice: 'Selecionar voz para síntese de voz', settings_label_tts_rate: 'Velocidade da fala', settings_label_tts_pitch: 'Tom da fala', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, ko: { _lang: 'ko', @@ -5702,6 +5992,7 @@ const LOCALES = { tab_workspaces: '공간', tab_profiles: 'Agent 프로필', tab_todos: 'Todos', + tab_insights: '통계', tab_settings: '설정', new_conversation: '새 대화', filter_conversations: '대화 필터…', @@ -6109,6 +6400,46 @@ const LOCALES = { settings_desc_tts_voice: '음성 합성 음성 선택', settings_label_tts_rate: '말 속도', settings_label_tts_pitch: '말 톤', + checkpoint_date: 'Date', // TODO: translate + checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate + checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate + checkpoint_diff_title: 'Changes in checkpoint', // TODO: translate + checkpoint_empty: 'No checkpoints found for this workspace.', // TODO: translate + checkpoint_error: 'Failed to load checkpoints', // TODO: translate + checkpoint_files: 'Files', // TODO: translate + checkpoint_loading: 'Loading checkpoints…', // TODO: translate + checkpoint_message: 'Message', // TODO: translate + checkpoint_restore: 'Restore', // TODO: translate + 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.`, // TODO: translate + checkpoint_restore_confirm_title: 'Restore checkpoint?', // TODO: translate + checkpoint_restored: 'Checkpoint restored', // TODO: translate + checkpoint_title: 'Checkpoints', // TODO: translate + checkpoint_view_diff: 'View diff', // TODO: translate + insights_activity_by_day: 'Activity by Day', // TODO: translate + insights_activity_by_hour: 'Activity by Hour', // TODO: translate + insights_cost: 'Estimated Cost', // TODO: translate + insights_footer: 'Showing data from the last {days} days', // TODO: translate + insights_input_tokens: 'Input', // TODO: translate + insights_messages: 'Messages', // TODO: translate + insights_models: 'Models', // TODO: translate + insights_no_cost: 'N/A', // TODO: translate + insights_output_tokens: 'Output', // TODO: translate + insights_peak_hour: 'Peak: {hour}', // TODO: translate + insights_sessions: 'Sessions', // TODO: translate + insights_title: 'Usage Analytics', // TODO: translate + insights_token_breakdown: 'Token Breakdown', // TODO: translate + insights_tokens: 'Tokens', // TODO: translate + insights_total: 'Total', // TODO: translate + settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).', // TODO: translate + settings_label_api_redact: 'Redact sensitive data in API responses', // TODO: translate + voice_error: 'Voice not supported in this browser', // TODO: translate + voice_listening: 'Listening…', // TODO: translate + voice_mode_active: 'Voice mode on', // TODO: translate + voice_mode_off: 'Voice mode off', // TODO: translate + voice_speaking: 'Speaking…', // TODO: translate + voice_thinking: 'Thinking…', // TODO: translate + voice_toggle: 'Voice input', // TODO: translate + subagent_children: 'Subagent sessions', // TODO: translate }, }; diff --git a/static/index.html b/static/index.html index b89c3263..a4b15258 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).
+