mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge pull request #2332 into stage-362
feat: show cron output usage metadata (Michaelyklam)
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2332** by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model, token, cost, and duration frontmatter from cron output files and returns it from `/api/crons/history` and `/api/crons/run`; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata.
|
||||
|
||||
- **PR #2319** by @Michaelyklam — Chat file uploads now land in a session-scoped attachment inbox instead of cluttering the active workspace root. By default uploads are stored under `~/.hermes/webui/attachments/<session_id>/`; operators can override the root with `HERMES_WEBUI_ATTACHMENT_DIR`, and the agent still receives the absolute uploaded file path for context. Archive extraction stays workspace-scoped (it's an explicit workspace operation). README updated to document the new default location. **Stage-361 maintainer fix applied inline**: Opus advisor caught that `_build_native_multimodal_message` at `api/streaming.py:787` required uploads to be under `workspace_root`, which would have silently dropped every image upload for vision-capable models once the inbox moved outside the workspace. The fix adds `_attachment_root()` (from `api/upload.py`) as a second allowed location, with 3 regression tests covering the new code path AND verifying the original workspace + cross-root rejection paths still work.
|
||||
|
||||
### Fixed
|
||||
|
||||
+64
-1
@@ -6816,10 +6816,14 @@ def _handle_cron_history(handler, parsed):
|
||||
for f in page:
|
||||
try:
|
||||
st = f.stat()
|
||||
usage = _cron_output_usage_metadata(
|
||||
f.read_text(encoding="utf-8", errors="replace")
|
||||
)
|
||||
runs.append({
|
||||
"filename": f.name,
|
||||
"size": st.st_size,
|
||||
"modified": st.st_mtime,
|
||||
"usage": usage,
|
||||
})
|
||||
except OSError:
|
||||
logger.debug("Failed to stat cron output file %s", f)
|
||||
@@ -6851,12 +6855,71 @@ def _handle_cron_run_detail(handler, parsed):
|
||||
try:
|
||||
content = fpath.read_text(encoding="utf-8", errors="replace")
|
||||
snippet = _cron_output_snippet(content)
|
||||
usage = _cron_output_usage_metadata(content)
|
||||
return j(handler, {"job_id": job_id, "filename": filename,
|
||||
"content": content, "snippet": snippet})
|
||||
"content": content, "snippet": snippet,
|
||||
"usage": usage})
|
||||
except Exception as e:
|
||||
return j(handler, {"error": str(e)}, status=500)
|
||||
|
||||
|
||||
def _cron_output_usage_metadata(text: str) -> dict:
|
||||
"""Extract optional token/cost metadata from a cron output markdown file."""
|
||||
import re as _re
|
||||
|
||||
head = text.split("## Response", 1)[0].split("# Response", 1)[0]
|
||||
usage: dict = {}
|
||||
|
||||
def _intish(value: str):
|
||||
cleaned = _re.sub(r"[^0-9]", "", value or "")
|
||||
return int(cleaned) if cleaned else None
|
||||
|
||||
def _floatish(value: str):
|
||||
match = _re.search(r"[-+]?\d+(?:\.\d+)?", (value or "").replace(",", ""))
|
||||
return float(match.group(0)) if match else None
|
||||
|
||||
for raw_line in head.splitlines():
|
||||
line = raw_line.strip()
|
||||
model_match = _re.match(r"\*\*(?:Model|Model Used):\*\*\s*(.+)$", line, _re.I)
|
||||
if model_match:
|
||||
usage["model"] = model_match.group(1).strip()
|
||||
continue
|
||||
provider_match = _re.match(r"\*\*Provider:\*\*\s*(.+)$", line, _re.I)
|
||||
if provider_match:
|
||||
usage["provider"] = provider_match.group(1).strip()
|
||||
continue
|
||||
cost_match = _re.match(r"\*\*(?:Estimated cost|Cost):\*\*\s*(.+)$", line, _re.I)
|
||||
if cost_match:
|
||||
cost = _floatish(cost_match.group(1))
|
||||
if cost is not None:
|
||||
usage["estimated_cost_usd"] = cost
|
||||
continue
|
||||
duration_match = _re.match(r"\*\*(?:Duration|Elapsed):\*\*\s*(.+)$", line, _re.I)
|
||||
if duration_match:
|
||||
seconds = _floatish(duration_match.group(1))
|
||||
if seconds is not None:
|
||||
usage["duration_seconds"] = seconds
|
||||
continue
|
||||
tokens_match = _re.match(r"\*\*Tokens:\*\*\s*(.+)$", line, _re.I)
|
||||
if tokens_match:
|
||||
value = tokens_match.group(1)
|
||||
input_match = _re.search(r"([0-9][0-9,]*)\s*(?:input|in)\b", value, _re.I)
|
||||
output_match = _re.search(r"([0-9][0-9,]*)\s*(?:output|out)\b", value, _re.I)
|
||||
total_match = _re.search(r"([0-9][0-9,]*)\s*(?:total\s*)?tokens?\b", value, _re.I)
|
||||
if input_match:
|
||||
usage["input_tokens"] = _intish(input_match.group(1))
|
||||
if output_match:
|
||||
usage["output_tokens"] = _intish(output_match.group(1))
|
||||
if total_match and "total_tokens" not in usage:
|
||||
usage["total_tokens"] = _intish(total_match.group(1))
|
||||
|
||||
if "total_tokens" not in usage:
|
||||
total = sum(int(usage.get(k) or 0) for k in ("input_tokens", "output_tokens"))
|
||||
if total:
|
||||
usage["total_tokens"] = total
|
||||
return usage
|
||||
|
||||
|
||||
def _cron_output_snippet(text: str, limit: int = 600) -> str:
|
||||
"""Extract the response body from a cron output .md file for preview.
|
||||
|
||||
|
||||
+30
-1
@@ -623,11 +623,12 @@ async function _loadCronDetailRuns(jobId){
|
||||
const sizeStr = run.size > 1024 ? (run.size/1024).toFixed(1)+' KB' : run.size+' B';
|
||||
const dateStr = new Date(run.modified * 1000).toLocaleString();
|
||||
const rid = `cron-det-run-${jobId}-${i}`;
|
||||
const usageStrip = _formatCronRunUsageStrip(run.usage);
|
||||
const runExpanded = _cronExpansionGet(_cronRunExpandKey(jobId, run.filename));
|
||||
const runToggleLabel = runExpanded ? (t('cron_collapse_output') || 'Collapse output') : (t('cron_expand_output') || 'Expand output');
|
||||
return `<div class="detail-run-item" id="${rid}">
|
||||
<div class="detail-run-head" onclick="_loadRunContent('${esc(jobId)}','${esc(run.filename)}','${rid}')">
|
||||
<span><span style="opacity:.7">${esc(ts)}</span> <span style="opacity:.4;font-size:11px">${esc(sizeStr)}</span></span>
|
||||
<span><span style="opacity:.7">${esc(ts)}</span> <span style="opacity:.4;font-size:11px">${esc(sizeStr)}</span>${usageStrip ? ` <span class="cron-run-usage-strip">${esc(usageStrip)}</span>` : ''}</span>
|
||||
<span class="detail-run-actions">
|
||||
<button type="button" class="detail-expand-toggle" onclick="event.stopPropagation();toggleCronRunExpanded('${esc(jobId)}','${esc(run.filename)}','${rid}')" title="${esc(runToggleLabel)}" aria-label="${esc(runToggleLabel)}">${esc(runExpanded ? '▴' : '▾')}</button>
|
||||
<span style="opacity:.6">▸</span>
|
||||
@@ -662,6 +663,13 @@ async function _loadRunContent(jobId, filename, runId){
|
||||
} else {
|
||||
body.textContent = data.snippet || data.content;
|
||||
}
|
||||
const usageStrip = _formatCronRunUsageStrip(data.usage);
|
||||
if (usageStrip) {
|
||||
const usage = document.createElement('div');
|
||||
usage.className = 'cron-run-usage-strip cron-run-usage-footer';
|
||||
usage.textContent = usageStrip;
|
||||
body.appendChild(usage);
|
||||
}
|
||||
// Show "View full output" button if content was truncated
|
||||
if (data.content && data.snippet && data.content.length > data.snippet.length) {
|
||||
const btn = document.createElement('button');
|
||||
@@ -1011,6 +1019,27 @@ function _cronOutputSnippet(content) {
|
||||
return body.slice(0, 600) || '(empty)';
|
||||
}
|
||||
|
||||
function _formatCronRunUsageStrip(usage) {
|
||||
if (!usage || typeof usage !== 'object') return '';
|
||||
const parts = [];
|
||||
const fmt = n => {
|
||||
const value = Number(n || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
if (value >= 1000000) return (value / 1000000).toFixed(value >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
|
||||
if (value >= 1000) return (value / 1000).toFixed(value >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k';
|
||||
return String(Math.round(value));
|
||||
};
|
||||
const input = fmt(usage.input_tokens);
|
||||
const output = fmt(usage.output_tokens);
|
||||
const total = fmt(usage.total_tokens);
|
||||
if (input || output) parts.push(`${input || '0'} in · ${output || '0'} out`);
|
||||
else if (total) parts.push(`${total} tokens`);
|
||||
const cost = Number(usage.estimated_cost_usd);
|
||||
if (Number.isFinite(cost) && cost > 0) parts.push(`$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`);
|
||||
if (usage.model) parts.push(String(usage.model));
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// ── Cron run watch ────────────────────────────────────────────────────────────
|
||||
let _cronWatchInterval = null;
|
||||
let _cronWatchStart = null;
|
||||
|
||||
@@ -3459,6 +3459,8 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.detail-run-body{display:none;margin-top:6px;font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.5;max-height:260px;overflow-y:auto;background:var(--sidebar);border:1px solid var(--border);border-radius:6px;padding:8px 10px;}
|
||||
.detail-run-item.open .detail-run-body{display:block;}
|
||||
.detail-run-body.expanded{max-height:none;overflow-y:visible;}
|
||||
.cron-run-usage-strip{display:inline-flex;align-items:center;gap:4px;margin-left:8px;color:var(--text-secondary);font-size:11px;opacity:.72;white-space:nowrap;}
|
||||
.cron-run-usage-footer{display:flex;margin:8px 0 0 0;padding-top:8px;border-top:1px solid var(--border-subtle);}
|
||||
.cron-item.active,.ws-row.active,.profile-card.active{background:var(--accent-bg);}
|
||||
.cron-item.active .cron-name,.ws-row.active .ws-row-name,.profile-card.active .profile-card-name{color:var(--accent-text);}
|
||||
|
||||
|
||||
@@ -116,6 +116,40 @@ def test_cron_output_snippet_helper(cleanup_test_sessions):
|
||||
assert "_cronOutputSnippet" in src
|
||||
|
||||
|
||||
def test_cron_output_usage_metadata_parses_optional_fields(cleanup_test_sessions):
|
||||
from api.routes import _cron_output_usage_metadata
|
||||
|
||||
content = "\n".join([
|
||||
"# Cron Job: Nightly",
|
||||
"**Model:** openai-codex/gpt-5.5",
|
||||
"**Tokens:** 12,345 in / 678 out",
|
||||
"**Estimated cost:** $0.0123 (estimated)",
|
||||
"**Duration:** 42.5s",
|
||||
"",
|
||||
"## Response",
|
||||
"Done",
|
||||
])
|
||||
|
||||
usage = _cron_output_usage_metadata(content)
|
||||
|
||||
assert usage["model"] == "openai-codex/gpt-5.5"
|
||||
assert usage["input_tokens"] == 12345
|
||||
assert usage["output_tokens"] == 678
|
||||
assert usage["total_tokens"] == 13023
|
||||
assert usage["estimated_cost_usd"] == 0.0123
|
||||
assert usage["duration_seconds"] == 42.5
|
||||
|
||||
|
||||
def test_cron_output_usage_strip_render_hook(cleanup_test_sessions):
|
||||
src, _ = get_text("/static/panels.js")
|
||||
css, _ = get_text("/static/style.css")
|
||||
|
||||
assert "_formatCronRunUsageStrip(run.usage)" in src
|
||||
assert "_formatCronRunUsageStrip(data.usage)" in src
|
||||
assert "cron-run-usage-strip" in src
|
||||
assert ".cron-run-usage-strip" in css
|
||||
|
||||
|
||||
def test_cron_output_window_preserves_response_after_large_prompt(cleanup_test_sessions):
|
||||
"""Large skill dumps before ## Response must not hide the useful output."""
|
||||
from api.routes import _cron_output_content_window
|
||||
|
||||
Reference in New Issue
Block a user