tracking: remove memorystore, ux: change streaming area, more agent markdown formating.
This commit is contained in:
+205
-41
@@ -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 = "<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) {
|
||||
const escaped = escapeHtml(text);
|
||||
const formatted = escaped
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
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("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
const nestedClass = item[1].length >= 2 ? ' class="nested"' : "";
|
||||
output.push(`<li${nestedClass}>${item[2]}</li>`);
|
||||
continue;
|
||||
}
|
||||
let inOrderedList = false;
|
||||
let inCode = false;
|
||||
let codeLines = [];
|
||||
|
||||
const closeLists = () => {
|
||||
if (inList) {
|
||||
output.push("</ul>");
|
||||
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>");
|
||||
} else if (/^###\s+/.test(trimmed)) {
|
||||
output.push(`<h3>${trimmed.replace(/^###\s+/, "")}</h3>`);
|
||||
} else if (/^##\s+/.test(trimmed)) {
|
||||
output.push(`<h3>${trimmed.replace(/^##\s+/, "")}</h3>`);
|
||||
} else if (/^#{1,6}\s+/.test(trimmed)) {
|
||||
const level = Math.min(4, trimmed.match(/^#+/)[0].length + 2);
|
||||
output.push(`<h${level}>${inlineMarkdown(trimmed.replace(/^#{1,6}\s+/, ""))}</h${level}>`);
|
||||
} else if (trimmed) {
|
||||
output.push(`<p>${line}</p>`);
|
||||
output.push(`<p>${inlineMarkdown(line)}</p>`);
|
||||
} else {
|
||||
output.push("<br>");
|
||||
}
|
||||
}
|
||||
if (inList) output.push("</ul>");
|
||||
|
||||
if (inCode) flushCode();
|
||||
closeLists();
|
||||
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) {
|
||||
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 = "<i></i><i></i><i></i>";
|
||||
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, "");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<div class="warning" id="warning" hidden></div>
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="composer-wrap">
|
||||
<div class="activity" id="activity" aria-live="polite"></div>
|
||||
<form class="composer" id="chat-form">
|
||||
<textarea id="message-input" rows="2" placeholder="Search listings, draft a reply, prepare an offer..."></textarea>
|
||||
<button type="submit">Send</button>
|
||||
|
||||
+101
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user