tracking: remove memorystore, ux: change streaming area, more agent markdown formating.

This commit is contained in:
2026-05-05 19:53:21 -04:00
parent 103f30d9c0
commit f7ac45ddd8
6 changed files with 348 additions and 48 deletions
+3
View File
@@ -36,6 +36,9 @@ coverage.xml
# FastAPI/Uvicorn/runtime files # FastAPI/Uvicorn/runtime files
*.log *.log
*.pid *.pid
data/
*.sqlite3
*.sqlite3-*
# Frontend dependencies and build artifacts # Frontend dependencies and build artifacts
node_modules/ node_modules/
+16
View File
@@ -61,3 +61,19 @@ def test_runtime_context_includes_uex_user_identity(tmp_path):
assert "timezone: America/New_York" in context assert "timezone: America/New_York" in context
assert "specializations: trading,hauling" in context assert "specializations: trading,hauling" in context
assert "hudson@example.test" not 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
+23
View File
@@ -116,6 +116,10 @@ class OllamaAgent:
yield {"type": "token", "content": chunk} yield {"type": "token", "content": chunk}
if message.get("tool_calls"): if message.get("tool_calls"):
tool_calls.extend(message["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: if not tool_calls:
self.messages.append(assistant_message) self.messages.append(assistant_message)
@@ -266,6 +270,25 @@ class OllamaAgent:
} }
return labels.get(name, f"Running {name}") 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 @staticmethod
def _profile_identity(profile: dict[str, Any]) -> str: def _profile_identity(profile: dict[str, Any]) -> str:
user = profile.get("uex_user") user = profile.get("uex_user")
+205 -41
View File
@@ -3,7 +3,6 @@ const input = document.getElementById("message-input");
const messages = document.getElementById("messages"); const messages = document.getElementById("messages");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const pendingEl = document.getElementById("pending-actions"); const pendingEl = document.getElementById("pending-actions");
const activityEl = document.getElementById("activity");
const warningEl = document.getElementById("warning"); const warningEl = document.getElementById("warning");
const memoryInspectorEl = document.getElementById("memory-inspector"); const memoryInspectorEl = document.getElementById("memory-inspector");
const memoryRefreshButton = document.getElementById("memory-refresh"); const memoryRefreshButton = document.getElementById("memory-refresh");
@@ -21,49 +20,212 @@ function addMessage(role, text) {
} }
function setMessageMarkdown(node, 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 = "<i></i><i></i><i></i>";
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) { function renderMarkdown(text) {
const escaped = escapeHtml(text); const lines = text.replace(/\r\n/g, "\n").split(/\n/);
const formatted = escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
const lines = formatted.split(/\n/);
const output = []; const output = [];
let inList = false; let inList = false;
for (const line of lines) { let inOrderedList = false;
const trimmed = line.trim(); let inCode = false;
const item = line.match(/^(\s*)-\s+(.+)$/); let codeLines = [];
if (item) {
if (!inList) { const closeLists = () => {
output.push("<ul>");
inList = true;
}
const nestedClass = item[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${item[2]}</li>`);
continue;
}
if (inList) { if (inList) {
output.push("</ul>"); output.push("</ul>");
inList = false; inList = false;
} }
if (/^---+$/.test(trimmed)) { if (inOrderedList) {
output.push("</ol>");
inOrderedList = false;
}
};
const flushCode = () => {
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
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("</ol>");
inOrderedList = false;
}
if (!inList) {
output.push("<ul>");
inList = true;
}
const nestedClass = unorderedItem[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${inlineMarkdown(unorderedItem[2])}</li>`);
continue;
}
const orderedItem = line.match(/^(\s*)\d+\.\s+(.+)$/);
if (orderedItem) {
if (inList) {
output.push("</ul>");
inList = false;
}
if (!inOrderedList) {
output.push("<ol>");
inOrderedList = true;
}
const nestedClass = orderedItem[1].length >= 2 ? ' class="nested"' : "";
output.push(`<li${nestedClass}>${inlineMarkdown(orderedItem[2])}</li>`);
continue;
}
closeLists();
if (/^>\s?/.test(trimmed)) {
output.push(`<blockquote>${inlineMarkdown(trimmed.replace(/^>\s?/, ""))}</blockquote>`);
} else if (/^---+$/.test(trimmed)) {
output.push("<hr>"); output.push("<hr>");
} else if (/^###\s+/.test(trimmed)) { } else if (/^#{1,6}\s+/.test(trimmed)) {
output.push(`<h3>${trimmed.replace(/^###\s+/, "")}</h3>`); const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2);
} else if (/^##\s+/.test(trimmed)) { output.push(`<h${level}>${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}</h${level}>`);
output.push(`<h3>${trimmed.replace(/^##\s+/, "")}</h3>`);
} else if (trimmed) { } else if (trimmed) {
output.push(`<p>${line}</p>`); output.push(`<p>${inlineMarkdown(line)}</p>`);
} else { } else {
output.push("<br>"); output.push("<br>");
} }
} }
if (inList) output.push("</ul>");
if (inCode) flushCode();
closeLists();
return output.join(""); return output.join("");
} }
function inlineMarkdown(text) {
return escapeHtml(text)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
}
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) => `<th style="text-align:${aligns[index] || "left"}">${inlineMarkdown(cell)}</th>`)
.join("");
const body = rows
.map((row) => {
const cells = headers
.map((_, index) => `<td style="text-align:${aligns[index] || "left"}">${inlineMarkdown(row[index] || "")}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function escapeHtml(text) { function escapeHtml(text) {
return text return text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -73,18 +235,16 @@ function escapeHtml(text) {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
function setActivity(text, active = false) { function formatMetrics(event) {
activityEl.innerHTML = ""; const read = formatTokenMetric(event.reading_tokens, event.reading_tokens_per_second);
if (!text) return; const wrote = formatTokenMetric(event.writing_tokens, event.writing_tokens_per_second);
const label = document.createElement("span"); return [read && `read ${read}`, wrote && `wrote ${wrote}`].filter(Boolean).join(" | ");
label.textContent = text; }
activityEl.appendChild(label);
if (active) { function formatTokenMetric(tokens, speed) {
const dots = document.createElement("span"); if (!tokens) return "";
dots.className = "working-dots"; const speedText = typeof speed === "number" ? ` @ ${speed.toFixed(1)}/s` : "";
dots.innerHTML = "<i></i><i></i><i></i>"; return `${tokens} tok${speedText}`;
activityEl.appendChild(dots);
}
} }
function setWarning(text) { function setWarning(text) {
@@ -277,9 +437,11 @@ async function sendMessage() {
input.disabled = true; input.disabled = true;
addMessage("user", message); addMessage("user", message);
const assistantNode = addMessage("assistant streaming", ""); const assistantNode = addMessage("assistant streaming", "");
ensureStreamingChrome(assistantNode);
let assistantText = ""; let assistantText = "";
statusEl.textContent = "Working"; statusEl.textContent = "Working";
setActivity("Thinking", true); setMessageActivity(assistantNode, "Thinking", true);
setMessageMetrics(assistantNode, "");
try { try {
const response = await fetch("/api/chat/stream", { const response = await fetch("/api/chat/stream", {
method: "POST", method: "POST",
@@ -303,7 +465,9 @@ async function sendMessage() {
if (!line) continue; if (!line) continue;
const event = JSON.parse(line.slice(6)); const event = JSON.parse(line.slice(6));
if (event.type === "status") { 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") { } else if (event.type === "warning") {
setWarning(event.message); setWarning(event.message);
assistantText += event.message; assistantText += event.message;
@@ -328,7 +492,7 @@ async function sendMessage() {
input.disabled = false; input.disabled = false;
input.focus(); input.focus();
statusEl.textContent = "Ready"; statusEl.textContent = "Ready";
setActivity(""); setMessageActivity(assistantNode, "");
} }
} }
-1
View File
@@ -19,7 +19,6 @@
<div class="warning" id="warning" hidden></div> <div class="warning" id="warning" hidden></div>
<div class="messages" id="messages"></div> <div class="messages" id="messages"></div>
<div class="composer-wrap"> <div class="composer-wrap">
<div class="activity" id="activity" aria-live="polite"></div>
<form class="composer" id="chat-form"> <form class="composer" id="chat-form">
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea> <textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
<button type="submit">Send</button> <button type="submit">Send</button>
+101 -6
View File
@@ -110,11 +110,16 @@ h2 {
} }
.message p:last-child, .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; margin-bottom: 0;
} }
.message ul { .message ul,
.message ol {
margin: 0 0 10px 20px; margin: 0 0 10px 20px;
padding: 0; padding: 0;
} }
@@ -138,6 +143,14 @@ h2 {
line-height: 1.3; line-height: 1.3;
} }
.message h4,
.message h5,
.message h6 {
margin: 10px 0 7px;
font-size: 14px;
line-height: 1.3;
}
.message hr { .message hr {
height: 1px; height: 1px;
margin: 12px 0; margin: 12px 0;
@@ -155,6 +168,57 @@ h2 {
font-size: 0.92em; 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 { .message.user {
margin-left: auto; margin-left: auto;
background: var(--panel-2); background: var(--panel-2);
@@ -173,13 +237,29 @@ h2 {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.activity { .message-activity {
min-height: 24px; display: flex;
padding: 7px 16px 0; align-items: center;
color: rgba(151, 161, 173, 0.72); justify-content: space-between;
gap: 12px;
min-height: 20px;
margin-bottom: 6px;
color: rgba(151, 161, 173, 0.82);
font-size: 12px; 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 { .working-dots {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -381,3 +461,18 @@ pre {
border-top: 1px solid var(--border); 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;
}
}