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

@@ -909,17 +909,6 @@ async def get_usage(
params: list = []
n = 1
# Exclude email handler agents
handler_ids_rows = await pool.fetch(
"SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
)
handler_ids = [str(r["agent_id"]) for r in handler_ids_rows]
if handler_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(handler_ids)))
clauses.append(f"ar.agent_id NOT IN ({placeholders})")
params.extend(handler_ids)
n += len(handler_ids)
if since_dt:
clauses.append(f"ar.started_at >= ${n}"); params.append(since_dt); n += 1
if end:
@@ -1039,6 +1028,20 @@ async def get_usage(
}
# ── Usage: clear cost data ────────────────────────────────────────────────────
@router.delete("/usage/cost")
async def clear_cost_data(request: Request):
"""Delete all agent_runs and null out conversation cost_usd. Admin only."""
_require_admin(request)
from ..database import get_pool as _gp
pool = await _gp()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM agent_runs")
await conn.execute("UPDATE conversations SET cost_usd = NULL WHERE cost_usd IS NOT NULL")
return {"ok": True}
# ── Inbox triggers ────────────────────────────────────────────────────────────
class InboxTriggerIn(BaseModel):
@@ -2857,6 +2860,38 @@ async def download_my_file(request: Request, path: str):
)
_FB_TEXT_EXTS = {
".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",
}
@router.get("/my/files/view")
async def view_my_file(request: Request, path: str):
user = _require_auth(request)
from ..users import get_user_folder
base = await get_user_folder(user.id)
if not base:
raise HTTPException(status_code=404, detail="No files folder configured")
target = _resolve_user_path(base, path)
if not _os.path.isfile(target):
raise HTTPException(status_code=404, detail="File not found")
ext = _os.path.splitext(target)[1].lower()
if ext not in _FB_TEXT_EXTS:
raise HTTPException(status_code=415, detail="File type not supported for viewing")
size = _os.path.getsize(target)
_MAX_VIEW = 512 * 1024 # 512 KB
try:
with open(target, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read(_MAX_VIEW)
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {"content": content, "size": size, "truncated": size > _MAX_VIEW}
@router.get("/my/files/download-zip")
async def download_my_zip(request: Request, path: str = ""):
user = _require_auth(request)

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);

View File

@@ -16,7 +16,7 @@
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
<div class="sidebar-logo-text">
<div class="sidebar-logo-name">{{ brand_name }}</div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.2</span></div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.3</span></div>
</div>
</div>

View File

@@ -42,7 +42,7 @@
<th>Name</th>
<th style="width:100px;text-align:right">Size</th>
<th style="width:170px">Modified</th>
<th style="width:140px"></th>
<th style="width:180px"></th>
</tr>
</thead>
<tbody id="file-tbody">
@@ -58,4 +58,20 @@
</div>
<!-- File viewer modal -->
<div class="modal-overlay" id="file-viewer-modal" style="display:none;align-items:flex-start;padding:40px 16px"
onclick="if(event.target===this)closeFileViewer()">
<div class="modal" style="max-width:860px;width:100%;max-height:calc(100vh - 80px);display:flex;flex-direction:column">
<div class="modal-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<span id="fv-title" style="font-family:var(--mono);font-size:13px;color:var(--text-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:calc(100% - 40px)"></span>
<button class="btn btn-ghost btn-small" onclick="closeFileViewer()"></button>
</div>
<div id="fv-truncated" style="display:none;font-size:12px;color:var(--yellow);margin-bottom:8px"></div>
<pre id="fv-content"
style="margin:0;overflow:auto;flex:1;background:var(--bg3);border:1px solid var(--border);
border-radius:4px;padding:12px 14px;font-family:var(--mono);font-size:12px;
line-height:1.6;white-space:pre-wrap;word-break:break-all;max-height:70vh"></pre>
</div>
</div>
{% endblock %}

View File

@@ -6,12 +6,20 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
<h1>Usage</h1>
<!-- Time range filter -->
<div style="display:flex;gap:6px">
<!-- Time range filter + admin actions -->
<div style="display:flex;gap:6px;align-items:center">
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
<button class="btn" id="usage-range-all" type="button" onclick="setUsageRange('all')">All time</button>
{% if current_user and current_user.is_admin %}
<div style="width:1px;height:20px;background:var(--border);margin:0 4px"></div>
<button class="btn" type="button" onclick="clearUsageCost()"
style="color:var(--danger,#dc3c3c);border-color:var(--danger,#dc3c3c)"
title="Delete all agent run history and reset cost estimates">
Clear costs
</button>
{% endif %}
</div>
</div>