feat: chat improvements, file viewer, usage fixes, and agent filesystem awareness
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user