Made Jarvis more mobile friendly

This commit is contained in:
2026-04-21 11:00:39 +02:00
parent a72eef4b82
commit eaea8d94b1
14 changed files with 604 additions and 97 deletions
+4 -4
View File
@@ -28,13 +28,13 @@ async def list_accounts(user_id: str | None = None) -> list[dict]:
pool = await get_pool() pool = await get_pool()
if user_id is None: if user_id is None:
rows = await pool.fetch( rows = await pool.fetch(
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
" LEFT JOIN agents a ON a.id = ea.agent_id" " LEFT JOIN agents a ON a.id = ea.agent_id"
" ORDER BY ea.created_at" " ORDER BY ea.created_at"
) )
else: else:
rows = await pool.fetch( rows = await pool.fetch(
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
" LEFT JOIN agents a ON a.id = ea.agent_id" " LEFT JOIN agents a ON a.id = ea.agent_id"
" WHERE ea.user_id = $1 ORDER BY ea.created_at", " WHERE ea.user_id = $1 ORDER BY ea.created_at",
user_id, user_id,
@@ -46,7 +46,7 @@ async def list_accounts_enabled() -> list[dict]:
"""Return all enabled accounts (used by listener on startup).""" """Return all enabled accounts (used by listener on startup)."""
pool = await get_pool() pool = await get_pool()
rows = await pool.fetch( rows = await pool.fetch(
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
" LEFT JOIN agents a ON a.id = ea.agent_id" " LEFT JOIN agents a ON a.id = ea.agent_id"
" WHERE ea.enabled = TRUE ORDER BY ea.created_at" " WHERE ea.enabled = TRUE ORDER BY ea.created_at"
) )
@@ -56,7 +56,7 @@ async def list_accounts_enabled() -> list[dict]:
async def get_account(account_id: str) -> dict | None: async def get_account(account_id: str) -> dict | None:
pool = await get_pool() pool = await get_pool()
row = await pool.fetchrow( row = await pool.fetchrow(
"SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt FROM email_accounts ea" "SELECT ea.*, a.name AS agent_name, a.model AS agent_model, a.prompt AS agent_prompt, a.max_tool_calls AS agent_max_tool_calls, a.prompt_mode AS agent_prompt_mode FROM email_accounts ea"
" LEFT JOIN agents a ON a.id = ea.agent_id" " LEFT JOIN agents a ON a.id = ea.agent_id"
" WHERE ea.id = $1", " WHERE ea.id = $1",
account_id, account_id,
+4 -2
View File
@@ -496,8 +496,10 @@ class EmailAccountListener:
f"- Memory file: {mem_path}\n" f"- Memory file: {mem_path}\n"
f"- Reasoning log: {log_path}\n" f"- Reasoning log: {log_path}\n"
f"Read the memory file before acting. " f"Read the memory file before acting. "
f"Append a reasoning entry to the reasoning log for each email you act on. " f"Append a brief reasoning entry to the reasoning log for each email you act on. "
f"If either file doesn't exist yet, create it with an appropriate template." f"If either file doesn't exist yet, create it with an appropriate template. "
f"If the memory file exceeds 120 lines, summarise it in-place (condense key facts, "
f"drop old entries) before appending — keep it concise to reduce token costs."
) )
await agent_runner.run_agent_and_wait( await agent_runner.run_agent_and_wait(
+8 -1
View File
@@ -55,7 +55,7 @@ logging.basicConfig(
logging.getLogger("server.tools.caldav_tool").setLevel(logging.DEBUG) logging.getLogger("server.tools.caldav_tool").setLevel(logging.DEBUG)
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -412,6 +412,13 @@ app.add_middleware(_AuthMiddleware)
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static") app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static")
app.include_router(api_router, prefix="/api") app.include_router(api_router, prefix="/api")
_STATIC_DIR = BASE_DIR / "web" / "static"
@app.get("/service-worker.js", include_in_schema=False)
async def _service_worker():
return FileResponse(_STATIC_DIR / "service-worker.js", media_type="application/javascript")
# 2nd Brain MCP server — mounted at /brain-mcp (SSE transport) # 2nd Brain MCP server — mounted at /brain-mcp (SSE transport)
app.mount("/brain-mcp", create_mcp_app()) app.mount("/brain-mcp", create_mcp_app())
+17 -5
View File
@@ -83,7 +83,12 @@ class AnthropicProvider(AIProvider):
"max_tokens": max_tokens, "max_tokens": max_tokens,
} }
if system: if system:
params["system"] = system # Wrap in a content block with cache_control so the system prompt is
# cached across repeated calls (e.g. email handling runs). Cached tokens
# cost ~10% of normal input price after the first write.
params["system"] = [
{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}
]
if tools: if tools:
# aide tool schemas ARE Anthropic format — pass through directly # aide tool schemas ARE Anthropic format — pass through directly
params["tools"] = tools params["tools"] = tools
@@ -163,10 +168,17 @@ class AnthropicProvider(AIProvider):
arguments=block.input, arguments=block.input,
)) ))
usage = UsageStats( if response.usage:
input_tokens=response.usage.input_tokens, cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0
output_tokens=response.usage.output_tokens, cache_write = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
) if response.usage else UsageStats() usage = UsageStats(
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cache_read_tokens=cache_read,
cache_write_tokens=cache_write,
)
else:
usage = UsageStats()
finish_reason = response.stop_reason or "stop" finish_reason = response.stop_reason or "stop"
if tool_calls: if tool_calls:
+2
View File
@@ -24,6 +24,8 @@ class ToolCallResult:
class UsageStats: class UsageStats:
input_tokens: int = 0 input_tokens: int = 0
output_tokens: int = 0 output_tokens: int = 0
cache_read_tokens: int = 0 # Anthropic: tokens served from cache (billed at ~10%)
cache_write_tokens: int = 0 # Anthropic: tokens written to cache (billed at ~125%)
@property @property
def total_tokens(self) -> int: def total_tokens(self) -> int:
+16 -1
View File
@@ -2585,6 +2585,14 @@ async def create_my_email_account(request: Request):
agent_model = body.get("agent_model", "").strip() agent_model = body.get("agent_model", "").strip()
agent_prompt = body.get("agent_prompt", "").strip() agent_prompt = body.get("agent_prompt", "").strip()
agent_max_tool_calls = body.get("agent_max_tool_calls")
if agent_max_tool_calls is not None:
try:
agent_max_tool_calls = int(agent_max_tool_calls)
except (TypeError, ValueError):
agent_max_tool_calls = None
agent_prompt_mode = body.get("agent_prompt_mode") or "combined"
# Auto-create a dedicated agent for this account # Auto-create a dedicated agent for this account
agent = await create_agent( agent = await create_agent(
name=f"Email Handler: {label}", name=f"Email Handler: {label}",
@@ -2593,6 +2601,8 @@ async def create_my_email_account(request: Request):
allowed_tools=["email"], allowed_tools=["email"],
created_by="user", created_by="user",
owner_user_id=user.id, owner_user_id=user.id,
max_tool_calls=agent_max_tool_calls,
prompt_mode=agent_prompt_mode,
) )
agent_id = agent["id"] agent_id = agent["id"]
@@ -2629,7 +2639,7 @@ async def update_my_email_account(request: Request, account_id: str):
raise HTTPException(status_code=404, detail="Account not found") raise HTTPException(status_code=404, detail="Account not found")
body = await request.json() body = await request.json()
# Update the linked agent's model and prompt if provided # Update the linked agent's model, prompt, and cost-control fields if provided
if acct.get("agent_id"): if acct.get("agent_id"):
agent_fields = {} agent_fields = {}
if "agent_model" in body: if "agent_model" in body:
@@ -2638,6 +2648,11 @@ async def update_my_email_account(request: Request, account_id: str):
agent_fields["prompt"] = body["agent_prompt"] agent_fields["prompt"] = body["agent_prompt"]
if "label" in body: if "label" in body:
agent_fields["name"] = f"Email Handler: {body['label']}" agent_fields["name"] = f"Email Handler: {body['label']}"
if "agent_max_tool_calls" in body:
v = body["agent_max_tool_calls"]
agent_fields["max_tool_calls"] = int(v) if v is not None else None
if "agent_prompt_mode" in body:
agent_fields["prompt_mode"] = body["agent_prompt_mode"] or "combined"
if agent_fields: if agent_fields:
await update_agent(acct["agent_id"], **agent_fields) await update_agent(acct["agent_id"], **agent_fields)
+181 -13
View File
@@ -140,6 +140,53 @@ let _skipNextRestore = false; // true when snapshot was just restored — skip
let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message
let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } } let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } }
/* ── PWA install ─────────────────────────────────────────────────────────── */
let _deferredInstallPrompt = null;
window.addEventListener("beforeinstallprompt", e => {
e.preventDefault();
_deferredInstallPrompt = e;
const btn = document.getElementById("pwa-install-btn");
if (btn) btn.style.display = "flex";
});
function handleInstall() {
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.navigator.standalone === true;
if (isStandalone) return;
if (_deferredInstallPrompt) {
_deferredInstallPrompt.prompt();
_deferredInstallPrompt.userChoice.then(() => { _deferredInstallPrompt = null; });
} else if (isIOS) {
alert("To add to your Home Screen:\n1. Tap the Share button (\u25a1\u2191) in Safari's toolbar\n2. Tap \"Add to Home Screen\"");
}
}
(function _initPWA() {
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches;
if (isIOS && !isStandalone) {
const btn = document.getElementById("pwa-install-btn");
if (btn) btn.style.display = "flex";
}
})();
function toggleSidebar() {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("sidebar-overlay");
if (!sidebar) return;
const isOpen = sidebar.classList.toggle("open");
overlay && overlay.classList.toggle("visible", isOpen);
}
function closeSidebar() {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("sidebar-overlay");
sidebar && sidebar.classList.remove("open");
overlay && overlay.classList.remove("visible");
}
function initNav() { function initNav() {
_setActiveNav(location.pathname); _setActiveNav(location.pathname);
@@ -149,6 +196,7 @@ function initNav() {
const href = el.getAttribute("href"); const href = el.getAttribute("href");
if (!href || href.startsWith("http")) return; // external links: normal if (!href || href.startsWith("http")) return; // external links: normal
e.preventDefault(); e.preventDefault();
closeSidebar();
navigateTo(href); navigateTo(href);
}); });
}); });
@@ -344,6 +392,10 @@ async function fileBrowserNavigate(path) {
nameTd.style.cssText = "cursor:pointer;color:var(--accent)"; nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
nameTd.textContent = entry.name; nameTd.textContent = entry.name;
nameTd.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(entry.path); nameTd.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(entry.path);
} else if (_fbIsTextFile(entry.name)) {
nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
nameTd.textContent = entry.name;
nameTd.onclick = (function(p, n) { return function() { fileBrowserViewFile(p, n); }; })(entry.path, entry.name);
} else { } else {
nameTd.textContent = entry.name; nameTd.textContent = entry.name;
} }
@@ -359,16 +411,6 @@ async function fileBrowserNavigate(path) {
const actionTd = document.createElement("td"); const actionTd = document.createElement("td");
actionTd.style.cssText = "text-align:right;display:flex;justify-content:flex-end;gap:6px;align-items:center"; 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"); const btn = document.createElement("button");
btn.className = "btn btn-ghost btn-small"; btn.className = "btn btn-ghost btn-small";
if (entry.is_dir) { if (entry.is_dir) {
@@ -785,7 +827,10 @@ function openModelPicker() {
_mpFocusIdx = -1; _mpFocusIdx = -1;
modal.classList.remove("hidden"); modal.classList.remove("hidden");
const s = document.getElementById("model-picker-search"); const s = document.getElementById("model-picker-search");
if (s) { s.value = ""; s.focus(); } if (s) {
s.value = "";
if (window.innerWidth > 768) s.focus();
}
_renderModelPicker(""); _renderModelPicker("");
} }
@@ -1548,6 +1593,45 @@ function closeAuditDetail() {
SETTINGS PAGE SETTINGS PAGE
*/ */
/* ── Mobile settings tab select ──────────────────────────────────────────── */
function _initMobileTabSelect(switchFn) {
if (window.innerWidth > 768) return;
const bar = document.querySelector(".tab-bar");
if (!bar || bar.dataset.selectBuilt) return;
bar.dataset.selectBuilt = "1";
const buttons = [...bar.querySelectorAll(".tab-btn")];
if (!buttons.length) return;
const wrap = document.createElement("div");
wrap.className = "tab-select-wrap";
const sel = document.createElement("select");
sel.className = "tab-select";
buttons.forEach(btn => {
const opt = document.createElement("option");
opt.value = btn.id.replace(/^[us]tab-|^stab-/, "");
opt.textContent = btn.textContent.trim();
if (btn.classList.contains("active")) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener("change", () => switchFn(sel.value));
const arrow = document.createElement("span");
arrow.className = "tab-select-arrow";
arrow.textContent = "▾";
wrap.appendChild(sel);
wrap.appendChild(arrow);
bar.parentNode.insertBefore(wrap, bar);
bar.style.display = "none";
}
function _syncTabSelect(name) {
const sel = document.querySelector(".tab-select");
if (sel) sel.value = name;
}
function switchSettingsTab(name) { function switchSettingsTab(name) {
["general", "whitelists", "credentials", "caldav", "pushover", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "webhooks", "mfa"].forEach(t => { ["general", "whitelists", "credentials", "caldav", "pushover", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "webhooks", "mfa"].forEach(t => {
const pane = document.getElementById(`spane-${t}`); const pane = document.getElementById(`spane-${t}`);
@@ -1555,6 +1639,7 @@ function switchSettingsTab(name) {
if (pane) pane.style.display = t === name ? "" : "none"; if (pane) pane.style.display = t === name ? "" : "none";
if (btn) btn.classList.toggle("active", t === name); if (btn) btn.classList.toggle("active", t === name);
}); });
_syncTabSelect(name);
if (name === "inbox") { loadInboxStatus(); } if (name === "inbox") { loadInboxStatus(); }
if (name === "emailaccounts") { loadEmailAccounts(); } if (name === "emailaccounts") { loadEmailAccounts(); }
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); } if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
@@ -1698,6 +1783,7 @@ function switchUserTab(name) {
const pane = document.getElementById("uspane-" + name); const pane = document.getElementById("uspane-" + name);
if (tab) tab.classList.add("active"); if (tab) tab.classList.add("active");
if (pane) pane.style.display = ""; if (pane) pane.style.display = "";
_syncTabSelect(name);
if (name === "mcp") loadMyMcpServers(); if (name === "mcp") loadMyMcpServers();
if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); } if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); }
if (name === "emailaccounts") { loadEmailAccounts(); } if (name === "emailaccounts") { loadEmailAccounts(); }
@@ -2187,6 +2273,10 @@ function openEmailAccountModal(id) {
document.getElementById("eam-folders-display").textContent = (acct?.monitored_folders || ["INBOX"]).join(", "); document.getElementById("eam-folders-display").textContent = (acct?.monitored_folders || ["INBOX"]).join(", ");
document.getElementById("eam-folders-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]); document.getElementById("eam-folders-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]);
document.getElementById("eam-agent-prompt").value = acct?.agent_prompt || ""; document.getElementById("eam-agent-prompt").value = acct?.agent_prompt || "";
const mtc = document.getElementById("eam-max-tool-calls");
if (mtc) mtc.value = acct?.agent_max_tool_calls != null ? acct.agent_max_tool_calls : "";
const pm = document.getElementById("eam-prompt-mode");
if (pm) pm.value = acct?.agent_prompt_mode || "combined";
_populateEamModelSelect(acct?.agent_model); _populateEamModelSelect(acct?.agent_model);
_loadEamExtraTools(acct?.extra_tools || [], acct?.telegram_chat_id || "", acct?.telegram_keyword || ""); _loadEamExtraTools(acct?.extra_tools || [], acct?.telegram_chat_id || "", acct?.telegram_keyword || "");
@@ -2347,6 +2437,9 @@ async function saveEmailAccount() {
const imap_password = document.getElementById("eam-imap-password").value; const imap_password = document.getElementById("eam-imap-password").value;
const agent_model = document.getElementById("eam-agent-model").value; const agent_model = document.getElementById("eam-agent-model").value;
const agent_prompt = document.getElementById("eam-agent-prompt").value.trim(); const agent_prompt = document.getElementById("eam-agent-prompt").value.trim();
const _mtcRaw = document.getElementById("eam-max-tool-calls")?.value;
const agent_max_tool_calls = _mtcRaw ? parseInt(_mtcRaw) : null;
const agent_prompt_mode = document.getElementById("eam-prompt-mode")?.value || "combined";
const initial_load_limit = parseInt(document.getElementById("eam-initial-load-limit").value || "200"); const initial_load_limit = parseInt(document.getElementById("eam-initial-load-limit").value || "200");
const monitored_folders = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]'); const monitored_folders = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]');
const extra_tools = Array.from(document.querySelectorAll("#eam-extra-tools-area input[type=checkbox]:checked")).map(c => c.value); const extra_tools = Array.from(document.querySelectorAll("#eam-extra-tools-area input[type=checkbox]:checked")).map(c => c.value);
@@ -2364,7 +2457,8 @@ async function saveEmailAccount() {
if (extra_tools.includes("telegram") && !telegram_keyword) { showFlash("Please enter a reply keyword for Telegram (e.g. 'work')."); return; } if (extra_tools.includes("telegram") && !telegram_keyword) { showFlash("Please enter a reply keyword for Telegram (e.g. 'work')."); return; }
const payload = { label, imap_host, imap_port, imap_username, imap_password, const payload = { label, imap_host, imap_port, imap_username, imap_password,
agent_model, agent_prompt, initial_load_limit, monitored_folders, extra_tools, agent_model, agent_prompt, agent_max_tool_calls, agent_prompt_mode,
initial_load_limit, monitored_folders, extra_tools,
telegram_chat_id, telegram_keyword }; telegram_chat_id, telegram_keyword };
const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts"; const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts";
@@ -2668,6 +2762,7 @@ function togglePasswordVisibility(inputId, btn) {
} }
function initUserSettings() { function initUserSettings() {
_initMobileTabSelect(switchUserTab);
const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys"; const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys";
switchUserTab(tab); switchUserTab(tab);
loadMyProviderKeys(); loadMyProviderKeys();
@@ -2688,6 +2783,7 @@ function initSettings() {
} }
if (!document.getElementById("limits-form")) return; if (!document.getElementById("limits-form")) return;
_initMobileTabSelect(switchSettingsTab);
loadApiKeyStatus(); loadApiKeyStatus();
loadProviderKeys(); loadProviderKeys();
loadUsersBaseFolder(); loadUsersBaseFolder();
@@ -4848,7 +4944,46 @@ async function editCurrentAgent() {
if (!agentId) return; if (!agentId) return;
const r = await fetch(`/api/agents/${agentId}`); const r = await fetch(`/api/agents/${agentId}`);
const agent = await r.json(); const agent = await r.json();
showAgentModal(agent); openPromptEditor(agent.name, agent.prompt);
}
function openPromptEditor(name, prompt) {
const overlay = document.getElementById("prompt-editor-overlay");
if (!overlay) return;
document.getElementById("pe-agent-name").textContent = name || "";
const ta = document.getElementById("pe-textarea");
ta.value = prompt || "";
overlay.style.display = "flex";
ta.focus();
// Place cursor at start
ta.setSelectionRange(0, 0);
ta.scrollTop = 0;
}
function closePromptEditor() {
const overlay = document.getElementById("prompt-editor-overlay");
if (overlay) overlay.style.display = "none";
}
async function savePromptEditor() {
const agentId = window.AGENT_ID;
if (!agentId) return;
const prompt = document.getElementById("pe-textarea").value;
const btn = document.getElementById("pe-save-btn");
if (btn) { btn.disabled = true; btn.textContent = "Saving…"; }
const r = await fetch(`/api/agents/${agentId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (btn) { btn.disabled = false; btn.textContent = "Save"; }
if (r.ok) {
closePromptEditor();
loadAgentDetail();
} else {
const d = await r.json().catch(() => ({}));
alert("Save failed: " + (d.detail || r.statusText));
}
} }
async function runCurrentAgent() { async function runCurrentAgent() {
@@ -4876,6 +5011,13 @@ async function saveAgentAndReload() {
function initAgentDetail() { function initAgentDetail() {
if (!document.getElementById("agent-detail-container")) return; if (!document.getElementById("agent-detail-container")) return;
_loadAvailableModels().then(() => loadAgentDetail()); _loadAvailableModels().then(() => loadAgentDetail());
// Keyboard shortcuts for the prompt editor
document.addEventListener("keydown", function _detailKeys(e) {
const overlay = document.getElementById("prompt-editor-overlay");
if (!overlay || overlay.style.display === "none") return;
if (e.key === "Escape") { e.preventDefault(); closePromptEditor(); }
if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); savePromptEditor(); }
});
} }
async function loadAgentDetail() { async function loadAgentDetail() {
@@ -5257,6 +5399,23 @@ function initHelp() {
// Disconnect any previous observer (SPA re-navigation) // Disconnect any previous observer (SPA re-navigation)
if (_helpObserver) { _helpObserver.disconnect(); _helpObserver = null; } if (_helpObserver) { _helpObserver.disconnect(); _helpObserver = null; }
// Mobile: add collapsible TOC toggle
if (window.innerWidth <= 768) {
const toc = document.querySelector(".help-toc");
if (toc && !toc.querySelector(".toc-toggle")) {
toc.classList.add("toc-collapsed");
const btn = document.createElement("button");
btn.className = "toc-toggle";
btn.innerHTML = "Contents <span class='toc-arrow'>▾</span>";
btn.addEventListener("click", () => {
toc.classList.toggle("toc-collapsed");
btn.querySelector(".toc-arrow").textContent =
toc.classList.contains("toc-collapsed") ? "▾" : "▴";
});
toc.insertBefore(btn, toc.firstChild);
}
}
const tocLinks = document.querySelectorAll(".toc-list a[href^='#']"); const tocLinks = document.querySelectorAll(".toc-list a[href^='#']");
const sections = document.querySelectorAll("section[data-section]"); const sections = document.querySelectorAll("section[data-section]");
@@ -5279,6 +5438,15 @@ function initHelp() {
e.preventDefault(); e.preventDefault();
const target = document.querySelector(a.getAttribute("href")); const target = document.querySelector(a.getAttribute("href"));
if (target) target.scrollIntoView({ behavior: "smooth" }); if (target) target.scrollIntoView({ behavior: "smooth" });
// On mobile: collapse TOC after selecting a section
if (window.innerWidth <= 768) {
const toc = document.querySelector(".help-toc");
if (toc) {
toc.classList.add("toc-collapsed");
const arrow = toc.querySelector(".toc-arrow");
if (arrow) arrow.textContent = "▾";
}
}
}); });
}); });
} }
+21
View File
@@ -0,0 +1,21 @@
{
"name": "oAI-Web",
"short_name": "Jarvis",
"description": "Personal AI Agent",
"start_url": "/",
"display": "standalone",
"background_color": "#0f1117",
"theme_color": "#0f1117",
"icons": [
{
"src": "/static/icon.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+36
View File
@@ -0,0 +1,36 @@
const CACHE = 'jarvis-v1';
const SHELL = [
'/static/style.css',
'/static/app.js',
'/static/icon.png',
'/static/logo.png',
];
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
if (url.pathname.startsWith('/static/')) {
// Cache-first for static assets
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
} else {
// Network-first for everything else (pages, API)
e.respondWith(
fetch(e.request).catch(() => caches.match(e.request))
);
}
});
+229
View File
@@ -669,6 +669,25 @@ tr:hover td { background: var(--bg2); }
.http-put { color: var(--yellow); font-weight: 700; font-family: var(--mono); font-size: 11px; } .http-put { color: var(--yellow); font-weight: 700; font-family: var(--mono); font-size: 11px; }
.http-del { color: var(--red); font-weight: 700; font-family: var(--mono); font-size: 11px; } .http-del { color: var(--red); font-weight: 700; font-family: var(--mono); font-size: 11px; }
/* ── Help TOC collapse toggle (mobile) ───────────────────────────────────── */
.toc-toggle {
display: none;
width: 100%;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 14px;
font-weight: 500;
padding: 10px 14px;
text-align: left;
cursor: pointer;
}
@media (max-width: 768px) {
.toc-toggle { display: block; }
}
/* ── Prompt mode toggle ───────────────────────────────────────────────────── */ /* ── Prompt mode toggle ───────────────────────────────────────────────────── */
.prompt-mode-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } .prompt-mode-toggle { display: flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.pm-btn { flex: 1; padding: 6px 8px; font-size: 12px; border: none; border-right: 1px solid var(--border); background: var(--bg2); color: var(--text-dim); cursor: pointer; transition: background 0.15s, color 0.15s; } .pm-btn { flex: 1; padding: 6px 8px; font-size: 12px; border: none; border-right: 1px solid var(--border); background: var(--bg2); color: var(--text-dim); cursor: pointer; transition: background 0.15s, color 0.15s; }
@@ -680,3 +699,213 @@ tr:hover td { background: var(--bg2); }
.stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; } .stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; }
.stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; } .stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); } .stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); }
/* ── Responsive layout ────────────────────────────────────────────────────── */
.app-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 150;
}
.mobile-header {
display: none;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
}
.hamburger-btn {
background: none;
border: none;
color: var(--text);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
border-radius: 4px;
transition: background 0.15s;
}
.hamburger-btn:hover { background: var(--bg3); }
.hamburger-btn svg { width: 20px; height: 20px; }
.mobile-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
flex: 1;
}
.mobile-install-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
margin-left: auto;
}
.mobile-install-btn:hover { background: var(--bg3); color: var(--accent); }
/* ── Mobile tab select (replaces tab bar on small screens) ───────────────── */
.tab-select-wrap {
display: none;
position: relative;
margin-bottom: 24px;
}
.tab-select {
width: 100%;
appearance: none;
-webkit-appearance: none;
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 14px;
font-weight: 500;
padding: 10px 36px 10px 14px;
cursor: pointer;
outline: none;
}
.tab-select:focus { border-color: var(--accent-dim); }
.tab-select-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-dim);
pointer-events: none;
font-size: 13px;
}
@media (max-width: 768px) {
.tab-select-wrap { display: block; }
}
/* ── Tab bar (settings + others) ─────────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tab-bar::-webkit-scrollbar { display: none; }
.tab-bar .tab-btn { flex-shrink: 0; }
/* ── Usage page header ────────────────────────────────────────────────────── */
.usage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
gap: 12px;
flex-wrap: wrap;
}
.usage-header-actions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
@media (max-width: 768px) {
html, body { overflow: hidden; }
.sidebar {
position: fixed;
left: -220px;
top: 0;
height: 100%;
z-index: 200;
transition: left 0.25s ease;
overflow-y: auto;
width: 200px;
}
.sidebar.open { left: 0; }
.sidebar-overlay.visible { display: block; }
.mobile-header { display: flex; }
.app-body { width: 100%; }
/* Page padding */
.page { padding: 16px; }
.page h1 { margin-bottom: 16px; }
/* Chat */
.chat-messages { padding: 12px 16px; }
.status-bar { padding: 6px 12px; flex-wrap: wrap; gap: 6px; font-size: 10px; }
/* Prevent iOS auto-zoom on input focus (triggered when font-size < 16px) */
input, textarea, select, .form-input, .chat-input, .tab-select {
font-size: 16px !important;
}
/* Chat input — textarea full-width row, buttons on second row */
.chat-input-wrap {
flex-wrap: wrap;
padding: 8px 12px;
gap: 6px;
}
.chat-input-wrap .chat-input {
flex: 1 1 100%;
min-height: 80px;
max-height: 160px;
order: 1;
}
#attach-btn { order: 2; }
#clear-btn { order: 3; }
#send-btn { order: 4; margin-left: auto; }
/* Modals slide up from bottom */
.modal-overlay { align-items: flex-end; }
.modal {
width: 100% !important;
max-width: 100% !important;
border-radius: var(--radius) var(--radius) 0 0 !important;
margin: 0;
}
/* Help page stacks with collapsible TOC */
.help-layout { flex-direction: column; overflow: auto; }
.help-toc {
width: 100%; min-width: unset;
border-right: none; border-bottom: 1px solid var(--border);
overflow: hidden;
padding: 12px 16px;
}
.help-toc .toc-list, .help-toc .form-input { display: none; }
.help-toc.toc-collapsed .toc-list,
.help-toc.toc-collapsed .form-input { display: none; }
.help-toc:not(.toc-collapsed) .toc-list,
.help-toc:not(.toc-collapsed) .form-input { display: block; margin-top: 10px; }
.help-content { padding: 20px 16px; }
/* Tab bar scrollable */
.tabs { overflow-x: auto; flex-wrap: nowrap; }
/* Filter bar */
.filter-bar { gap: 6px; }
.filter-bar .form-input { min-width: 0; }
}
+24 -48
View File
@@ -114,57 +114,33 @@
</div> </div>
</div> </div>
<!-- Edit modal (reuse agent modal markup with inline) --> <!-- Fullscreen prompt editor -->
<div class="modal-overlay hidden" id="agent-modal"> <div id="prompt-editor-overlay" style="
<div class="modal" style="max-width:560px;width:100%"> display:none;position:fixed;inset:0;z-index:1001;
<h3 id="agent-modal-title">Edit Agent</h3> background:var(--bg);flex-direction:column;
<input type="hidden" id="a-id"> ">
<!-- Header bar -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> <div style="
<div class="form-group"> display:flex;align-items:center;justify-content:space-between;
<label>Name</label> padding:12px 20px;border-bottom:1px solid var(--border);
<input type="text" id="a-name" class="form-input" required> background:var(--bg2);flex-shrink:0;gap:12px
</div> ">
<div class="form-group"> <div style="display:flex;align-items:center;gap:12px;min-width:0">
<label>Model</label> <span style="font-size:13px;color:var(--text-dim);white-space:nowrap">Editing prompt —</span>
<select id="a-model" class="form-input"></select> <span id="pe-agent-name" style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div>
</div> </div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<div class="form-group"> <span style="font-size:11px;color:var(--text-dim)">Ctrl+S to save · Esc to cancel</span>
<label>Description</label> <button class="btn btn-ghost" onclick="closePromptEditor()">Cancel</button>
<input type="text" id="a-desc" class="form-input"> <button class="btn btn-primary" id="pe-save-btn" onclick="savePromptEditor()">Save</button>
</div>
<div class="form-group">
<label>Prompt</label>
<textarea id="a-prompt" class="form-input" rows="5" style="resize:vertical"></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label>Schedule</label>
<input type="text" id="a-schedule" class="form-input"
placeholder="0 8 * * *" oninput="updateAgentCronPreview(this.value)">
<div id="a-cron-preview" style="font-size:11px;color:var(--text-dim);margin-top:4px"></div>
</div>
<div class="form-group" style="display:flex;flex-direction:column;justify-content:center;gap:8px;padding-top:18px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="a-subagents">
<span>Can create sub-agents</span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="a-enabled" checked>
<span>Enabled</span>
</label>
</div>
</div>
<div class="modal-buttons">
<button class="btn btn-ghost" onclick="closeAgentModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveAgentAndReload()">Save</button>
</div> </div>
</div> </div>
<!-- Editor -->
<textarea id="pe-textarea" spellcheck="false" style="
flex:1;width:100%;box-sizing:border-box;resize:none;border:none;outline:none;
background:var(--bg);color:var(--text);font-family:var(--mono);font-size:13px;
line-height:1.7;padding:24px 32px;
"></textarea>
</div> </div>
{% endblock %} {% endblock %}
+28 -4
View File
@@ -2,21 +2,26 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>{% block title %}{{ brand_name }}{% endblock %}</title> <title>{% block title %}{{ brand_name }}{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/icon.png"> <link rel="icon" type="image/png" href="/static/icon.png">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#0f1117">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/static/icon.png">
<link rel="stylesheet" href="/static/style.css?v={{ sv }}"> <link rel="stylesheet" href="/static/style.css?v={{ sv }}">
{% if theme_css %}<style>{{ theme_css | safe }}</style>{% endif %} {% if theme_css %}<style>{{ theme_css | safe }}</style>{% endif %}
</head> </head>
<body> <body>
<!-- ── Sidebar ── --> <!-- ── Sidebar ── -->
<nav class="sidebar"> <nav class="sidebar" id="sidebar">
<div class="sidebar-logo"> <div class="sidebar-logo">
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img"> <img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
<div class="sidebar-logo-text"> <div class="sidebar-logo-text">
<div class="sidebar-logo-name">{{ brand_name }}</div> <div class="sidebar-logo-name">{{ brand_name }}</div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.3</span></div> <div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.5</span></div>
</div> </div>
</div> </div>
@@ -89,8 +94,22 @@
</div> </div>
</nav> </nav>
<!-- ── Sidebar overlay (mobile) ── -->
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<!-- ── Main column (nag + content) ── --> <!-- ── Main column (nag + content) ── -->
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden"> <div class="app-body">
<!-- ── Mobile header ── -->
<div class="mobile-header">
<button class="hamburger-btn" onclick="toggleSidebar()" aria-label="Menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span class="mobile-title">{{ agent_name }}</span>
<button class="mobile-install-btn" id="pwa-install-btn" onclick="handleInstall()" title="Add to Home Screen" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M12 2v13M7 9l5 5 5-5"/><path d="M5 20h14"/></svg>
</button>
</div>
{% if needs_personality_setup %} {% if needs_personality_setup %}
<div class="nag-banner" id="nag-banner"> <div class="nag-banner" id="nag-banner">
@@ -110,6 +129,11 @@
<script>window.AGENT_NAME = "{{ agent_name }}";</script> <script>window.AGENT_NAME = "{{ agent_name }}";</script>
<script src="/static/app.js?v={{ sv }}"></script> <script src="/static/app.js?v={{ sv }}"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').catch(() => {});
}
</script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>
</html> </html>
+32 -17
View File
@@ -13,7 +13,7 @@
{% if current_user.is_admin %} {% if current_user.is_admin %}
<!-- ── Admin Tabs ── --> <!-- ── Admin Tabs ── -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:28px"> <div class="tab-bar" style="border-bottom:1px solid var(--border);margin-bottom:28px">
<button type="button" class="tab-btn active" id="stab-general" onclick="switchSettingsTab('general')">General</button> <button type="button" class="tab-btn active" id="stab-general" onclick="switchSettingsTab('general')">General</button>
<button type="button" class="tab-btn" id="stab-whitelists" onclick="switchSettingsTab('whitelists')">Whitelists</button> <button type="button" class="tab-btn" id="stab-whitelists" onclick="switchSettingsTab('whitelists')">Whitelists</button>
<button type="button" class="tab-btn" id="stab-credentials" onclick="switchSettingsTab('credentials')">Credentials</button> <button type="button" class="tab-btn" id="stab-credentials" onclick="switchSettingsTab('credentials')">Credentials</button>
@@ -32,7 +32,7 @@
</div> </div>
{% else %} {% else %}
<!-- ── User Tabs ── --> <!-- ── User Tabs ── -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:28px"> <div class="tab-bar" style="border-bottom:1px solid var(--border);margin-bottom:28px">
<button type="button" class="tab-btn active" id="ustab-apikeys" onclick="switchUserTab('apikeys')">API Keys</button> <button type="button" class="tab-btn active" id="ustab-apikeys" onclick="switchUserTab('apikeys')">API Keys</button>
<button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button> <button type="button" class="tab-btn" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button>
<button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button> <button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button>
@@ -1934,18 +1934,18 @@
</div> </div>
<!-- ── Email Handling Account modal ── --> <!-- ── Email Handling Account modal ── -->
<div class="modal-overlay" id="email-account-modal" style="display:none"> <div class="modal-overlay" id="email-account-modal" style="display:none;align-items:flex-start;padding:32px 16px;overflow-y:auto">
<div class="modal" style="max-width:820px;width:100%"> <div class="modal" style="max-width:1100px;width:100%">
<h3 id="eam-title">Add Handling Account</h3> <h3 id="eam-title" style="margin-bottom:16px">Add Handling Account</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:16px"> <div style="display:grid;grid-template-columns:1fr 1.4fr 1fr;gap:24px">
<!-- Left column: IMAP account --> <!-- Column 1: IMAP account -->
<div> <div>
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Account</p> <p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Account</p>
<div class="form-group"><label>Label</label> <div class="form-group"><label>Label</label>
<input type="text" id="eam-label" class="form-input" placeholder="e.g. Work Email"></div> <input type="text" id="eam-label" class="form-input" placeholder="e.g. Work Email"></div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px"> <div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
<div class="form-group"><label>IMAP Host</label> <div class="form-group"><label>IMAP Host</label>
<input type="text" id="eam-imap-host" class="form-input" placeholder="imap.example.com"></div> <input type="text" id="eam-imap-host" class="form-input" placeholder="imap.example.com"></div>
<div class="form-group"><label>Port</label> <div class="form-group"><label>Port</label>
@@ -1955,7 +1955,7 @@
<input type="text" id="eam-imap-username" class="form-input" placeholder="user@example.com"></div> <input type="text" id="eam-imap-username" class="form-input" placeholder="user@example.com"></div>
<div class="form-group"><label>Password</label> <div class="form-group"><label>Password</label>
<input type="password" id="eam-imap-password" class="form-input" placeholder="Leave blank to keep existing"></div> <input type="password" id="eam-imap-password" class="form-input" placeholder="Leave blank to keep existing"></div>
<div class="form-group"><label>Initial load limit <span style="color:var(--text-dim);font-size:11px">(emails on first connect)</span></label> <div class="form-group"><label>Initial load limit <span style="color:var(--text-dim);font-size:11px">(on first connect)</span></label>
<input type="number" id="eam-initial-load-limit" class="form-input" value="200" min="0" max="5000"></div> <input type="number" id="eam-initial-load-limit" class="form-input" value="200" min="0" max="5000"></div>
<div class="form-group"> <div class="form-group">
<label>Monitored folders</label> <label>Monitored folders</label>
@@ -1963,26 +1963,41 @@
<span id="eam-folders-display" style="font-size:13px;color:var(--text-dim)">INBOX</span> <span id="eam-folders-display" style="font-size:13px;color:var(--text-dim)">INBOX</span>
<button type="button" class="btn btn-ghost btn-small" id="eam-load-folders-btn" onclick="loadEamFolders()">Load folders</button> <button type="button" class="btn btn-ghost btn-small" id="eam-load-folders-btn" onclick="loadEamFolders()">Load folders</button>
</div> </div>
<div id="eam-folders-checklist" style="display:none;max-height:160px;overflow-y:auto;background:var(--bg2);padding:8px 12px;border-radius:var(--radius);border:1px solid var(--border)"></div> <div id="eam-folders-checklist" style="display:none;max-height:140px;overflow-y:auto;background:var(--bg2);padding:8px 12px;border-radius:var(--radius);border:1px solid var(--border)"></div>
<input type="hidden" id="eam-folders-hidden" value='["INBOX"]'> <input type="hidden" id="eam-folders-hidden" value='["INBOX"]'>
</div> </div>
</div> </div>
<!-- Right column: Handling agent --> <!-- Column 2: Agent prompt + model -->
<div> <div>
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Handling Agent</p> <p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Handling Agent</p>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:14px;line-height:1.5">
A dedicated agent is created for this account. It can use email tools plus any notification tools you enable below.
</p>
<div class="form-group"><label>Model</label> <div class="form-group"><label>Model</label>
<select id="eam-agent-model" class="form-input"><option value="">Loading…</option></select> <select id="eam-agent-model" class="form-input"><option value="">Loading…</option></select>
</div> </div>
<div class="form-group" style="display:flex;flex-direction:column;flex:1"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="form-group">
<label>Max tool calls <span style="color:var(--text-dim);font-size:11px">(blank = default)</span></label>
<input type="number" id="eam-max-tool-calls" class="form-input" min="1" max="50" placeholder="e.g. 6">
</div>
<div class="form-group">
<label>Prompt mode</label>
<select id="eam-prompt-mode" class="form-input">
<option value="combined">Combined (full personality)</option>
<option value="agent_only">Agent only (cheaper)</option>
</select>
</div>
</div>
<div class="form-group" style="display:flex;flex-direction:column">
<label>Agent prompt</label> <label>Agent prompt</label>
<textarea id="eam-agent-prompt" class="form-input" rows="8" <textarea id="eam-agent-prompt" class="form-input" rows="10"
style="resize:vertical;font-size:13px;line-height:1.5" style="resize:vertical;font-size:13px;line-height:1.5;flex:1"
placeholder="Describe how the agent should handle incoming emails…"></textarea> placeholder="Describe how the agent should handle incoming emails…"></textarea>
</div> </div>
</div>
<!-- Column 3: Options -->
<div>
<p style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);margin-bottom:14px;font-weight:600">Options</p>
<div class="form-group"> <div class="form-group">
<label>Notification tools <span style="font-size:11px;color:var(--text-dim)">(optional)</span></label> <label>Notification tools <span style="font-size:11px;color:var(--text-dim)">(optional)</span></label>
<div id="eam-extra-tools-area" style="margin-top:6px;display:flex;flex-direction:column;gap:8px"> <div id="eam-extra-tools-area" style="margin-top:6px;display:flex;flex-direction:column;gap:8px">
+2 -2
View File
@@ -4,10 +4,10 @@
{% block content %} {% block content %}
<div class="page" id="usage-container"> <div class="page" id="usage-container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px"> <div class="usage-header">
<h1>Usage</h1> <h1>Usage</h1>
<!-- Time range filter + admin actions --> <!-- Time range filter + admin actions -->
<div style="display:flex;gap:6px;align-items:center"> <div class="usage-header-actions">
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button> <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-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-30d" type="button" onclick="setUsageRange('30d')">30 days</button>