2 Commits

Author SHA1 Message Date
rune c5a5356a0d Updated README.md 2026-04-21 11:09:51 +02:00
rune eaea8d94b1 Made Jarvis more mobile friendly 2026-04-21 11:00:39 +02:00
15 changed files with 652 additions and 116 deletions
+48 -19
View File
@@ -1,23 +1,45 @@
# oAI-Web - Personal AI Agent
A secure, self-hosted personal AI agent. Handles calendar, email, files, web research, and Telegram - controlled by you, running on your own hardware.
A secure, self-hosted personal AI agent. Handles calendar, email, files, web research, Telegram, and more - controlled by you, running on your own hardware.
## Features
- **Chat interface** - conversational UI via browser, with model selector
- **Chat interface** - conversational UI via browser, with model selector and markdown rendering
- **CalDAV** - read and write calendar events (per-user credentials, configured in Settings)
- **CardDAV / Contacts** - search and manage contacts from your CardDAV server
- **Email** - read inbox, send replies (whitelist-managed recipients)
- **Filesystem** - read/write files in your personal data folder
- **Web access** - tiered: whitelisted domains always allowed, others on request
- **Browser** - headless Chromium via Playwright; fetch pages, take screenshots, click, fill forms, and interact with web UIs (full image only)
- **Push notifications** - Pushover for iOS/Android (set your own User Key in Settings)
- **Telegram** - send and receive messages via your own bot
- **Webhooks** - trigger agents from external services (iOS Shortcuts, GitHub, Home Assistant, etc.)
- **Monitors** - page-change and RSS feed monitors that dispatch agents automatically
- **Scheduled tasks** - cron-based autonomous tasks with declared permission scopes
- **Agents** - goal-oriented runs with model selection and full run history
- **2nd Brain** - personal semantic memory powered by pgvector; capture and search thoughts across sessions
- **MCP Servers** - connect external Model Context Protocol servers and expose their tools to your agents
- **Image generation** - generate images via your configured provider
- **Audit log** - every tool call logged, append-only
- **Multi-user** - each user has their own credentials and settings
- **PWA** - installable on iOS and Android home screens; mobile-optimised layout
---
## Docker Images
Four pre-built images are available. Choose the one that matches your architecture and whether you need the browser tool.
| Image | Architecture | Browser tool |
|-------|-------------|--------------|
| `image.gitlab.pm/rune/oai-web:latest` | amd64 | Yes |
| `image.gitlab.pm/rune/oai-web:latest-no-browser` | amd64 | No |
| `image.gitlab.pm/rune/oai-web:latest_arm64` | arm64 | Yes |
| `image.gitlab.pm/rune/oai-web:latest-no-browser_arm64` | arm64 | No |
**Full image** includes Playwright and a Chromium installation. This adds roughly 350 MB to the image size but enables the browser tool - the agent can fetch pages, take screenshots, click elements, fill forms, and navigate web UIs on your behalf.
**No-browser image** is leaner and faster to pull. All other tools and features are identical. Choose this if you don't need the agent to interact with web pages directly, or if you are constrained on disk space.
---
@@ -28,9 +50,10 @@ A secure, self-hosted personal AI agent. Handles calendar, email, files, web res
- A PostgreSQL-compatible host (included in the compose file)
---
## Documentation
There is a [documentation site](https://docs.jarvis.pm) with in depth information on the project.
There is a [documentation site](https://docs.jarvis.pm) with in-depth information on the project.
## Installation
@@ -98,7 +121,6 @@ PORT=8080
TIMEZONE=Europe/Oslo
```
---
### Database *
@@ -223,11 +245,11 @@ The file is mounted read-only into the container. Changes take effect on the nex
## Your Settings
After logging in, go to **Settings** to configure your personal services. Each user has their own credentials nothing is shared with other users.
After logging in, go to **Settings** to configure your personal services. Each user has their own credentials - nothing is shared with other users.
### CalDAV / CardDAV
Set up your personal calendar and contacts server under **Settings CalDAV / CardDAV**:
Set up your personal calendar and contacts server under **Settings - CalDAV / CardDAV**:
- Enter your server URL (e.g. `mail.example.com`), username, and password
- Optionally specify a calendar name (leave blank for the default calendar)
@@ -235,7 +257,7 @@ Set up your personal calendar and contacts server under **Settings → CalDAV /
- Use the **Test** buttons to verify your connection before saving
- Enable **Allow contact writes** if you want agents to be able to create and update contacts
There is no system-wide fallback if you don't configure it, calendar and contacts tools won't be available to your agents.
There is no system-wide fallback - if you don't configure it, calendar and contacts tools won't be available to your agents.
### Pushover
@@ -243,29 +265,33 @@ To receive push notifications on your iOS or Android device:
1. Create a free account at [pushover.net](https://pushover.net)
2. Copy your **User Key** from the dashboard
3. Go to **Settings Pushover** and save your User Key
3. Go to **Settings - Pushover** and save your User Key
The app is already registered by your admin you only need your own User Key.
The app is already registered by your admin - you only need your own User Key.
### Webhooks
Create inbound webhooks under **Settings Webhooks** to trigger your agents from external services:
Create inbound webhooks under **Settings - Webhooks** to trigger your agents from external services:
- Assign a name and target agent, then copy the secret token shown at creation (it's shown only once)
- **POST trigger**: send `{"message": "your message"}` to `/webhook/{token}`
- **GET trigger**: visit `/webhook/{token}?q=your+message` ideal for iOS Shortcuts URL actions
- **GET trigger**: visit `/webhook/{token}?q=your+message` - ideal for iOS Shortcuts URL actions
- Enable or disable webhooks without deleting them
### Telegram
Set your personal bot token under **Settings Telegram** (or **Settings Profile Telegram Bot Token**) if you want your own Telegram bot. Your chat ID must be whitelisted by the admin before messages are processed.
Set your personal bot token under **Settings - Telegram** (or **Settings - Profile - Telegram Bot Token**) if you want your own Telegram bot. Your chat ID must be whitelisted by the admin before messages are processed.
### Email Accounts
Set up your own email accounts under **Settings Email Accounts**:
Set up your own email accounts under **Settings - Email Accounts**:
- **Trigger account** dispatches agents based on keyword rules in incoming emails
- **Handling account** a dedicated AI agent reads and handles each incoming email
- **Trigger account** - dispatches agents based on keyword rules in incoming emails
- **Handling account** - a dedicated AI agent reads and handles each incoming email
### Browser - Trusted Domains
When the agent uses the browser tool to interact with a page (clicking, filling forms), it will ask for confirmation unless the domain is pre-approved. Add trusted domains under **Settings - Browser** to skip the confirmation prompt for those sites.
---
@@ -283,11 +309,14 @@ docker compose up -d
| URL | Description |
|-----|-------------|
| `/` | Chat - send messages, select model, view tool activity |
| `/chats` | Chat history - browse and resume past conversations |
| `/tasks` | Scheduled tasks - cron-based autonomous tasks |
| `/agents` | Agents - goal-oriented runs with model selection and run history |
| `/monitors` | Monitors - page-change watchers and RSS feed monitors |
| `/files` | Files - browse, download, and manage your personal data folder |
| `/models` | Model browser - all available models, capabilities, and pricing |
| `/files` | Files - browse, view, and manage your personal data folder |
| `/audit` | Audit log - filterable view of every tool call |
| `/usage` | Usage and costs - token usage and cost tracking across runs |
| `/settings` | Your personal settings: CalDAV, CardDAV, Pushover, Webhooks, Telegram, Email Accounts, and more |
---
@@ -316,10 +345,10 @@ Contributions are welcome!
## Disclaimer
oAI-Web takes real actions on your behalf it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
oAI-Web takes real actions on your behalf - it can send emails, write files, make calendar changes, post Telegram messages, and interact with web pages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind - the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms.
---
**Star this project if you find it useful!**
**Star this project if you find it useful!**
**🐛 Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-web/issues/new)
**Found a bug?** [Open an issue](https://gitlab.pm/rune/oai-web/issues/new)
+4 -4
View File
@@ -28,13 +28,13 @@ async def list_accounts(user_id: str | None = None) -> list[dict]:
pool = await get_pool()
if user_id is None:
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"
" ORDER BY ea.created_at"
)
else:
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"
" WHERE ea.user_id = $1 ORDER BY ea.created_at",
user_id,
@@ -46,7 +46,7 @@ async def list_accounts_enabled() -> list[dict]:
"""Return all enabled accounts (used by listener on startup)."""
pool = await get_pool()
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"
" 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:
pool = await get_pool()
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"
" WHERE ea.id = $1",
account_id,
+4 -2
View File
@@ -496,8 +496,10 @@ class EmailAccountListener:
f"- Memory file: {mem_path}\n"
f"- Reasoning log: {log_path}\n"
f"Read the memory file before acting. "
f"Append a 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"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 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(
+8 -1
View File
@@ -55,7 +55,7 @@ logging.basicConfig(
logging.getLogger("server.tools.caldav_tool").setLevel(logging.DEBUG)
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.templating import Jinja2Templates
@@ -412,6 +412,13 @@ app.add_middleware(_AuthMiddleware)
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static")
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)
app.mount("/brain-mcp", create_mcp_app())
+17 -5
View File
@@ -83,7 +83,12 @@ class AnthropicProvider(AIProvider):
"max_tokens": max_tokens,
}
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:
# aide tool schemas ARE Anthropic format — pass through directly
params["tools"] = tools
@@ -163,10 +168,17 @@ class AnthropicProvider(AIProvider):
arguments=block.input,
))
usage = UsageStats(
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
) if response.usage else UsageStats()
if response.usage:
cache_read = getattr(response.usage, "cache_read_input_tokens", 0) or 0
cache_write = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
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"
if tool_calls:
+2
View File
@@ -24,6 +24,8 @@ class ToolCallResult:
class UsageStats:
input_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
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_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
agent = await create_agent(
name=f"Email Handler: {label}",
@@ -2593,6 +2601,8 @@ async def create_my_email_account(request: Request):
allowed_tools=["email"],
created_by="user",
owner_user_id=user.id,
max_tool_calls=agent_max_tool_calls,
prompt_mode=agent_prompt_mode,
)
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")
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"):
agent_fields = {}
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"]
if "label" in body:
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:
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 _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() {
_setActiveNav(location.pathname);
@@ -149,6 +196,7 @@ function initNav() {
const href = el.getAttribute("href");
if (!href || href.startsWith("http")) return; // external links: normal
e.preventDefault();
closeSidebar();
navigateTo(href);
});
});
@@ -344,6 +392,10 @@ async function fileBrowserNavigate(path) {
nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
nameTd.textContent = entry.name;
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 {
nameTd.textContent = entry.name;
}
@@ -359,16 +411,6 @@ 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) {
@@ -785,7 +827,10 @@ function openModelPicker() {
_mpFocusIdx = -1;
modal.classList.remove("hidden");
const s = document.getElementById("model-picker-search");
if (s) { s.value = ""; s.focus(); }
if (s) {
s.value = "";
if (window.innerWidth > 768) s.focus();
}
_renderModelPicker("");
}
@@ -1548,6 +1593,45 @@ function closeAuditDetail() {
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) {
["general", "whitelists", "credentials", "caldav", "pushover", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "webhooks", "mfa"].forEach(t => {
const pane = document.getElementById(`spane-${t}`);
@@ -1555,6 +1639,7 @@ function switchSettingsTab(name) {
if (pane) pane.style.display = t === name ? "" : "none";
if (btn) btn.classList.toggle("active", t === name);
});
_syncTabSelect(name);
if (name === "inbox") { loadInboxStatus(); }
if (name === "emailaccounts") { loadEmailAccounts(); }
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
@@ -1698,6 +1783,7 @@ function switchUserTab(name) {
const pane = document.getElementById("uspane-" + name);
if (tab) tab.classList.add("active");
if (pane) pane.style.display = "";
_syncTabSelect(name);
if (name === "mcp") loadMyMcpServers();
if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); }
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-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]);
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);
_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 agent_model = document.getElementById("eam-agent-model").value;
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 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);
@@ -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; }
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 };
const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts";
@@ -2668,6 +2762,7 @@ function togglePasswordVisibility(inputId, btn) {
}
function initUserSettings() {
_initMobileTabSelect(switchUserTab);
const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys";
switchUserTab(tab);
loadMyProviderKeys();
@@ -2688,6 +2783,7 @@ function initSettings() {
}
if (!document.getElementById("limits-form")) return;
_initMobileTabSelect(switchSettingsTab);
loadApiKeyStatus();
loadProviderKeys();
loadUsersBaseFolder();
@@ -4848,7 +4944,46 @@ async function editCurrentAgent() {
if (!agentId) return;
const r = await fetch(`/api/agents/${agentId}`);
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() {
@@ -4876,6 +5011,13 @@ async function saveAgentAndReload() {
function initAgentDetail() {
if (!document.getElementById("agent-detail-container")) return;
_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() {
@@ -5257,6 +5399,23 @@ function initHelp() {
// Disconnect any previous observer (SPA re-navigation)
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 sections = document.querySelectorAll("section[data-section]");
@@ -5279,6 +5438,15 @@ function initHelp() {
e.preventDefault();
const target = document.querySelector(a.getAttribute("href"));
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-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 { 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; }
@@ -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-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); }
/* ── 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>
<!-- Edit modal (reuse agent modal markup with inline) -->
<div class="modal-overlay hidden" id="agent-modal">
<div class="modal" style="max-width:560px;width:100%">
<h3 id="agent-modal-title">Edit Agent</h3>
<input type="hidden" id="a-id">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="form-group">
<label>Name</label>
<input type="text" id="a-name" class="form-input" required>
</div>
<div class="form-group">
<label>Model</label>
<select id="a-model" class="form-input"></select>
</div>
<!-- Fullscreen prompt editor -->
<div id="prompt-editor-overlay" style="
display:none;position:fixed;inset:0;z-index:1001;
background:var(--bg);flex-direction:column;
">
<!-- Header bar -->
<div style="
display:flex;align-items:center;justify-content:space-between;
padding:12px 20px;border-bottom:1px solid var(--border);
background:var(--bg2);flex-shrink:0;gap:12px
">
<div style="display:flex;align-items:center;gap:12px;min-width:0">
<span style="font-size:13px;color:var(--text-dim);white-space:nowrap">Editing prompt —</span>
<span id="pe-agent-name" style="font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" id="a-desc" class="form-input">
</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 style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<span style="font-size:11px;color:var(--text-dim)">Ctrl+S to save · Esc to cancel</span>
<button class="btn btn-ghost" onclick="closePromptEditor()">Cancel</button>
<button class="btn btn-primary" id="pe-save-btn" onclick="savePromptEditor()">Save</button>
</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>
{% endblock %}
+28 -4
View File
@@ -2,21 +2,26 @@
<html lang="en">
<head>
<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>
<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 }}">
{% if theme_css %}<style>{{ theme_css | safe }}</style>{% endif %}
</head>
<body>
<!-- ── Sidebar ── -->
<nav class="sidebar">
<nav class="sidebar" id="sidebar">
<div class="sidebar-logo">
<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.3</span></div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.5</span></div>
</div>
</div>
@@ -89,8 +94,22 @@
</div>
</nav>
<!-- ── Sidebar overlay (mobile) ── -->
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<!-- ── 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 %}
<div class="nag-banner" id="nag-banner">
@@ -110,6 +129,11 @@
<script>window.AGENT_NAME = "{{ agent_name }}";</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 %}
</body>
</html>
+32 -17
View File
@@ -13,7 +13,7 @@
{% if current_user.is_admin %}
<!-- ── 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" id="stab-whitelists" onclick="switchSettingsTab('whitelists')">Whitelists</button>
<button type="button" class="tab-btn" id="stab-credentials" onclick="switchSettingsTab('credentials')">Credentials</button>
@@ -32,7 +32,7 @@
</div>
{% else %}
<!-- ── 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" id="ustab-personality" onclick="switchUserTab('personality')">Personality</button>
<button type="button" class="tab-btn" id="ustab-inbox" onclick="switchUserTab('inbox')">Inbox</button>
@@ -1934,18 +1934,18 @@
</div>
<!-- ── Email Handling Account modal ── -->
<div class="modal-overlay" id="email-account-modal" style="display:none">
<div class="modal" style="max-width:820px;width:100%">
<h3 id="eam-title">Add Handling Account</h3>
<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:1100px;width:100%">
<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>
<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>
<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>
<input type="text" id="eam-imap-host" class="form-input" placeholder="imap.example.com"></div>
<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>
<div class="form-group"><label>Password</label>
<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>
<div class="form-group">
<label>Monitored folders</label>
@@ -1963,26 +1963,41 @@
<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>
</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"]'>
</div>
</div>
<!-- Right column: Handling agent -->
<!-- Column 2: Agent prompt + model -->
<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: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>
<select id="eam-agent-model" class="form-input"><option value="">Loading…</option></select>
</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>
<textarea id="eam-agent-prompt" class="form-input" rows="8"
style="resize:vertical;font-size:13px;line-height:1.5"
<textarea id="eam-agent-prompt" class="form-input" rows="10"
style="resize:vertical;font-size:13px;line-height:1.5;flex:1"
placeholder="Describe how the agent should handle incoming emails…"></textarea>
</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">
<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">
+2 -2
View File
@@ -4,10 +4,10 @@
{% block content %}
<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>
<!-- 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-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>