From f7ac45ddd807aa126723120814e2852cea58370c Mon Sep 17 00:00:00 2001 From: HRiggs Date: Tue, 5 May 2026 19:53:21 -0400 Subject: [PATCH] tracking: remove memorystore, ux: change streaming area, more agent markdown formating. --- .gitignore | 3 + tests/test_agent.py | 16 +++ traderai/agent.py | 23 +++++ web/app.js | 246 ++++++++++++++++++++++++++++++++++++-------- web/index.html | 1 - web/styles.css | 107 +++++++++++++++++-- 6 files changed, 348 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 95b7750..13b0a04 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ coverage.xml # FastAPI/Uvicorn/runtime files *.log *.pid +data/ +*.sqlite3 +*.sqlite3-* # Frontend dependencies and build artifacts node_modules/ diff --git a/tests/test_agent.py b/tests/test_agent.py index ac92022..cecee13 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -61,3 +61,19 @@ def test_runtime_context_includes_uex_user_identity(tmp_path): assert "timezone: America/New_York" in context assert "specializations: trading,hauling" in context assert "hudson@example.test" not in context + + +def test_stream_metrics_include_reading_and_writing_rates(): + metrics = OllamaAgent._stream_metrics( + { + "prompt_eval_count": 20, + "prompt_eval_duration": 2_000_000_000, + "eval_count": 30, + "eval_duration": 3_000_000_000, + } + ) + + assert metrics["reading_tokens"] == 20 + assert metrics["reading_tokens_per_second"] == 10 + assert metrics["writing_tokens"] == 30 + assert metrics["writing_tokens_per_second"] == 10 diff --git a/traderai/agent.py b/traderai/agent.py index e7542fe..643bcde 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -116,6 +116,10 @@ class OllamaAgent: yield {"type": "token", "content": chunk} if message.get("tool_calls"): tool_calls.extend(message["tool_calls"]) + if event.get("done"): + metrics = self._stream_metrics(event) + if metrics: + yield {"type": "metrics", **metrics} if not tool_calls: self.messages.append(assistant_message) @@ -266,6 +270,25 @@ class OllamaAgent: } return labels.get(name, f"Running {name}") + @staticmethod + def _stream_metrics(event: dict[str, Any]) -> dict[str, Any]: + prompt_tokens = int(event.get("prompt_eval_count") or 0) + prompt_duration = int(event.get("prompt_eval_duration") or 0) + output_tokens = int(event.get("eval_count") or 0) + output_duration = int(event.get("eval_duration") or 0) + + def rate(tokens: int, duration_ns: int) -> float | None: + if not tokens or not duration_ns: + return None + return tokens / (duration_ns / 1_000_000_000) + + return { + "reading_tokens": prompt_tokens, + "reading_tokens_per_second": rate(prompt_tokens, prompt_duration), + "writing_tokens": output_tokens, + "writing_tokens_per_second": rate(output_tokens, output_duration), + } + @staticmethod def _profile_identity(profile: dict[str, Any]) -> str: user = profile.get("uex_user") diff --git a/web/app.js b/web/app.js index 640710f..dcffdd5 100644 --- a/web/app.js +++ b/web/app.js @@ -3,7 +3,6 @@ const input = document.getElementById("message-input"); const messages = document.getElementById("messages"); const statusEl = document.getElementById("status"); const pendingEl = document.getElementById("pending-actions"); -const activityEl = document.getElementById("activity"); const warningEl = document.getElementById("warning"); const memoryInspectorEl = document.getElementById("memory-inspector"); const memoryRefreshButton = document.getElementById("memory-refresh"); @@ -21,49 +20,212 @@ function addMessage(role, text) { } function setMessageMarkdown(node, text) { - node.innerHTML = renderMarkdown(text); + const body = node.querySelector(".message-body") || node; + body.innerHTML = renderMarkdown(text); +} + +function setMessageActivity(node, text, active = false) { + const activity = node.querySelector(".message-activity"); + if (!activity) return; + const phase = activity.querySelector(".message-phase"); + phase.innerHTML = ""; + if (text) { + const label = document.createElement("span"); + label.textContent = text; + phase.appendChild(label); + } + if (active) { + const dots = document.createElement("span"); + dots.className = "working-dots"; + dots.innerHTML = ""; + phase.appendChild(dots); + } +} + +function setMessageMetrics(node, metrics) { + const metricsEl = node.querySelector(".message-metrics"); + if (!metricsEl) return; + metricsEl.textContent = metrics || ""; +} + +function ensureStreamingChrome(node) { + if (node.querySelector(".message-activity")) return; + node.innerHTML = ""; + const activity = document.createElement("div"); + activity.className = "message-activity"; + const phase = document.createElement("span"); + phase.className = "message-phase"; + const metrics = document.createElement("span"); + metrics.className = "message-metrics"; + const body = document.createElement("div"); + body.className = "message-body"; + activity.append(phase, metrics); + node.append(activity, body); } function renderMarkdown(text) { - const escaped = escapeHtml(text); - const formatted = escaped - .replace(/`([^`]+)`/g, "$1") - .replace(/\*\*(.+?)\*\*/g, "$1"); - const lines = formatted.split(/\n/); + const lines = text.replace(/\r\n/g, "\n").split(/\n/); const output = []; let inList = false; - for (const line of lines) { - const trimmed = line.trim(); - const item = line.match(/^(\s*)-\s+(.+)$/); - if (item) { - if (!inList) { - output.push(""); inList = false; } - if (/^---+$/.test(trimmed)) { + if (inOrderedList) { + output.push(""); + inOrderedList = false; + } + }; + + const flushCode = () => { + output.push(`
${escapeHtml(codeLines.join("\n"))}
`); + codeLines = []; + inCode = false; + }; + + const isTableAt = (index) => { + if (index + 1 >= lines.length) return false; + return isTableRow(lines[index]) && isTableDivider(lines[index + 1]); + }; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + + if (/^```/.test(trimmed)) { + if (inCode) { + flushCode(); + } else { + closeLists(); + inCode = true; + } + continue; + } + + if (inCode) { + codeLines.push(line); + continue; + } + + if (isTableAt(index)) { + closeLists(); + const headers = splitTableRow(lines[index]); + const aligns = splitTableRow(lines[index + 1]).map((cell) => tableAlignment(cell)); + const rows = []; + index += 2; + while (index < lines.length && isTableRow(lines[index])) { + rows.push(splitTableRow(lines[index])); + index += 1; + } + index -= 1; + output.push(renderTable(headers, aligns, rows)); + continue; + } + + const unorderedItem = line.match(/^(\s*)[-*+]\s+(.+)$/); + if (unorderedItem) { + if (inOrderedList) { + output.push(""); + inOrderedList = false; + } + if (!inList) { + output.push(""); + inList = false; + } + if (!inOrderedList) { + output.push("
    "); + inOrderedList = true; + } + const nestedClass = orderedItem[1].length >= 2 ? ' class="nested"' : ""; + output.push(`${inlineMarkdown(orderedItem[2])}`); + continue; + } + + closeLists(); + + if (/^>\s?/.test(trimmed)) { + output.push(`
    ${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}
    `); + } else if (/^---+$/.test(trimmed)) { output.push("
    "); - } else if (/^###\s+/.test(trimmed)) { - output.push(`

    ${trimmed.replace(/^###\s+/, "")}

    `); - } else if (/^##\s+/.test(trimmed)) { - output.push(`

    ${trimmed.replace(/^##\s+/, "")}

    `); + } else if (/^#{1,6}\s+/.test(trimmed)) { + const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2); + output.push(`${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}`); } else if (trimmed) { - output.push(`

    ${line}

    `); + output.push(`

    ${inlineMarkdown(line)}

    `); } else { output.push("
    "); } } - if (inList) output.push(""); + + if (inCode) flushCode(); + closeLists(); return output.join(""); } +function inlineMarkdown(text) { + return escapeHtml(text) + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); +} + +function isTableRow(line) { + const trimmed = line.trim(); + return trimmed.includes("|") && /^\|?.+\|.+\|?$/.test(trimmed); +} + +function isTableDivider(line) { + return splitTableRow(line).every((cell) => /^:?-{3,}:?$/.test(cell.trim())); +} + +function splitTableRow(line) { + return line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +function tableAlignment(cell) { + const trimmed = cell.trim(); + if (trimmed.startsWith(":") && trimmed.endsWith(":")) return "center"; + if (trimmed.endsWith(":")) return "right"; + return "left"; +} + +function renderTable(headers, aligns, rows) { + const head = headers + .map((cell, index) => `${inlineMarkdown(cell)}`) + .join(""); + const body = rows + .map((row) => { + const cells = headers + .map((_, index) => `${inlineMarkdown(row[index] || "")}`) + .join(""); + return `${cells}`; + }) + .join(""); + return `
    ${head}${body}
    `; +} + function escapeHtml(text) { return text .replace(/&/g, "&") @@ -73,18 +235,16 @@ function escapeHtml(text) { .replace(/'/g, "'"); } -function setActivity(text, active = false) { - activityEl.innerHTML = ""; - if (!text) return; - const label = document.createElement("span"); - label.textContent = text; - activityEl.appendChild(label); - if (active) { - const dots = document.createElement("span"); - dots.className = "working-dots"; - dots.innerHTML = ""; - activityEl.appendChild(dots); - } +function formatMetrics(event) { + const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second); + const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second); + return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | "); +} + +function formatTokenMetric(tokens, speed) { + if (!tokens) return ""; + const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : ""; + return `${tokens} tok${speedText}`; } function setWarning(text) { @@ -277,9 +437,11 @@ async function sendMessage() { input.disabled = true; addMessage("user", message); const assistantNode = addMessage("assistant streaming", ""); + ensureStreamingChrome(assistantNode); let assistantText = ""; statusEl.textContent = "Working"; - setActivity("Thinking", true); + setMessageActivity(assistantNode, "Thinking", true); + setMessageMetrics(assistantNode, ""); try { const response = await fetch("/api/chat/stream", { method: "POST", @@ -303,7 +465,9 @@ async function sendMessage() { if (!line) continue; const event = JSON.parse(line.slice(6)); if (event.type === "status") { - setActivity(event.message, true); + setMessageActivity(assistantNode, event.message, true); + } else if (event.type === "metrics") { + setMessageMetrics(assistantNode, formatMetrics(event)); } else if (event.type === "warning") { setWarning(event.message); assistantText += event.message; @@ -328,7 +492,7 @@ async function sendMessage() { input.disabled = false; input.focus(); statusEl.textContent = "Ready"; - setActivity(""); + setMessageActivity(assistantNode, ""); } } diff --git a/web/index.html b/web/index.html index 465e8e8..91caa94 100644 --- a/web/index.html +++ b/web/index.html @@ -19,7 +19,6 @@
    -
    diff --git a/web/styles.css b/web/styles.css index f8884fb..57d436f 100644 --- a/web/styles.css +++ b/web/styles.css @@ -110,11 +110,16 @@ h2 { } .message p:last-child, -.message ul:last-child { +.message ul:last-child, +.message ol:last-child, +.message blockquote:last-child, +.message .table-wrap:last-child, +.message pre:last-child { margin-bottom: 0; } -.message ul { +.message ul, +.message ol { margin: 0 0 10px 20px; padding: 0; } @@ -138,6 +143,14 @@ h2 { line-height: 1.3; } +.message h4, +.message h5, +.message h6 { + margin: 10px 0 7px; + font-size: 14px; + line-height: 1.3; +} + .message hr { height: 1px; margin: 12px 0; @@ -155,6 +168,57 @@ h2 { font-size: 0.92em; } +.message pre { + max-height: none; + margin: 0 0 10px; + white-space: pre; +} + +.message pre code { + display: block; + padding: 0; + border: 0; + background: transparent; +} + +.message blockquote { + margin: 0 0 10px; + padding: 8px 10px; + border-left: 3px solid var(--accent); + background: rgba(68, 194, 165, 0.08); + color: #d7dde4; +} + +.message a { + color: #69d7bd; +} + +.table-wrap { + max-width: 100%; + margin: 0 0 10px; + overflow-x: auto; +} + +.message table { + width: 100%; + min-width: 420px; + border-collapse: collapse; + font-size: 13px; +} + +.message th, +.message td { + padding: 8px 9px; + border: 1px solid var(--border); + vertical-align: top; +} + +.message th { + background: #20262d; + color: #ffffff; + font-weight: 700; +} + .message.user { margin-left: auto; background: var(--panel-2); @@ -173,13 +237,29 @@ h2 { border-top: 1px solid var(--border); } -.activity { - min-height: 24px; - padding: 7px 16px 0; - color: rgba(151, 161, 173, 0.72); +.message-activity { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 20px; + margin-bottom: 6px; + color: rgba(151, 161, 173, 0.82); font-size: 12px; } +.message-phase { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.message-metrics { + color: rgba(151, 161, 173, 0.56); + text-align: right; + white-space: nowrap; +} + .working-dots { display: inline-flex; align-items: center; @@ -381,3 +461,18 @@ pre { border-top: 1px solid var(--border); } } + +@media (max-width: 560px) { + .composer { + grid-template-columns: 1fr; + } + + .composer button { + width: 100%; + min-height: 42px; + } + + .message-metrics { + display: none; + } +}