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("
${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("${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}`); + } else if (/^---+$/.test(trimmed)) { output.push("
${line}
`); + output.push(`${inlineMarkdown(line)}
`); } else { output.push("$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) => `