feat: chat improvements, file viewer, usage fixes, and agent filesystem awareness

This commit is contained in:
2026-04-16 13:42:06 +02:00
parent b6a5d169a9
commit a72eef4b82
10 changed files with 319 additions and 470 deletions

View File

@@ -358,6 +358,17 @@ async function fileBrowserNavigate(path) {
const actionTd = document.createElement("td");
actionTd.style.cssText = "text-align:right;display:flex;justify-content:flex-end;gap:6px;align-items:center";
// View button — text files only
if (!entry.is_dir && _fbIsTextFile(entry.name)) {
const viewBtn = document.createElement("button");
viewBtn.className = "btn btn-ghost btn-small";
viewBtn.title = "View file";
viewBtn.textContent = "View";
viewBtn.onclick = (function(p, n) { return function(e) { e.stopPropagation(); fileBrowserViewFile(p, n); }; })(entry.path, entry.name);
actionTd.appendChild(viewBtn);
}
const btn = document.createElement("button");
btn.className = "btn btn-ghost btn-small";
if (entry.is_dir) {
@@ -461,6 +472,43 @@ async function fileBrowserDeleteFile(path, name) {
}
}
const _FB_TEXT_EXTS = new Set([
"md","txt","json","xml","yaml","yml","csv","html","htm","css","js","ts",
"jsx","tsx","py","sh","bash","zsh","log","sql","toml","ini","conf","cfg",
"env","gitignore","dockerfile","rst","tex","diff","patch","nfo","tsv",
]);
function _fbIsTextFile(name) {
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : "";
return _FB_TEXT_EXTS.has(ext);
}
async function fileBrowserViewFile(path, name) {
try {
const r = await _fbFetch("/api/my/files/view?path=" + encodeURIComponent(path));
const data = await r.json();
const modal = document.getElementById("file-viewer-modal");
document.getElementById("fv-title").textContent = name;
const pre = document.getElementById("fv-content");
pre.textContent = data.content;
const notice = document.getElementById("fv-truncated");
if (data.truncated) {
notice.textContent = "File truncated at 512 KB.";
notice.style.display = "";
} else {
notice.style.display = "none";
}
modal.style.display = "flex";
} catch (e) {
alert("Could not load file: " + e.message);
}
}
function closeFileViewer() {
const modal = document.getElementById("file-viewer-modal");
if (modal) modal.style.display = "none";
}
/* ══════════════════════════════════════════════════════════════════════════
CHAT PAGE
══════════════════════════════════════════════════════════════════════════ */
@@ -799,6 +847,58 @@ function _renderModelPicker(q) {
}
}
/* ── Markdown renderer ───────────────────────────────────────────────────── */
function renderMarkdown(text) {
const parts = [];
const re = /```(\w*)\n?([\s\S]*?)```/g;
let last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push({ type: "text", s: text.slice(last, m.index) });
parts.push({ type: "code", lang: m[1] || "", s: m[2].replace(/\n$/, "") });
last = m.index + m[0].length;
}
if (last < text.length) parts.push({ type: "text", s: text.slice(last) });
return parts.map(p => {
if (p.type === "code") {
const langLabel = p.lang ? `<span class="code-lang">${esc(p.lang)}</span>` : `<span class="code-lang"></span>`;
return `<div class="code-block">` +
`<div class="code-block-header">${langLabel}` +
`<button class="copy-code-btn btn btn-ghost btn-small" onclick="copyCode(this)">Copy</button>` +
`</div><pre><code>${esc(p.s)}</code></pre></div>`;
}
return _renderInline(p.s);
}).join("");
}
function _renderInline(text) {
let s = esc(text);
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
s = s.replace(/`([^`\n]+)`/g, '<code class="inline-code">$1</code>');
s = s.replace(/\n/g, "<br>");
return s;
}
function copyCode(btn) {
const code = btn.closest(".code-block").querySelector("code").textContent;
navigator.clipboard.writeText(code).then(() => {
const orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = orig; }, 1500);
}).catch(() => {});
}
function copyResponse(btn) {
const bubble = btn.closest(".message.assistant").querySelector(".message-bubble");
const text = bubble.dataset.raw || bubble.textContent;
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = orig; }, 1500);
}).catch(() => {});
}
/* ── Event handling ─────────────────────────────────────────────────────── */
// Track in-progress assistant message and tool indicators by call_id
@@ -865,15 +965,22 @@ function restoreChat(msg) {
if (turn.role === "user") {
const div = document.createElement("div");
div.className = "message user";
div.innerHTML = `<div class="message-bubble">${esc(turn.text)}</div>`;
div.innerHTML = `<div class="message-bubble" style="white-space:normal">${renderMarkdown(turn.text)}</div>`;
container.appendChild(div);
} else if (turn.role === "assistant") {
const div = document.createElement("div");
div.className = "message assistant";
const bubble = document.createElement("div");
bubble.className = "message-bubble";
bubble.textContent = turn.text;
bubble.dataset.raw = turn.text;
bubble.innerHTML = renderMarkdown(turn.text);
bubble.style.whiteSpace = "normal";
div.appendChild(bubble);
const copyBtn = document.createElement("button");
copyBtn.className = "copy-response-btn btn btn-ghost btn-small";
copyBtn.textContent = "Copy response";
copyBtn.onclick = function() { copyResponse(this); };
div.appendChild(copyBtn);
container.appendChild(div);
}
}
@@ -927,6 +1034,18 @@ function resolveToolIndicator(callId, success, result, confirmed) {
}
function finishResponse(msg) {
// Render markdown on the completed bubble and add a copy-response button
if (currentBubble && currentAssistantDiv) {
const raw = currentBubble.textContent;
currentBubble.dataset.raw = raw;
currentBubble.innerHTML = renderMarkdown(raw);
currentBubble.style.whiteSpace = "normal";
const copyBtn = document.createElement("button");
copyBtn.className = "copy-response-btn btn btn-ghost btn-small";
copyBtn.textContent = "Copy response";
copyBtn.onclick = function() { copyResponse(this); };
currentAssistantDiv.appendChild(copyBtn);
}
currentAssistantDiv = null;
currentBubble = null;
finishGenerating();
@@ -959,8 +1078,8 @@ function createUserMessage(text, imageDataUrls) {
).join("");
html += `<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:${text ? "6px" : "0"}">${imgs}</div>`;
}
if (text) html += `<div>${esc(text)}</div>`;
wrap.innerHTML = `<div class="message-bubble">${html}</div>`;
if (text) html += renderMarkdown(text);
wrap.innerHTML = `<div class="message-bubble" style="white-space:normal">${html}</div>`;
document.getElementById("chat-messages").appendChild(wrap);
return wrap;
}
@@ -1301,6 +1420,16 @@ function initUsage() {
loadUsage();
}
async function clearUsageCost() {
if (!confirm("Clear all usage data?\n\nThis deletes all agent run history and resets chat session cost estimates. The agents themselves are not affected. This cannot be undone.")) return;
const r = await fetch("/api/usage/cost", { method: "DELETE", credentials: "same-origin" });
if (!r.ok) {
alert("Failed to clear cost data.");
return;
}
loadUsage();
}
/* ══════════════════════════════════════════════════════════════════════════
AUDIT PAGE

View File

@@ -193,6 +193,72 @@ body {
border-bottom-left-radius: 2px;
}
/* Copy response button (below assistant bubble, visible on hover) */
.copy-response-btn {
font-size: 11px;
color: var(--text-dim);
margin-top: 2px;
align-self: flex-start;
opacity: 0;
transition: opacity 0.15s;
}
.message.assistant:hover .copy-response-btn,
.copy-response-btn:focus { opacity: 1; }
/* Fenced code blocks inside chat bubbles */
.code-block {
margin: 8px 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
font-size: 13px;
}
.code-block:first-child { margin-top: 0; }
.code-block:last-child { margin-bottom: 0; }
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 10px;
background: var(--bg3);
border-bottom: 1px solid var(--border);
min-height: 30px;
}
.code-lang {
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
}
.copy-code-btn {
font-size: 11px !important;
padding: 2px 8px !important;
}
.code-block pre {
margin: 0;
padding: 12px 14px;
overflow-x: auto;
background: var(--bg);
white-space: pre-wrap;
word-break: break-all;
}
.code-block code {
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--text);
}
/* Inline code */
.inline-code {
font-family: var(--mono);
font-size: 0.88em;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
}
.message-meta {
font-size: 11px;
color: var(--text-dim);