Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 67 additions & 52 deletions app/logger.py
Original file line number Diff line number Diff line change
@@ -1,112 +1,127 @@
"""
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:
return datetime.now()


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: <compressed text>
Expanded output:
<expanded readable English>
----------------------------------------
"""
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:
f.write(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
20 changes: 15 additions & 5 deletions app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
20 changes: 18 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand All @@ -11,7 +11,8 @@

<!-- ── Top bar ──────────────────────────────────────────────── -->
<div class="topbar">
<div class="app-title">πŸ’° Save Token</div>
<button id="app-title-btn" class="app-title" title="Click to view system prompt for selected model">πŸ’° Save Token</button>
<button id="theme-toggle" class="theme-toggle-btn" title="Toggle dark/light mode" aria-label="Toggle dark/light mode">πŸŒ™</button>
<div class="topbar-controls">
<!-- Target AI family + variant dropdowns -->
<select id="family-select" title="Select target AI family" aria-label="Target AI family"></select>
Expand All @@ -33,6 +34,21 @@
</div>
</div>

<!-- ── System prompt modal ──────────────────────────────────────── -->
<div id="prompt-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-label="Model system prompt" style="display:none">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title" id="modal-title">System Prompt</span>
<button class="modal-close-btn" id="modal-close-btn" aria-label="Close">βœ•</button>
</div>
<p class="modal-hint">Paste this as your <strong>first message</strong> or <strong>system prompt</strong> before starting a conversation with this model.</p>
<div class="modal-code-wrapper">
<pre class="modal-code" id="modal-code-content">Select a model from the dropdown first.</pre>
<button class="btn btn-primary modal-copy-btn" id="modal-copy-btn">πŸ“‹ Copy</button>
</div>
</div>
</div>

<!-- ── Error banner ─────────────────────────────────────────── -->
<div id="error-banner" class="error-banner" role="alert"></div>

Expand Down
Loading