feat: P3 improvements — insights panel, rollback UI, voice mode, subagent tree, api redact toggle
- #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
This commit is contained in:
@@ -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})?$")
|
||||
|
||||
@@ -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):
|
||||
|
||||
282
api/rollback.py
Normal file
282
api/rollback.py
Normal file
@@ -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/<hash>/`` 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 <ckpt_dir>/<commit_hash>/
|
||||
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,
|
||||
}
|
||||
149
api/routes.py
149
api/routes.py
@@ -842,6 +842,102 @@ button:hover{background:rgba(124,185,255,.25)}
|
||||
<script src="/static/login.js"></script>
|
||||
</body></html>"""
|
||||
|
||||
# ── 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 ─────────────────────────────────────────────────────────
|
||||
|
||||
265
static/boot.js
265
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<event.results.length;i++){
|
||||
const txt=event.results[i][0].transcript;
|
||||
if(event.results[i].isFinal){ final+=txt; _finalText=final; }
|
||||
else{ interim+=txt; }
|
||||
}
|
||||
ta.value=final||interim;
|
||||
autoResize();
|
||||
|
||||
// Auto-send on silence after final result
|
||||
if(_finalText){
|
||||
_silenceTimer=setTimeout(()=>{
|
||||
_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
|
||||
|
||||
@@ -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: '대화 필터…',
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
|
||||
<div class="rail-spacer"></div>
|
||||
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</nav>
|
||||
@@ -101,6 +102,7 @@
|
||||
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="nav-tab" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
|
||||
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
|
||||
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</div>
|
||||
@@ -153,6 +155,23 @@
|
||||
</div>
|
||||
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
|
||||
</div>
|
||||
<!-- Insights panel -->
|
||||
<div class="panel-view" id="panelInsights">
|
||||
<div class="panel-head">
|
||||
<span data-i18n="tab_insights">Insights</span>
|
||||
<div class="panel-head-actions">
|
||||
<button class="panel-head-btn" id="insightsRefreshBtn" onclick="loadInsights(true)" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-head-sub" style="padding:0 12px 8px">
|
||||
<select id="insightsPeriod" onchange="loadInsights()" style="width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
|
||||
<option value="7">7 days</option>
|
||||
<option value="30" selected>30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
<option value="365">365 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Workspaces panel -->
|
||||
<div class="panel-view" id="panelWorkspaces">
|
||||
<div class="panel-head">
|
||||
@@ -359,6 +378,10 @@
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
@@ -374,6 +397,16 @@
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="icon-btn voice-mode-btn" id="btnVoiceMode" title="Turn-based voice mode" style="display:none" data-i18n-title="voice_toggle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
<path d="M20 3l-1.5 1.5" opacity=".5"/>
|
||||
<path d="M4 3l1.5 1.5" opacity=".5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="composer-divider" aria-hidden="true"></div>
|
||||
<button class="yolo-pill" id="yoloPill" type="button" onclick="cmdYolo()" style="display:none" title="YOLO mode — click to disable" data-i18n-title="yolo_pill_title_active">
|
||||
<span class="yolo-pill-icon" aria-hidden="true">⚡</span>
|
||||
@@ -594,6 +627,14 @@
|
||||
<div class="main-view-empty-sub" data-i18n="profiles_empty_sub">Pick an agent profile from the sidebar to view and edit its settings, or create a new one.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainInsights" class="main-view">
|
||||
<div class="main-view-header">
|
||||
<div class="main-view-title" data-i18n="insights_title">Usage Analytics</div>
|
||||
</div>
|
||||
<div class="main-view-content" id="insightsContent" style="padding:16px;overflow-y:auto">
|
||||
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainSettings" class="main-view">
|
||||
<div class="settings-main">
|
||||
<div class="settings-pane active" id="settingsPaneConversation">
|
||||
@@ -767,6 +808,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Group thinking and tool calls into one collapsed activity section per assistant turn.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsApiRedact" checked style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span data-i18n="settings_label_api_redact">Redact sensitive data in API responses</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_api_redact">Self-hosted users can disable for transparency (not recommended for shared instances).</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
|
||||
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
|
||||
231
static/panels.js
231
static/panels.js
@@ -160,11 +160,11 @@ async function switchPanel(name, opts = {}) {
|
||||
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
|
||||
const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1));
|
||||
if (panelEl) panelEl.classList.add('active');
|
||||
// Toggle main content view. Each entry in MAIN_VIEW_PANELS gets a matching
|
||||
// Update main content view. Each entry in MAIN_VIEW_PANELS gets a matching
|
||||
// showing-<name> class on <main>; no class means chat (the default).
|
||||
const mainEl = document.querySelector('main.main');
|
||||
if (mainEl) {
|
||||
['settings','skills','memory','tasks','workspaces','profiles'].forEach(p => {
|
||||
['settings','skills','memory','tasks','workspaces','profiles','insights'].forEach(p => {
|
||||
mainEl.classList.toggle('showing-' + p, nextPanel === p);
|
||||
});
|
||||
}
|
||||
@@ -175,6 +175,7 @@ async function switchPanel(name, opts = {}) {
|
||||
if (nextPanel === 'workspaces') await loadWorkspacesPanel();
|
||||
if (nextPanel === 'profiles') await loadProfilesPanel();
|
||||
if (nextPanel === 'todos') loadTodos();
|
||||
if (nextPanel === 'insights') await loadInsights();
|
||||
if (nextPanel === 'settings') {
|
||||
switchSettingsSection(_currentSettingsSection);
|
||||
loadSettingsPanel();
|
||||
@@ -848,6 +849,112 @@ function loadTodos() {
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Insights panel ──
|
||||
async function loadInsights(animate) {
|
||||
const box = $('insightsContent');
|
||||
const refreshBtn = $('insightsRefreshBtn');
|
||||
if (!box) return;
|
||||
if (animate && refreshBtn) {
|
||||
refreshBtn.style.opacity = '0.5';
|
||||
refreshBtn.disabled = true;
|
||||
}
|
||||
const period = ($('insightsPeriod') || {}).value || '30';
|
||||
try {
|
||||
const data = await api(`/api/insights?days=${period}`);
|
||||
_renderInsights(data, box);
|
||||
} catch(e) {
|
||||
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
|
||||
} finally {
|
||||
if (animate && refreshBtn) {
|
||||
refreshBtn.style.opacity = '';
|
||||
refreshBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _renderInsights(d, box) {
|
||||
const fmtNum = n => n.toLocaleString();
|
||||
const fmtCost = c => c > 0 ? '$' + c.toFixed(4) : t('insights_no_cost');
|
||||
const fmtTokens = n => n >= 1e6 ? (n/1e6).toFixed(1) + 'M' : n >= 1e3 ? (n/1e3).toFixed(1) + 'K' : fmtNum(n);
|
||||
|
||||
// Overview cards
|
||||
const overviewCards = [
|
||||
{ label: t('insights_sessions'), value: fmtNum(d.total_sessions), icon: li('message-square', 18) },
|
||||
{ label: t('insights_messages'), value: fmtNum(d.total_messages), icon: li('hash', 18) },
|
||||
{ label: t('insights_tokens'), value: fmtTokens(d.total_tokens), icon: li('cpu', 18) },
|
||||
{ label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) },
|
||||
];
|
||||
|
||||
// Models table
|
||||
let modelsHtml = '';
|
||||
if (d.models && d.models.length) {
|
||||
const totalSess = d.models.reduce((a, m) => a + m.sessions, 0) || 1;
|
||||
modelsHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_models'))}</div><div class="insights-table"><div class="insights-table-head"><span>Model</span><span>Sessions</span><span>Share</span></div>` +
|
||||
d.models.map(m => {
|
||||
const pct = ((m.sessions / totalSess) * 100).toFixed(0);
|
||||
return `<div class="insights-table-row"><span class="insights-model-name" title="${esc(m.model)}">${esc(m.model)}</span><span>${m.sessions}</span><span>${pct}%</span></div>`;
|
||||
}).join('') +
|
||||
`</div></div>`;
|
||||
}
|
||||
|
||||
// Activity by day of week
|
||||
let dowHtml = '';
|
||||
if (d.activity_by_day) {
|
||||
const maxDow = Math.max(...d.activity_by_day.map(x => x.sessions), 1);
|
||||
dowHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_activity_by_day'))}</div><div class="insights-bars">` +
|
||||
d.activity_by_day.map(r => {
|
||||
const pct = (r.sessions / maxDow * 100).toFixed(0);
|
||||
return `<div class="insights-bar-row"><span class="insights-bar-label">${r.day}</span><div class="insights-bar-track"><div class="insights-bar-fill" style="width:${pct}%"></div></div><span class="insights-bar-value">${r.sessions}</span></div>`;
|
||||
}).join('') +
|
||||
`</div></div>`;
|
||||
}
|
||||
|
||||
// Activity by hour
|
||||
let hodHtml = '';
|
||||
if (d.activity_by_hour) {
|
||||
const maxHod = Math.max(...d.activity_by_hour.map(x => x.sessions), 1);
|
||||
const peakHour = d.activity_by_hour.reduce((a, b) => b.sessions > a.sessions ? b : a, {hour:0,sessions:0});
|
||||
hodHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_activity_by_hour'))} <span style="font-weight:400;font-size:11px;color:var(--muted)">${esc(t('insights_peak_hour').replace('{hour}', peakHour.hour + ':00'))}</span></div><div class="insights-bars">` +
|
||||
d.activity_by_hour.map(r => {
|
||||
const pct = (r.sessions / maxHod * 100).toFixed(0);
|
||||
const isPeak = r.hour === peakHour.hour && peakHour.sessions > 0;
|
||||
return `<div class="insights-bar-row"><span class="insights-bar-label">${String(r.hour).padStart(2,'0')}</span><div class="insights-bar-track"><div class="insights-bar-fill${isPeak ? ' insights-bar-peak' : ''}" style="width:${pct}%"></div></div><span class="insights-bar-value">${r.sessions}</span></div>`;
|
||||
}).join('') +
|
||||
`</div></div>`;
|
||||
}
|
||||
|
||||
// Token breakdown
|
||||
const tokenCards = `
|
||||
<div class="insights-card">
|
||||
<div class="insights-card-title">${esc(t('insights_token_breakdown'))}</div>
|
||||
<div class="insights-token-row">
|
||||
<span class="insights-token-label">${esc(t('insights_input_tokens'))}</span>
|
||||
<span class="insights-token-value">${fmtTokens(d.total_input_tokens)}</span>
|
||||
</div>
|
||||
<div class="insights-token-row">
|
||||
<span class="insights-token-label">${esc(t('insights_output_tokens'))}</span>
|
||||
<span class="insights-token-value">${fmtTokens(d.total_output_tokens)}</span>
|
||||
</div>
|
||||
<div class="insights-token-row insights-token-total">
|
||||
<span class="insights-token-label">${esc(t('insights_total'))}</span>
|
||||
<span class="insights-token-value">${fmtTokens(d.total_tokens)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="insights-grid">
|
||||
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
|
||||
</div>
|
||||
<div class="insights-row">
|
||||
${tokenCards}
|
||||
${modelsHtml}
|
||||
</div>
|
||||
${dowHtml}
|
||||
${hodHtml}
|
||||
<div style="text-align:center;color:var(--muted);font-size:10px;margin-top:12px;opacity:.6">${esc(t('insights_footer').replace('{days}', d.period_days))}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function clearConversation() {
|
||||
if(!S.session) return;
|
||||
const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true});
|
||||
@@ -1698,11 +1805,18 @@ function _renderWorkspaceDetail(ws){
|
||||
<div class="detail-row"><div class="detail-row-label">Path</div><div class="detail-row-value"><code>${esc(ws.path)}</code></div></div>
|
||||
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value">${statusBadge}${defaultBadge}</div></div>
|
||||
</div>
|
||||
<div class="detail-card" style="margin-top:12px">
|
||||
<div class="detail-card-title">${esc(t('checkpoint_title'))}</div>
|
||||
<div id="checkpointListContainer">
|
||||
<div style="color:var(--muted);font-size:12px;padding:8px 0">${esc(t('checkpoint_loading'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
body.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
_workspaceMode = 'read';
|
||||
_setWorkspaceHeaderButtons('read', ws);
|
||||
_loadCheckpoints(ws.path);
|
||||
}
|
||||
|
||||
function _setWorkspaceHeaderButtons(mode, ws){
|
||||
@@ -2708,6 +2822,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
|
||||
const apiRedactCb=$('settingsApiRedact');
|
||||
if(apiRedactCb) payload.api_redact_enabled=apiRedactCb.checked;
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb) payload.show_cli_sessions=showCliCb.checked;
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
@@ -2900,6 +3016,8 @@ async function loadSettingsPanel(){
|
||||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const apiRedactCb=$('settingsApiRedact');
|
||||
if(apiRedactCb){apiRedactCb.checked=settings.api_redact_enabled!==false;apiRedactCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const syncCb=$('settingsSyncInsights');
|
||||
@@ -3344,6 +3462,7 @@ async function saveSettings(andClose){
|
||||
body.language=language;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
|
||||
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
@@ -3631,3 +3750,111 @@ switchSettingsSection=function(name){
|
||||
_origSwitchSettings(name);
|
||||
if(name==='system') loadMcpServers();
|
||||
};
|
||||
|
||||
// ── Checkpoints / Rollback ──────────────────────────────────────────────────
|
||||
|
||||
async function _loadCheckpoints(workspace){
|
||||
const container=$('checkpointListContainer');
|
||||
if(!container) return;
|
||||
try{
|
||||
const data=await api(`/api/rollback/list?workspace=${encodeURIComponent(workspace)}`);
|
||||
const checkpoints=data.checkpoints||[];
|
||||
if(!checkpoints.length){
|
||||
container.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:8px 0">${esc(t('checkpoint_empty'))}</div>`;
|
||||
return;
|
||||
}
|
||||
let html='';
|
||||
for(const ck of checkpoints){
|
||||
const shortId=ck.id||ck.commit||'?';
|
||||
const msg=ck.message||'checkpoint';
|
||||
const date=ck.date_display||ck.date||'';
|
||||
const files=ck.files||0;
|
||||
html+=`
|
||||
<div class="detail-row" style="align-items:center;padding:6px 0;border-bottom:1px solid var(--border,rgba(255,255,255,0.08))">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(msg)}">${esc(msg)}</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:2px">
|
||||
<code style="font-size:10px">${esc(shortId)}</code>
|
||||
${date ? ` · ${esc(date)}` : ''}
|
||||
${files ? ` · ${esc(t('checkpoint_files'))}: ${files}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-shrink:0;margin-left:8px">
|
||||
<button class="panel-head-btn" title="${esc(t('checkpoint_view_diff'))}" onclick="event.stopPropagation();_viewCheckpointDiff('${esc(workspace)}','${esc(ck.id)}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</button>
|
||||
<button class="panel-head-btn" title="${esc(t('checkpoint_restore'))}" onclick="event.stopPropagation();_restoreCheckpoint('${esc(workspace)}','${esc(ck.id)}','${esc(msg.replace(/'/g,"\\'"))}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
container.innerHTML=html;
|
||||
}catch(e){
|
||||
container.innerHTML=`<div style="color:var(--error,#f87171);font-size:12px;padding:8px 0">${esc(t('checkpoint_error'))}: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _viewCheckpointDiff(workspace,checkpoint){
|
||||
const modal=document.getElementById('checkpointDiffModal');
|
||||
if(!modal){
|
||||
const m=document.createElement('div');
|
||||
m.id='checkpointDiffModal';
|
||||
m.style.cssText='position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6)';
|
||||
m.innerHTML=`
|
||||
<div style="background:var(--bg,${getComputedStyle(document.documentElement).getPropertyValue('--bg')||'#1a1a2e'});border:1px solid var(--border,rgba(255,255,255,0.12));border-radius:12px;width:90vw;max-width:800px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border,rgba(255,255,255,0.08))">
|
||||
<div id="checkpointDiffModalTitle" style="font-weight:600;font-size:14px"></div>
|
||||
<button onclick="document.getElementById('checkpointDiffModal').style.display='none'" style="background:none;border:none;color:var(--fg);cursor:pointer;font-size:18px;padding:0 4px">×</button>
|
||||
</div>
|
||||
<div id="checkpointDiffModalBody" style="flex:1;overflow:auto;padding:12px 16px">
|
||||
<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_loading'))}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
m.onclick=(e)=>{if(e.target===m) m.style.display='none';};
|
||||
document.body.appendChild(m);
|
||||
}
|
||||
modal.style.display='flex';
|
||||
$('checkpointDiffModalTitle').textContent=t('checkpoint_diff_title');
|
||||
$('checkpointDiffModalBody').innerHTML=`<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_loading'))}</div>`;
|
||||
try{
|
||||
const data=await api(`/api/rollback/diff?workspace=${encodeURIComponent(workspace)}&checkpoint=${encodeURIComponent(checkpoint)}`);
|
||||
const body=$('checkpointDiffModalBody');
|
||||
if(!data.total_changes){
|
||||
body.innerHTML=`<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_diff_no_changes'))}</div>`;
|
||||
return;
|
||||
}
|
||||
let html=`<div style="font-size:12px;margin-bottom:8px">${esc(t('checkpoint_diff_files_changed',data.total_changes))}</div>`;
|
||||
if(data.files_changed){
|
||||
html+='<div style="margin-bottom:8px">';
|
||||
for(const f of data.files_changed){
|
||||
const icon=f.status==='deleted'?'−':'~';
|
||||
const color=f.status==='deleted'?'var(--error,#f87171)':'var(--accent,#60a5fa)';
|
||||
html+=`<div style="font-size:12px;padding:2px 0"><span style="color:${color};font-weight:bold;margin-right:6px">${icon}</span><code style="font-size:11px">${esc(f.file)}</code></div>`;
|
||||
}
|
||||
html+='</div>';
|
||||
}
|
||||
if(data.diff){
|
||||
html+=`<pre style="background:var(--bg-secondary,rgba(0,0,0,0.3));border:1px solid var(--border,rgba(255,255,255,0.08));border-radius:8px;padding:12px;font-size:11px;line-height:1.4;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:50vh;overflow-y:auto;color:var(--fg)">${esc(data.diff)}</pre>`;
|
||||
}
|
||||
body.innerHTML=html;
|
||||
}catch(e){
|
||||
$('checkpointDiffModalBody').innerHTML=`<div style="color:var(--error,#f87171);font-size:12px">${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function _restoreCheckpoint(workspace,checkpoint,message){
|
||||
const label=message||checkpoint;
|
||||
const ok=await showConfirmDialog({title:t('checkpoint_restore_confirm_title'),message:t('checkpoint_restore_confirm_message',label),confirmLabel:t('checkpoint_restore'),danger:true,focusCancel:true});
|
||||
if(!ok) return;
|
||||
try{
|
||||
const data=await api('/api/rollback/restore',{method:'POST',body:JSON.stringify({workspace,checkpoint})});
|
||||
if(data&&data.ok){
|
||||
showToast(t('checkpoint_restored')+(data.files_restored_count?` (${data.files_restored_count} ${t('checkpoint_files').toLowerCase()})`:''));
|
||||
}else{
|
||||
showToast((data&&data.error)||'Restore failed','error');
|
||||
}
|
||||
}catch(e){
|
||||
showToast(t('checkpoint_restore')+': '+e.message,'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,16 +1211,22 @@ function _sessionTimeBucketLabel(timestampMs, nowMs) {
|
||||
return t('session_time_bucket_older');
|
||||
}
|
||||
|
||||
function _sessionLineageKey(s){
|
||||
function _sessionLineageKey(s, sessionIdsInList){
|
||||
if(!s||!s.session_id) return null;
|
||||
// If parent_session_id points to another session in the current list,
|
||||
// this is a subagent child — don't collapse it into lineage (#494).
|
||||
if(s.parent_session_id && sessionIdsInList && sessionIdsInList.has(s.parent_session_id)){
|
||||
return null;
|
||||
}
|
||||
return s._lineage_root_id || s.lineage_root_id || s.parent_session_id || null;
|
||||
}
|
||||
|
||||
function _collapseSessionLineageForSidebar(sessions){
|
||||
const result=[];
|
||||
const sessionIdsInList=new Set((sessions||[]).map(s=>s.session_id));
|
||||
const groups=new Map();
|
||||
for(const s of sessions||[]){
|
||||
const key=_sessionLineageKey(s);
|
||||
const key=_sessionLineageKey(s, sessionIdsInList);
|
||||
if(!key){result.push(s);continue;}
|
||||
if(!groups.has(key)) groups.set(key,[]);
|
||||
groups.get(key).push(s);
|
||||
@@ -1256,6 +1262,23 @@ function renderSessionListFromCache(){
|
||||
// Filter archived unless toggle is on
|
||||
const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
|
||||
const sessions=_collapseSessionLineageForSidebar(sessionsRaw);
|
||||
// Build parent→children map for subagent tree (#494).
|
||||
// Only children whose parent exists in the current (post-collapse) list are grouped.
|
||||
const _sessionIdsInList=new Set(sessions.map(s=>s.session_id));
|
||||
const _parentChildrenMap=new Map();
|
||||
const _topLevelSessions=[];
|
||||
for(const s of sessions){
|
||||
if(s.parent_session_id && _sessionIdsInList.has(s.parent_session_id)){
|
||||
if(!_parentChildrenMap.has(s.parent_session_id)) _parentChildrenMap.set(s.parent_session_id,[]);
|
||||
_parentChildrenMap.get(s.parent_session_id).push(s);
|
||||
} else {
|
||||
_topLevelSessions.push(s);
|
||||
}
|
||||
}
|
||||
// Collapse state for subagent tree groups — persisted in localStorage (#494)
|
||||
let _treeCollapsed={};
|
||||
try{_treeCollapsed=JSON.parse(localStorage.getItem('hermes-tree-collapsed')||'{}');}catch(e){}
|
||||
const _saveTreeCollapsed=()=>{try{localStorage.setItem('hermes-tree-collapsed',JSON.stringify(_treeCollapsed));}catch(e){}};
|
||||
const archivedCount=projectFiltered.filter(s=>s.archived).length;
|
||||
const list=$('sessionList');list.innerHTML='';
|
||||
// Batch select bar (when in select mode)
|
||||
@@ -1347,7 +1370,7 @@ function renderSessionListFromCache(){
|
||||
empty.textContent='No sessions in this project yet.';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||
const orderedSessions=[..._topLevelSessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||
// Separate pinned from unpinned
|
||||
const pinned=orderedSessions.filter(s=>s.pinned);
|
||||
const unpinned=orderedSessions.filter(s=>!s.pinned);
|
||||
@@ -1393,7 +1416,47 @@ function renderSessionListFromCache(){
|
||||
_saveCollapsed();
|
||||
};
|
||||
wrapper.appendChild(hdr);
|
||||
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
|
||||
for(const s of g.items){
|
||||
const parentEl=_renderOneSession(s, Boolean(g.isPinned));
|
||||
body.appendChild(parentEl);
|
||||
// Render subagent children as indented tree (#494)
|
||||
const children=_parentChildrenMap.get(s.session_id);
|
||||
if(children&&children.length){
|
||||
parentEl.classList.add('session-parent');
|
||||
const treeCaret=document.createElement('span');
|
||||
treeCaret.className='session-tree-caret';
|
||||
treeCaret.textContent='\u25B8'; // right-pointing triangle (collapsed)
|
||||
treeCaret.title=t('subagent_children');
|
||||
parentEl.querySelector('.session-title-row').prepend(treeCaret);
|
||||
const childCount=children.length;
|
||||
const childBadge=document.createElement('span');
|
||||
childBadge.className='session-tree-badge';
|
||||
childBadge.textContent=childCount;
|
||||
childBadge.title=t('subagent_children');
|
||||
parentEl.querySelector('.session-title-row').appendChild(childBadge);
|
||||
const isCollapsed=_treeCollapsed[s.session_id]!==false; // collapsed by default
|
||||
const childContainer=document.createElement('div');
|
||||
childContainer.className='session-tree-children';
|
||||
if(isCollapsed){childContainer.style.display='none';treeCaret.classList.add('collapsed');}
|
||||
else{treeCaret.classList.remove('collapsed');treeCaret.textContent='\u25BE';}
|
||||
const sortedChildren=[...children].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||
for(const child of sortedChildren){
|
||||
const childEl=_renderOneSession(child, Boolean(g.isPinned));
|
||||
childEl.classList.add('session-tree-child');
|
||||
childContainer.appendChild(childEl);
|
||||
}
|
||||
body.appendChild(childContainer);
|
||||
treeCaret.onclick=(e)=>{
|
||||
e.stopPropagation();
|
||||
const hidden=childContainer.style.display==='none';
|
||||
childContainer.style.display=hidden?'':'none';
|
||||
treeCaret.textContent=hidden?'\u25BE':'\u25B8';
|
||||
treeCaret.classList.toggle('collapsed',!hidden);
|
||||
_treeCollapsed[s.session_id]=!hidden;
|
||||
_saveTreeCollapsed();
|
||||
};
|
||||
}
|
||||
}
|
||||
wrapper.appendChild(body);
|
||||
list.appendChild(wrapper);
|
||||
}
|
||||
|
||||
@@ -956,6 +956,18 @@
|
||||
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,83,80,.3);}50%{box-shadow:0 0 0 6px rgba(239,83,80,0);}}
|
||||
.mic-status{font-size:11px;color:var(--error);padding:4px 12px;display:flex;align-items:center;gap:6px;}
|
||||
.mic-dot{width:6px;height:6px;border-radius:50%;background:var(--error);animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
|
||||
|
||||
/* ── Turn-based voice mode (#1333) ── */
|
||||
.voice-mode-btn{transition:color .15s,background .15s;}
|
||||
.voice-mode-btn.active{color:var(--accent);background:rgba(var(--accent-rgb,99,102,241),.15);}
|
||||
.voice-mode-btn.active svg{filter:drop-shadow(0 0 3px rgba(var(--accent-rgb,99,102,241),.5));}
|
||||
.voice-mode-bar{font-size:11px;padding:4px 12px;display:flex;align-items:center;gap:8px;border-bottom:1px solid rgba(255,255,255,.05);}
|
||||
.voice-mode-indicator{width:8px;height:8px;border-radius:50%;flex-shrink:0;}
|
||||
.voice-mode-indicator.listening{background:var(--error);animation:voice-mode-pulse 1s ease-in-out infinite;}
|
||||
.voice-mode-indicator.speaking{background:var(--accent);animation:voice-mode-pulse 1.5s ease-in-out infinite;}
|
||||
.voice-mode-indicator.thinking{background:var(--warning,#f59e0b);animation:voice-mode-pulse 2s ease-in-out infinite;}
|
||||
.voice-mode-label{color:var(--muted);font-size:11px;}
|
||||
@keyframes voice-mode-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:.5;transform:scale(.85);}}
|
||||
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
|
||||
.send-btn{width:34px;height:34px;border-radius:50%;background:var(--accent);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px var(--accent-bg-strong);}
|
||||
.send-btn.stop,.send-btn.interrupt{background:var(--error);box-shadow:0 2px 10px rgba(0,0,0,.18);}
|
||||
@@ -1251,7 +1263,7 @@
|
||||
#terminalWorkspaceLabel{max-width:110px;}
|
||||
#terminalDockWorkspaceLabel{max-width:96px;}
|
||||
/* Touch targets — minimum 44px */
|
||||
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
|
||||
.icon-btn,.mic-btn,.voice-mode-btn{min-width:44px;min-height:44px;}
|
||||
.session-item{min-height:44px;padding:10px 40px 10px 12px;}
|
||||
.session-item.streaming,.session-item.unread{padding-right:40px;}
|
||||
.session-actions{opacity:1;pointer-events:auto;}
|
||||
@@ -1994,8 +2006,9 @@ main.main > #mainSkills,
|
||||
main.main > #mainMemory,
|
||||
main.main > #mainTasks,
|
||||
main.main > #mainWorkspaces,
|
||||
main.main > #mainProfiles{display:none;}
|
||||
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles) > #mainChat{display:flex;}
|
||||
main.main > #mainProfiles,
|
||||
main.main > #mainInsights{display:none;}
|
||||
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
|
||||
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
|
||||
main.main.showing-skills > #mainSkills{display:flex;}
|
||||
main.main.showing-memory > #mainMemory{display:flex;}
|
||||
@@ -2296,6 +2309,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
.session-item.archived{opacity:.5;}
|
||||
.session-item.archived .session-title{font-style:italic;}
|
||||
|
||||
/* ── Subagent session tree (#494) ── */
|
||||
.session-tree-children{margin-left:16px;border-left:1px solid var(--border,rgba(255,255,255,.1));padding-left:4px;}
|
||||
.session-tree-child.session-item{font-size:12px;opacity:.85;border-radius:6px;padding:6px 8px;}
|
||||
.session-tree-child.session-item:hover{opacity:1;}
|
||||
.session-tree-child.session-item.active{opacity:1;}
|
||||
.session-tree-child.session-item .session-meta{font-size:10px;}
|
||||
.session-tree-caret{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;font-size:10px;cursor:pointer;color:var(--muted);flex-shrink:0;transition:transform .15s;user-select:none;border-radius:3px;margin-right:2px;}
|
||||
.session-tree-caret:hover{color:var(--text);background:rgba(255,255,255,.06);}
|
||||
.session-tree-caret.collapsed{transform:none;}
|
||||
.session-tree-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;font-size:9px;font-weight:700;padding:0 4px;border-radius:8px;background:rgba(99,179,237,.2);color:#63b3ed;margin-left:auto;flex-shrink:0;user-select:none;}
|
||||
|
||||
/* ── Session tags ── */
|
||||
.session-tag{display:inline-block;font-size:9px;font-weight:600;padding:1px 5px;margin-left:4px;border-radius:3px;background:rgba(99,179,237,.2);color:#63b3ed;cursor:pointer;vertical-align:middle;}
|
||||
.session-tag:hover{background:rgba(99,179,237,.35);}
|
||||
@@ -2854,3 +2878,40 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.html-preview-iframe{width:100%;height:400px;border:none;display:block;background:#fff;}
|
||||
.html-preview-fallback{padding:8px;font-size:13px;}
|
||||
.html-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
|
||||
|
||||
/* ── Insights panel (#464) ────────────────────────────────────────────────── */
|
||||
main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.insights-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px;}
|
||||
.insights-stat{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;}
|
||||
.insights-stat-value{font-size:22px;font-weight:700;color:var(--text);}
|
||||
.insights-stat-label{font-size:11px;color:var(--muted);margin-top:4px;}
|
||||
.insights-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
|
||||
.insights-card{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;}
|
||||
.insights-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;}
|
||||
.insights-table{width:100%;font-size:12px;}
|
||||
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
|
||||
.insights-table-row{display:grid;grid-template-columns:1fr 80px;padding:6px 0;border-bottom:1px solid var(--border,.05);}
|
||||
.insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.insights-bars{display:flex;flex-direction:column;gap:6px;}
|
||||
.insights-bar-row{display:grid;grid-template-columns:40px 1fr 40px;align-items:center;gap:8px;}
|
||||
.insights-bar-label{font-size:11px;color:var(--muted);text-align:right;}
|
||||
.insights-bar-track{height:16px;background:var(--border,.15);border-radius:4px;overflow:hidden;}
|
||||
.insights-bar-fill{height:100%;background:var(--accent);border-radius:4px;min-width:2px;transition:width .3s;}
|
||||
.insights-bar-fill.peak{background:#f6ad55;}
|
||||
.insights-bar-value{font-size:11px;color:var(--text);}
|
||||
.insights-token-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border,.05);}
|
||||
.insights-token-label{color:var(--muted);}
|
||||
.insights-token-value{font-weight:600;}
|
||||
|
||||
/* ── Checkpoints / Rollback UI (#466) ─────────────────────────────────────── */
|
||||
.checkpoint-list{display:flex;flex-direction:column;gap:8px;}
|
||||
.checkpoint-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;font-size:12px;}
|
||||
.checkpoint-item-actions{display:flex;gap:6px;}
|
||||
.checkpoint-item-actions button{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px 4px;border-radius:4px;}
|
||||
.checkpoint-item-actions button:hover{color:var(--accent);background:rgba(255,255,255,.06);}
|
||||
.checkpoint-item-actions button.danger:hover{color:#fc8181;}
|
||||
.checkpoint-diff{position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;}
|
||||
.checkpoint-diff-modal{background:var(--surface);border:1px solid var(--border);border-radius:8px;max-width:700px;width:90%;max-height:80vh;display:flex;flex-direction:column;}
|
||||
.checkpoint-diff-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);}
|
||||
.checkpoint-diff-body{padding:12px 16px;overflow-y:auto;flex:1;}
|
||||
.checkpoint-diff-body pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-all;}
|
||||
|
||||
Reference in New Issue
Block a user