diff --git a/app/logger.py b/app/logger.py index 661bd81..829bcef 100644 --- a/app/logger.py +++ b/app/logger.py @@ -1,25 +1,24 @@ """ logger.py — Session log handler for Save Token. -Logs expanded (readable English) content only — never raw caveman text. -One file per session. New file when app starts OR when gap > 30 minutes. -File naming: logs/MMDDYYYY/MMDDYYYY_HHMMSS.txt (Windows-safe, no colons). +One file per session. Logs both compress and expand operations in sequence. +File named: logs/YYYY-MM-DD/YYYY-MM-DD_HH-MM.txt +Session starts on first operation after app launch. +Session ends when the window/process is closed. """ import os -import re -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path -# Repo root is one level up from this file REPO_ROOT = Path(__file__).parent.parent LOGS_DIR = REPO_ROOT / "logs" -# New session if gap between entries exceeds this many seconds -SESSION_GAP_SECONDS = 30 * 60 # 30 minutes +SESSION_GAP_SECONDS = 30 * 60 # 30 minutes idle = new session _current_log_path: Path | None = None _last_log_time: datetime | None = None +_session_start_time: datetime | None = None def _now() -> datetime: @@ -27,71 +26,83 @@ def _now() -> datetime: def _make_log_path(dt: datetime) -> Path: - """ - Build log path: logs/MMDDYYYY/MMDDYYYY_HHMMSS.txt - """ - date_folder = dt.strftime("%m%d%Y") - filename = dt.strftime("%m%d%Y_%H%M%S") + ".txt" + date_folder = dt.strftime("%Y-%m-%d") + filename = dt.strftime("%Y-%m-%d_%H-%M") + ".txt" return LOGS_DIR / date_folder / filename -def _ensure_new_session() -> Path: - """ - Decide whether to start a new log file. - Start new session if: - - No current log file exists, or - - Gap since last entry exceeds SESSION_GAP_SECONDS - """ - global _current_log_path, _last_log_time +def _ensure_session() -> Path: + global _current_log_path, _last_log_time, _session_start_time now = _now() if _current_log_path is None or _last_log_time is None: _current_log_path = _make_log_path(now) + _session_start_time = now + _current_log_path.parent.mkdir(parents=True, exist_ok=True) + # Write session header + with open(_current_log_path, "a", encoding="utf-8") as f: + f.write(f"{'='*56}\n") + f.write(f" Save Token Session — {now.strftime('%Y-%m-%d %H:%M')}\n") + f.write(f"{'='*56}\n\n") else: gap = (now - _last_log_time).total_seconds() if gap > SESSION_GAP_SECONDS: _current_log_path = _make_log_path(now) + _session_start_time = now + _current_log_path.parent.mkdir(parents=True, exist_ok=True) + with open(_current_log_path, "a", encoding="utf-8") as f: + f.write(f"{'='*56}\n") + f.write(f" Save Token Session — {now.strftime('%Y-%m-%d %H:%M')}\n") + f.write(f"{'='*56}\n\n") - _current_log_path.parent.mkdir(parents=True, exist_ok=True) return _current_log_path -def log_entry( - original_compressed: str, - expanded: str, - model_used: str, - route: str, - mode: str, -) -> None: - """ - Append a log entry to the current session file. - - Format: - ---------------------------------------- - [04/24/2026 14:32:01] - Model: gemma3:12b | Route: chat | Mode: full - Compressed input: - Expanded output: - - ---------------------------------------- - """ +def log_compress(original: str, compressed: str, model_used: str, words_saved: int, percent_saved: int) -> None: + """Log a compression operation.""" global _last_log_time - log_path = _ensure_new_session() + log_path = _ensure_session() now = _now() _last_log_time = now - timestamp = now.strftime("%m/%d/%Y %H:%M:%S") - separator = "-" * 48 + timestamp = now.strftime("%H:%M:%S") + sep = "-" * 48 entry = ( - f"\n{separator}\n" - f"[{timestamp}]\n" - f"Model: {model_used} | Route: {route} | Mode: {mode}\n" - f"Compressed input: {original_compressed.strip()}\n" - f"Expanded output:\n{expanded.strip()}\n" - f"{separator}\n" + f"\n[{timestamp}] COMPRESS ({words_saved} words saved, {percent_saved}% reduction)\n" + f"Model: {model_used}\n" + f"{sep}\n" + f"ORIGINAL:\n{original.strip()}\n" + f"{sep}\n" + f"COMPRESSED:\n{compressed.strip()}\n" + f"{sep}\n" + ) + + with open(log_path, "a", encoding="utf-8") as f: + f.write(entry) + + +def log_expand(compressed_input: str, expanded: str, model_used: str) -> None: + """Log an expansion operation.""" + global _last_log_time + + log_path = _ensure_session() + now = _now() + _last_log_time = now + + timestamp = now.strftime("%H:%M:%S") + sep = "-" * 48 + + entry = ( + f"\n[{timestamp}] EXPAND\n" + f"Model: {model_used}\n" + f"{sep}\n" + f"COMPRESSED INPUT:\n{compressed_input.strip()}\n" + f"{sep}\n" + f"EXPANDED OUTPUT:\n{expanded.strip()}\n" + f"{sep}\n" ) with open(log_path, "a", encoding="utf-8") as f: @@ -99,14 +110,18 @@ def log_entry( def get_log_path() -> str: - """Return the current log file path as a string (for status display).""" if _current_log_path is None: return "No log file yet" return str(_current_log_path) +def get_logs_dir() -> str: + return str(LOGS_DIR) + + def reset_session() -> None: """Force-start a new log session (called on app startup).""" - global _current_log_path, _last_log_time + global _current_log_path, _last_log_time, _session_start_time _current_log_path = None _last_log_time = None + _session_start_time = None diff --git a/app/server.py b/app/server.py index 97869ae..18ab743 100644 --- a/app/server.py +++ b/app/server.py @@ -227,6 +227,18 @@ async def compress(req: CompressRequest): savings = calculate_savings(req.text, compressed) + # Log the compression + try: + session_logger.log_compress( + original=req.text, + compressed=compressed, + model_used=model, + words_saved=savings["words_saved"], + percent_saved=savings["percent_saved"], + ) + except Exception: + pass + return CompressResponse( compressed=compressed, original_words=savings["original_words"], @@ -265,14 +277,12 @@ async def expand(req: ExpandRequest): except RuntimeError as e: raise HTTPException(status_code=503, detail=str(e)) - # Log the expansion (expanded text only, never caveman) + # Log the expansion try: - session_logger.log_entry( - original_compressed=req.text, + session_logger.log_expand( + compressed_input=req.text, expanded=expanded, model_used=model, - route=route, - mode="expand", ) except Exception: pass # Never let logging errors crash the app diff --git a/frontend/app.js b/frontend/app.js index 93f8c4c..c93c060 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -130,6 +130,86 @@ function hideError() { errorBanner.classList.remove("visible"); } +// ── Theme toggle ────────────────────────────────────────────────────────────── +const themeToggleBtn = document.getElementById('theme-toggle'); +const html = document.documentElement; + +function applyTheme(theme) { + html.setAttribute('data-theme', theme); + themeToggleBtn.textContent = theme === 'dark' ? '☀️' : '🌙'; + localStorage.setItem('save-token-theme', theme); +} + +// Load saved theme on startup +const savedTheme = localStorage.getItem('save-token-theme') || 'light'; +applyTheme(savedTheme); + +themeToggleBtn.addEventListener('click', () => { + const current = html.getAttribute('data-theme'); + applyTheme(current === 'dark' ? 'light' : 'dark'); +}); + +// ── System prompt modal ─────────────────────────────────────────────────────── +const promptModal = document.getElementById('prompt-modal'); +const modalCodeContent = document.getElementById('modal-code-content'); +const modalTitle = document.getElementById('modal-title'); +const modalCopyBtn = document.getElementById('modal-copy-btn'); +const modalCloseBtn = document.getElementById('modal-close-btn'); +const appTitleBtn = document.getElementById('app-title-btn'); + +async function openPromptModal() { + const selected = variantSelect.options[variantSelect.selectedIndex]; + if (!selected || !selected.dataset.file) { + modalTitle.textContent = 'System Prompt'; + modalCodeContent.textContent = 'Select a model from the dropdown first, then click 💰 to see its system prompt.'; + promptModal.style.display = 'flex'; + return; + } + const mdFile = selected.dataset.file; + modalTitle.textContent = `System Prompt — ${selected.textContent.trim()}`; + modalCodeContent.textContent = 'Loading...'; + promptModal.style.display = 'flex'; + try { + const resp = await fetch(`/model-persona/${mdFile}`); + if (!resp.ok) throw new Error('Not found'); + const text = await resp.text(); + modalCodeContent.textContent = text; + } catch (e) { + modalCodeContent.textContent = `Could not load ${mdFile}. Make sure the server is running.`; + } +} + +appTitleBtn.addEventListener('click', openPromptModal); + +modalCloseBtn.addEventListener('click', () => { + promptModal.style.display = 'none'; +}); + +promptModal.addEventListener('click', (e) => { + if (e.target === promptModal) promptModal.style.display = 'none'; +}); + +modalCopyBtn.addEventListener('click', async () => { + const content = modalCodeContent.textContent; + if (!content || content === 'Loading...' + || content === 'Select a model from the dropdown first, then click 💰 to see its system prompt.' + || content.startsWith('Could not load ')) return; + try { + await navigator.clipboard.writeText(content); + modalCopyBtn.textContent = '✓ Copied!'; + setTimeout(() => { modalCopyBtn.textContent = '📋 Copy'; }, 2000); + } catch (e) { + showError('Could not copy. Please select the text manually and press Ctrl+C.'); + } +}); + +// Close modal with Escape key +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && promptModal.style.display !== 'none') { + promptModal.style.display = 'none'; + } +}); + // ── Model Family / Variant dropdowns ──────────────────────────────────────── function buildFamilyDropdown() { diff --git a/frontend/index.html b/frontend/index.html index aaae685..408018e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + @@ -11,7 +11,8 @@
-
💰 Save Token
+ +
@@ -33,6 +34,21 @@
+ + + diff --git a/frontend/style.css b/frontend/style.css index 971083f..948f8be 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -1,5 +1,6 @@ /* style.css — Save Token UI */ +/* ── Light theme (default) ───────────────────────── */ :root { --bg: #f0f2f5; --bg-card: #f8f9fa; @@ -21,6 +22,25 @@ --mono: 'Consolas', 'Courier New', monospace; } +/* ── Dark theme ──────────────────────────────────── */ +[data-theme="dark"] { + --bg: #1a1a2e; + --bg-card: #16213e; + --bg-input: #0f3460; + --accent: #4d9de0; + --accent-hover: #3a7bc8; + --accent-success: #2ecc71; + --accent-success-hover: #27ae60; + --accent-warn: #f39c12; + --text: #d1d5db; + --text-dim: #9ca3af; + --text-muted: #6b7280; + --border: #2a2a4a; + --border-light: #3a3a5a; + --checkpoint-bg: #0d2137; + --checkpoint-border: #4d9de0; +} + *, *::before, *::after { box-sizing: border-box; margin: 0; @@ -61,10 +81,33 @@ html, body { } .app-title { + background: none; + border: none; + cursor: pointer; font-size: 1.7rem; font-weight: 700; letter-spacing: -0.3px; color: var(--text); + padding: 0; + font-family: var(--font); +} +.app-title:hover { opacity: 0.8; } + +/* ── Theme toggle button ─────────────────────────── */ +.theme-toggle-btn { + background: none; + border: 1px solid var(--border-light); + border-radius: var(--radius); + color: var(--text-dim); + cursor: pointer; + font-size: 18px; + padding: 4px 10px; + line-height: 1; + transition: border-color 0.2s, background 0.2s; +} +.theme-toggle-btn:hover { + border-color: var(--accent); + background: var(--bg-card); } .topbar-controls { @@ -462,6 +505,89 @@ select:focus, select:hover { display: block; } +/* ── Modal ───────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal-box { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + width: 100%; + max-width: 680px; + max-height: 80vh; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-title { + font-size: 16px; + font-weight: 700; + color: var(--text); +} + +.modal-close-btn { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 18px; + padding: 0 4px; + line-height: 1; +} +.modal-close-btn:hover { color: var(--text); } + +.modal-hint { + font-size: 14px; + color: var(--text-dim); + margin: 0; +} + +.modal-code-wrapper { + position: relative; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 8px; +} + +.modal-code { + background: var(--bg-input); + border: 1px solid var(--border-light); + border-radius: var(--radius); + padding: 12px; + font-size: 13px; + font-family: var(--mono); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + overflow-y: auto; + max-height: 50vh; + margin: 0; +} + +.modal-copy-btn { + align-self: flex-end; +} + /* ── Scrollbar ───────────────────────────────────── */ ::-webkit-scrollbar { diff --git a/launch.py b/launch.py index 4857d55..8415f18 100644 --- a/launch.py +++ b/launch.py @@ -4,8 +4,9 @@ Usage: python launch.py -This opens the app in a native window (not a browser tab). -The window appears in the Windows taskbar when minimised. +Opens the app as a native window in the Windows taskbar (not a browser tab). +The window minimises to the taskbar. Closing the window ends the session and +finalises the log file. """ import threading @@ -13,11 +14,12 @@ import sys from pathlib import Path -# Add repo root to path sys.path.insert(0, str(Path(__file__).parent)) import uvicorn import webview +from app import logger as session_logger + def start_server(): uvicorn.run( @@ -28,20 +30,19 @@ def start_server(): log_level="warning", ) + if __name__ == "__main__": - # Start FastAPI in background thread + session_logger.reset_session() + server_thread = threading.Thread(target=start_server, daemon=True) server_thread.start() - - # Wait for server to be ready time.sleep(1.5) - # Open native desktop window webview.create_window( title="💰 Save Token", url="http://127.0.0.1:8000", - width=1200, - height=800, + width=1280, + height=900, min_size=(800, 600), resizable=True, )