feat: token usage toggle (setting + /usage command) + timestamp fixes
Token usage display: - Add 'show_token_usage' boolean to settings (default: false, off by default) - Settings panel: checkbox 'Show token usage after responses' - /usage slash command: instant toggle with toast feedback, persists to server, updates checkbox if settings panel is open, re-renders messages - Boot: load show_token_usage alongside send_key on startup - ui.js: gate usage badge on window._showTokenUsage flag Timestamps: - streaming.py: stamp 'timestamp' on every message that lacks one at conversation completion; old messages (no timestamp field) now get a wall-clock time the first time they're touched by a new turn - messages.js: stamp _ts on the last assistant message at done-event time so the time shows immediately on the current turn before next reload - Timestamps already render in the UI (Sprint 14): faint time on each role header line, full opacity on hover, full date in title tooltip
This commit is contained in:
@@ -632,6 +632,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
'default_model': DEFAULT_MODEL,
|
'default_model': DEFAULT_MODEL,
|
||||||
'default_workspace': str(DEFAULT_WORKSPACE),
|
'default_workspace': str(DEFAULT_WORKSPACE),
|
||||||
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
||||||
|
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +652,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
|||||||
_SETTINGS_ENUM_VALUES = {
|
_SETTINGS_ENUM_VALUES = {
|
||||||
'send_key': {'enter', 'ctrl+enter'},
|
'send_key': {'enter', 'ctrl+enter'},
|
||||||
}
|
}
|
||||||
|
_SETTINGS_BOOL_KEYS = {'show_token_usage'}
|
||||||
|
|
||||||
def save_settings(settings: dict) -> dict:
|
def save_settings(settings: dict) -> dict:
|
||||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||||
@@ -669,6 +671,9 @@ def save_settings(settings: dict) -> dict:
|
|||||||
# Validate enum-constrained keys
|
# Validate enum-constrained keys
|
||||||
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
|
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
|
||||||
continue
|
continue
|
||||||
|
# Coerce bool keys
|
||||||
|
if k in _SETTINGS_BOOL_KEYS:
|
||||||
|
v = bool(v)
|
||||||
current[k] = v
|
current[k] = v
|
||||||
SETTINGS_FILE.write_text(
|
SETTINGS_FILE.write_text(
|
||||||
json.dumps(current, ensure_ascii=False, indent=2),
|
json.dumps(current, ensure_ascii=False, indent=2),
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
|||||||
persist_user_message=msg_text,
|
persist_user_message=msg_text,
|
||||||
)
|
)
|
||||||
s.messages = result.get('messages') or s.messages
|
s.messages = result.get('messages') or s.messages
|
||||||
|
# Stamp 'timestamp' on any messages that don't have one yet
|
||||||
|
_now = time.time()
|
||||||
|
for _m in s.messages:
|
||||||
|
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
|
||||||
|
_m['timestamp'] = int(_now)
|
||||||
s.title = title_from(s.messages, s.title)
|
s.title = title_from(s.messages, s.title)
|
||||||
# Read token/cost usage from the agent object (if available)
|
# Read token/cost usage from the agent object (if available)
|
||||||
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
|
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
|||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
// Load send key preference
|
// Load send key preference
|
||||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';}
|
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;}catch(e){window._sendKey='enter';window._showTokenUsage=false;}
|
||||||
// Fetch active profile
|
// Fetch active profile
|
||||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||||
// Update profile chip label immediately
|
// Update profile chip label immediately
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const COMMANDS=[
|
|||||||
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
|
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
|
||||||
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
||||||
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
||||||
|
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseCommand(text){
|
function parseCommand(text){
|
||||||
@@ -98,6 +99,19 @@ async function cmdNew(){
|
|||||||
showToast('New session created');
|
showToast('New session created');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cmdUsage(){
|
||||||
|
const next=!window._showTokenUsage;
|
||||||
|
window._showTokenUsage=next;
|
||||||
|
try{
|
||||||
|
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
|
||||||
|
}catch(e){}
|
||||||
|
// Update the settings checkbox if the panel is open
|
||||||
|
const cb=$('settingsShowTokenUsage');
|
||||||
|
if(cb) cb.checked=next;
|
||||||
|
renderMessages();
|
||||||
|
showToast('Token usage '+(next?'on':'off'));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||||
|
|
||||||
let _cmdSelectedIdx=-1;
|
let _cmdSelectedIdx=-1;
|
||||||
|
|||||||
@@ -324,6 +324,13 @@
|
|||||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
Show token usage after responses
|
||||||
|
</label>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||||
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword">Access Password</label>
|
<label for="settingsPassword">Access Password</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ async function send(){
|
|||||||
}
|
}
|
||||||
if(S.session&&S.session.session_id===activeSid){
|
if(S.session&&S.session.session_id===activeSid){
|
||||||
S.session=d.session;S.messages=d.session.messages||[];
|
S.session=d.session;S.messages=d.session.messages||[];
|
||||||
|
// Stamp _ts on the last assistant message if it has no timestamp
|
||||||
|
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
|
||||||
|
if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
|
||||||
if(d.usage) S.lastUsage=d.usage;
|
if(d.usage) S.lastUsage=d.usage;
|
||||||
if(d.session.tool_calls&&d.session.tool_calls.length){
|
if(d.session.tool_calls&&d.session.tool_calls.length){
|
||||||
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
|
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
|
||||||
|
|||||||
@@ -958,6 +958,8 @@ async function loadSettingsPanel(){
|
|||||||
// Send key preference
|
// Send key preference
|
||||||
const sendKeySel=$('settingsSendKey');
|
const sendKeySel=$('settingsSendKey');
|
||||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
||||||
|
const showUsageCb=$('settingsShowTokenUsage');
|
||||||
|
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
|
||||||
// Password field: always blank (we don't send hash back)
|
// Password field: always blank (we don't send hash back)
|
||||||
const pwField=$('settingsPassword');
|
const pwField=$('settingsPassword');
|
||||||
if(pwField) pwField.value='';
|
if(pwField) pwField.value='';
|
||||||
@@ -979,16 +981,19 @@ async function saveSettings(){
|
|||||||
const model=($('settingsModel')||{}).value;
|
const model=($('settingsModel')||{}).value;
|
||||||
const workspace=($('settingsWorkspace')||{}).value;
|
const workspace=($('settingsWorkspace')||{}).value;
|
||||||
const sendKey=($('settingsSendKey')||{}).value;
|
const sendKey=($('settingsSendKey')||{}).value;
|
||||||
|
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||||
const pw=($('settingsPassword')||{}).value;
|
const pw=($('settingsPassword')||{}).value;
|
||||||
const body={};
|
const body={};
|
||||||
if(model) body.default_model=model;
|
if(model) body.default_model=model;
|
||||||
if(workspace) body.default_workspace=workspace;
|
if(workspace) body.default_workspace=workspace;
|
||||||
if(sendKey) body.send_key=sendKey;
|
if(sendKey) body.send_key=sendKey;
|
||||||
|
body.show_token_usage=showTokenUsage;
|
||||||
// Password: only act if the field has content; blank = leave auth unchanged
|
// Password: only act if the field has content; blank = leave auth unchanged
|
||||||
if(pw && pw.trim()){
|
if(pw && pw.trim()){
|
||||||
try{
|
try{
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||||||
window._sendKey=sendKey||'enter';
|
window._sendKey=sendKey||'enter';
|
||||||
|
window._showTokenUsage=showTokenUsage;
|
||||||
showToast('Settings saved (password set — login now required)');
|
showToast('Settings saved (password set — login now required)');
|
||||||
toggleSettings();
|
toggleSettings();
|
||||||
return;
|
return;
|
||||||
@@ -997,6 +1002,8 @@ async function saveSettings(){
|
|||||||
try{
|
try{
|
||||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||||
window._sendKey=sendKey||'enter';
|
window._sendKey=sendKey||'enter';
|
||||||
|
window._showTokenUsage=showTokenUsage;
|
||||||
|
renderMessages();
|
||||||
showToast('Settings saved');
|
showToast('Settings saved');
|
||||||
toggleSettings();
|
toggleSettings();
|
||||||
}catch(e){
|
}catch(e){
|
||||||
|
|||||||
@@ -488,8 +488,8 @@ function renderMessages(){
|
|||||||
else inner.appendChild(frag);
|
else inner.appendChild(frag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render usage badge on the last assistant message row (if usage data exists)
|
// Render usage badge on the last assistant message row (if enabled and usage data exists)
|
||||||
if(S.session&&(S.session.input_tokens||S.session.output_tokens)){
|
if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){
|
||||||
const rows=inner.querySelectorAll('.msg-row');
|
const rows=inner.querySelectorAll('.msg-row');
|
||||||
let lastAssist=null;
|
let lastAssist=null;
|
||||||
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}
|
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}
|
||||||
|
|||||||
Reference in New Issue
Block a user