Version 1.2.2. Added usage overview. Shows token used and cost in $.

This commit is contained in:
2026-04-15 10:00:39 +02:00
parent 752691fe54
commit d4c6420481
18 changed files with 1657 additions and 86 deletions

View File

@@ -470,7 +470,7 @@ class Agent:
confirmed = False
# Confirmation flow (interactive sessions only)
if tool.requires_confirmation and task_id is None:
if await tool.should_confirm(**tc.arguments) and task_id is None:
description = tool.confirmation_description(**tc.arguments)
yield ConfirmationRequiredEvent(
call_id=tc.id,
@@ -697,12 +697,18 @@ class Agent:
# Update in-memory history for multi-turn
self._session_history[session_id] = messages
# Persist conversation to DB
# Persist conversation to DB (with accumulated usage for cost tracking)
from ..providers.models import compute_cost_usd as _compute_cost
_model_str = model or "" # full "provider:model" string for pricing lookup
_cost = _compute_cost(_model_str, total_usage.input_tokens, total_usage.output_tokens) if _model_str else None
await _save_conversation(
session_id=session_id,
messages=messages,
task_id=task_id,
model=response.model or run_model or model or "",
input_tokens=total_usage.input_tokens,
output_tokens=total_usage.output_tokens,
cost_usd=_cost,
)
yield DoneEvent(
@@ -735,6 +741,9 @@ async def _save_conversation(
messages: list[dict],
task_id: str | None,
model: str = "",
input_tokens: int = 0,
output_tokens: int = 0,
cost_usd: float | None = None,
) -> None:
from ..context_vars import current_user as _cu
user_id = _cu.get().id if _cu.get() else None
@@ -745,26 +754,41 @@ async def _save_conversation(
"SELECT id, title FROM conversations WHERE id = $1", session_id
)
if existing:
# Only update title if still unset (don't overwrite a user-renamed title)
# Accumulate tokens across turns; only update title if still unset
if not existing["title"]:
title = _derive_title(messages)
await pool.execute(
"UPDATE conversations SET messages = $1, ended_at = $2, title = $3, model = $4 WHERE id = $5",
messages, now, title, model or None, session_id,
"""UPDATE conversations
SET messages = $1, ended_at = $2, title = $3, model = $4,
input_tokens = COALESCE(input_tokens, 0) + $5,
output_tokens = COALESCE(output_tokens, 0) + $6,
cost_usd = COALESCE(cost_usd, 0) + COALESCE($7, 0)
WHERE id = $8""",
messages, now, title, model or None,
input_tokens, output_tokens, cost_usd, session_id,
)
else:
await pool.execute(
"UPDATE conversations SET messages = $1, ended_at = $2, model = $3 WHERE id = $4",
messages, now, model or None, session_id,
"""UPDATE conversations
SET messages = $1, ended_at = $2, model = $3,
input_tokens = COALESCE(input_tokens, 0) + $4,
output_tokens = COALESCE(output_tokens, 0) + $5,
cost_usd = COALESCE(cost_usd, 0) + COALESCE($6, 0)
WHERE id = $7""",
messages, now, model or None,
input_tokens, output_tokens, cost_usd, session_id,
)
else:
title = _derive_title(messages)
await pool.execute(
"""
INSERT INTO conversations (id, started_at, ended_at, messages, task_id, user_id, title, model)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO conversations
(id, started_at, ended_at, messages, task_id, user_id, title, model,
input_tokens, output_tokens, cost_usd)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
""",
session_id, now, now, messages, task_id, user_id, title, model or None,
input_tokens, output_tokens, cost_usd,
)
except Exception as e:
logger.error(f"Failed to save conversation {session_id}: {e}")

View File

@@ -277,12 +277,17 @@ class AgentRunner:
elif isinstance(event, ErrorEvent):
final_text = f"Error: {event.message}"
run_model = agent_data.get("model") or ""
from ..providers.models import compute_cost_usd
cost = compute_cost_usd(run_model, input_tokens, output_tokens) if run_model else None
await agent_store.finish_run(
run_id,
status="success",
input_tokens=input_tokens,
output_tokens=output_tokens,
result=final_text,
model=run_model or None,
cost_usd=cost,
)
logger.info(
f"[agent-runner] Agent '{agent_data['name']}' run={run_id[:8]} completed OK"

View File

@@ -1,5 +1,20 @@
"""
agents/tasks.py — Agent and agent run CRUD operations (async).
agents/tasks.py — Agent and agent run CRUD operations (async, PostgreSQL).
Agents are persistent, named, goal-oriented configurations that can be:
- Run manually (via the Agents UI or /api/agents/{id}/run)
- Scheduled with a cron expression (managed by AgentRunner + APScheduler)
- Triggered by email, Telegram, webhooks, or monitors
agent_runs records every execution — input/output tokens, status, result text.
This is the source of truth for the Agents UI run history and token totals.
Design note on allowed_tools:
- NULL in DB means "all tools" (unlimited access)
- [] (empty list) is falsy in Python — treated identically to NULL
- Non-empty list restricts the agent to exactly those tools
- This is enforced structurally: only declared schemas are sent to the model;
it is impossible for the model to call a tool it wasn't given a schema for.
"""
from __future__ import annotations
@@ -161,6 +176,8 @@ async def finish_run(
output_tokens: int = 0,
result: str | None = None,
error: str | None = None,
model: str | None = None,
cost_usd: float | None = None,
) -> dict | None:
now = _now()
pool = await get_pool()
@@ -168,10 +185,12 @@ async def finish_run(
"""
UPDATE agent_runs
SET ended_at = $1, status = $2, input_tokens = $3,
output_tokens = $4, result = $5, error = $6
WHERE id = $7
output_tokens = $4, result = $5, error = $6,
model = $7, cost_usd = $8
WHERE id = $9
""",
now, status, input_tokens, output_tokens, result, error, run_id,
now, status, input_tokens, output_tokens, result, error,
model or None, cost_usd, run_id,
)
return await get_run(run_id)

View File

@@ -465,6 +465,29 @@ _MIGRATIONS: list[list[str]] = [
[
"ALTER TABLE webhook_targets ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE",
],
# v28 — Browser trusted domains (per-user interaction pre-approval)
[
"""
CREATE TABLE IF NOT EXISTS browser_approved_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (owner_user_id, domain)
)
""",
],
# v29 — Track model per agent run for usage/cost overview
[
"ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS model TEXT",
],
# v30 — Track token usage and cost per chat conversation
[
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS input_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS output_tokens INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE conversations ADD COLUMN IF NOT EXISTS cost_usd REAL",
],
]
@@ -821,7 +844,15 @@ filesystem_whitelist_store = FilesystemWhitelistStore()
# ─── Initialisation ───────────────────────────────────────────────────────────
async def _init_connection(conn: asyncpg.Connection) -> None:
"""Register codecs on every new connection so asyncpg handles JSONB ↔ dict."""
"""
Register codecs on every new connection so asyncpg handles JSONB ↔ dict automatically.
Critical: asyncpg does NOT auto-serialize Python dicts to PostgreSQL JSONB.
Without this, inserting a dict into a JSONB column raises:
asyncpg.exceptions.UnsupportedClientFeatureError: ...
The codecs must be registered on every connection (not just once on the pool),
which is why this is passed as `init=` to create_pool().
"""
await conn.set_type_codec(
"jsonb",
encoder=json.dumps,

View File

@@ -85,6 +85,14 @@ class EmailAccountListener:
# ── Main loop ─────────────────────────────────────────────────────────────
async def _run_loop(self) -> None:
"""
Outer retry loop. Restarts the inner trigger/handling loop on any error
with exponential backoff (5s → 10s → 20s → ... → 60s max).
CancelledError is propagated cleanly (listener.stop() cancels the task).
Any other exception is logged and retried — typical causes: IMAP disconnect,
network timeout, credential rotation. Backoff resets on successful cycle.
"""
backoff = 5
while True:
try:

View File

@@ -2,9 +2,38 @@
main.py — FastAPI application entry point.
Provides:
- HTML pages: /, /agents, /audit, /settings, /login, /setup, /admin/users
- WebSocket: /ws/{session_id} (streaming agent responses)
- REST API: /api/*
- HTML pages: /, /chats, /agents, /models, /audit, /monitors, /files,
/settings, /login, /setup, /admin/users, /help
- WebSocket: /ws/{session_id} (streaming agent responses)
- REST API: /api/* (see server/web/routes.py)
- Brain MCP: /brain-mcp/sse (MCP protocol for 2nd Brain access)
Startup order (lifespan):
1. init_db() — run PostgreSQL migrations, create pool
2. _refresh_brand_globals() — load brand name/logo from DB into Jinja2 globals
3. _ensure_session_secret() — auto-generate HMAC signing secret if not set
4. check user_count() — set _needs_setup flag (redirects to /setup if 0 users)
5. cleanup_stale_runs() — mark any interrupted "running" agent_runs as "error"
6. init_brain_db() — connect to brain PostgreSQL (pgvector)
7. build_registry() — create ToolRegistry with all production tools
8. discover_and_register_mcp_tools() — connect to configured MCP servers and add their tools
9. Agent(registry=...) — create the singleton agent loop
10. agent_runner.init/start() — load agent cron schedules into APScheduler, start scheduler
11. page_monitor / rss_monitor — wire into shared APScheduler, load schedules
12. _migrate_email_accounts() — one-time migration of old inbox:* credentials
13. inbox_listener.start_all() — start IMAP listeners for all email accounts
14. telegram_listener.start() — start Telegram long-polling listener
15. _session_manager.run() — start Brain MCP session manager
Key singletons (module-level, shared across all requests):
_agent: Agent instance — owns in-memory session history
_registry: ToolRegistry — the set of available tools
Both are initialised in lifespan() and referenced by the WebSocket handler.
Auth middleware (_AuthMiddleware):
Runs on every request before route handlers. Validates the session cookie or
API key, stores the resolved CurrentUser in the current_user ContextVar.
Routes use _require_auth() / _require_admin() helpers to enforce access control.
"""
from __future__ import annotations
@@ -716,6 +745,22 @@ async def setup_post(request: Request):
# ── HTML pages ────────────────────────────────────────────────────────────────
async def _user_can_view_usage(user) -> bool:
"""Admins always; non-admins only if they have their own API key and aren't on admin keys."""
if not user:
return False
if user.is_admin:
return True
from .database import user_settings_store
if await user_settings_store.get(user.id, "use_admin_keys"):
return False
return bool(
await user_settings_store.get(user.id, "anthropic_api_key") or
await user_settings_store.get(user.id, "openrouter_api_key") or
await user_settings_store.get(user.id, "openai_api_key")
)
async def _ctx(request: Request, **extra):
"""Build template context with current_user and active theme CSS injected."""
from .web.themes import get_theme_css, DEFAULT_THEME
@@ -734,6 +779,7 @@ async def _ctx(request: Request, **extra):
"current_user": user,
"theme_css": theme_css,
"needs_personality_setup": needs_personality_setup,
"can_view_usage": await _user_can_view_usage(user),
**extra,
}
@@ -765,6 +811,15 @@ async def models_page(request: Request):
return templates.TemplateResponse("models.html", await _ctx(request))
@app.get("/usage", response_class=HTMLResponse)
async def usage_page(request: Request):
user = _get_current_user(request)
if not await _user_can_view_usage(user):
from fastapi.responses import RedirectResponse
return RedirectResponse("/", status_code=303)
return templates.TemplateResponse("usage.html", await _ctx(request))
@app.get("/audit", response_class=HTMLResponse)
async def audit_page(request: Request):
return templates.TemplateResponse("audit.html", await _ctx(request))

View File

@@ -372,6 +372,50 @@ async def get_models_info(
return results
def get_model_pricing(model_id: str) -> tuple[float | None, float | None]:
"""
Return (prompt_per_1m, completion_per_1m) in USD for the given model ID.
Uses only in-memory data (hardcoded Anthropic/OpenAI + cached OpenRouter raw).
Returns (None, None) if pricing is unknown for this model.
model_id format: "anthropic:claude-sonnet-4-6", "openrouter:openai/gpt-4o", "openai:gpt-4o"
"""
for m in _ANTHROPIC_MODEL_INFO:
if m["id"] == model_id:
p = m["pricing"]
return p["prompt_per_1m"], p["completion_per_1m"]
for m in _OPENAI_MODEL_INFO:
if m["id"] == model_id:
p = m["pricing"]
return p["prompt_per_1m"], p["completion_per_1m"]
# OpenRouter: strip "openrouter:" prefix to get the bare OR model id
if model_id.startswith("openrouter:"):
bare = model_id[len("openrouter:"):]
for m in _or_raw:
if m.get("id", "") == bare and not _is_free_openrouter(m):
pricing = m.get("pricing", {})
try:
prompt = float(pricing.get("prompt", 0)) * 1_000_000
completion = float(pricing.get("completion", 0)) * 1_000_000
return prompt, completion
except (TypeError, ValueError):
return None, None
return None, None
def compute_cost_usd(
model_id: str,
input_tokens: int,
output_tokens: int,
) -> float | None:
"""Compute estimated cost in USD for a completed run. Returns None if pricing unknown."""
prompt_per_1m, completion_per_1m = get_model_pricing(model_id)
if prompt_per_1m is None or completion_per_1m is None:
return None
return (input_tokens / 1_000_000) * prompt_per_1m + (output_tokens / 1_000_000) * completion_per_1m
async def get_access_tier(
user_id: str | None = None,
is_admin: bool = True,

View File

@@ -62,6 +62,14 @@ class BaseTool(ABC):
"input_schema": self.input_schema,
}
async def should_confirm(self, **kwargs) -> bool:
"""
Return True if this specific call requires confirmation.
Override in subclasses for per-call logic (e.g. domain allow-lists).
Default: return self.requires_confirmation.
"""
return self.requires_confirmation
def confirmation_description(self, **kwargs) -> str:
"""
Human-readable description of the action shown to the user

View File

@@ -1,9 +1,12 @@
"""
tools/browser_tool.py — Playwright headless browser tool.
For JS-heavy pages that httpx can't render. Enforces the same Tier 1/2
web whitelist as WebTool. Browser instance is lazy-initialized and shared
across calls.
Read operations (fetch_page, screenshot) never require confirmation.
Interactive operations (click, fill, select, press) require confirmation
unless the target domain is in the user's browser_approved_domains list.
Sessions are stateful within a session_id: navigate with fetch_page first,
then use interactive ops without a url to act on the current page.
Requires: playwright package + `playwright install chromium`
"""
@@ -13,7 +16,7 @@ import asyncio
import logging
from typing import ClassVar
from ..context_vars import current_task_id, web_tier2_enabled
from ..context_vars import current_task_id, current_session_id, web_tier2_enabled
from ..security import assert_domain_tier1, sanitize_external_content
from .base import BaseTool, ToolResult
@@ -21,14 +24,39 @@ logger = logging.getLogger(__name__)
_MAX_TEXT_CHARS = 25_000
_TIMEOUT_MS = 30_000
_USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
_INTERACTIVE_OPS = {"click", "fill", "select", "press"}
async def _is_domain_approved(user_id: str, hostname: str) -> bool:
"""Return True if hostname (or a parent domain) is in the user's approved list."""
from ..database import get_pool
pool = await get_pool()
rows = await pool.fetch(
"SELECT domain FROM browser_approved_domains WHERE owner_user_id = $1",
user_id,
)
hostname = hostname.lower()
for row in rows:
d = row["domain"].lower().lstrip("*.")
if hostname == d or hostname.endswith("." + d):
return True
return False
class BrowserTool(BaseTool):
name = "browser"
description = (
"Fetch web pages using a real headless browser (Chromium). "
"Use this for JS-heavy pages or single-page apps that the regular 'web' tool cannot read. "
"Operations: fetch_page (extract text content), screenshot (base64 PNG). "
"Headless Chromium browser for JS-heavy pages and web interactions. "
"Read ops: fetch_page (extract text), screenshot (PNG). "
"Interactive ops: click, fill (type into field), select (dropdown), press (keyboard key). "
"Interactive ops require confirmation unless the domain is in your Browser Trusted Domains list. "
"Page state is kept across calls within the same session — navigate with fetch_page first, "
"then use interactive ops (omit url to stay on the current page). "
"Follows the same domain whitelist rules as the web tool."
)
input_schema = {
@@ -36,87 +64,129 @@ class BrowserTool(BaseTool):
"properties": {
"operation": {
"type": "string",
"enum": ["fetch_page", "screenshot"],
"description": "fetch_page extracts text; screenshot returns a base64 PNG.",
"enum": ["fetch_page", "screenshot", "click", "fill", "select", "press"],
"description": (
"fetch_page: extract page text. "
"screenshot: capture PNG. "
"click: click an element (selector required). "
"fill: type into a field (selector + value required). "
"select: choose a <select> option (selector + value required). "
"press: press a keyboard key (key required; selector optional)."
),
},
"url": {
"type": "string",
"description": "URL to navigate to.",
"description": (
"URL to navigate to. Required for fetch_page and screenshot. "
"For interactive ops, omit to act on the current page."
),
},
"selector": {
"type": "string",
"description": "CSS selector for click / fill / select / press operations.",
},
"value": {
"type": "string",
"description": "Text to type (fill) or option value to select (select).",
},
"key": {
"type": "string",
"description": "Key name for press (e.g. 'Enter', 'Tab', 'Escape', 'ArrowDown').",
},
"wait_for": {
"type": "string",
"description": "CSS selector to wait for before extracting content (optional).",
"description": "CSS selector to wait for before acting (optional).",
},
"extract_selector": {
"type": "string",
"description": "CSS selector to extract text from (optional; defaults to full page).",
"description": "CSS selector for text extraction in fetch_page (optional; defaults to full page body).",
},
},
"required": ["operation", "url"],
"required": ["operation"],
}
requires_confirmation = False
allowed_in_scheduled_tasks = False # Too resource-heavy for scheduled agents
requires_confirmation = True # default; read ops override via should_confirm()
allowed_in_scheduled_tasks = False
# Module-level shared browser/playwright (lazy-init, reused)
# Shared Playwright/browser instance (lazy-init)
_playwright = None
_browser = None
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
async def execute(self, operation: str, url: str = "", wait_for: str = "", extract_selector: str = "", **_) -> ToolResult:
if not url:
return ToolResult(success=False, error="'url' is required")
# Per-session pages: session_id → (context, page)
_sessions: ClassVar[dict] = {}
# Whitelist check (same Tier 1/2 rules as WebTool)
denied = await self._check_tier(url)
if denied:
return denied
# ── Confirmation logic ────────────────────────────────────────────────────
try:
from playwright.async_api import async_playwright
except ImportError:
return ToolResult(
success=False,
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
)
async def should_confirm(self, operation: str = "", url: str = "", **_) -> bool:
if operation not in _INTERACTIVE_OPS:
return False # read-only ops never need confirmation
try:
# Determine the target hostname
target_url = url
if not target_url:
# Acting on the current page — check its URL
sid = current_session_id.get() or "default"
data = BrowserTool._sessions.get(sid)
if data:
try:
target_url = data[1].url
except Exception:
pass
if not target_url:
return True # Unknown target → confirm to be safe
from urllib.parse import urlparse
hostname = urlparse(target_url).hostname or ""
if not hostname:
return True
from ..context_vars import current_user as _cu
user = _cu.get()
if not user:
return True
return not await _is_domain_approved(user.id, hostname)
def confirmation_description(self, operation: str = "", url: str = "",
selector: str = "", value: str = "", key: str = "", **_) -> str:
loc = url or "current page"
if operation == "click":
return f"Click '{selector}' on {loc}"
if operation == "fill":
display_val = value[:40] + "" if len(value) > 40 else value
return f"Type \"{display_val}\" into '{selector}' on {loc}"
if operation == "select":
return f"Select '{value}' in '{selector}' on {loc}"
if operation == "press":
return f"Press '{key}' on {loc}"
return super().confirmation_description(operation=operation, url=url)
# ── Session management ────────────────────────────────────────────────────
async def _get_page(self, session_id: str, url: str | None = None):
"""Get or create a page for this session; navigate to url if given."""
data = BrowserTool._sessions.get(session_id)
page = None
if data:
context, page = data
if page.is_closed():
try:
await context.close()
except Exception:
pass
page = None
if page is None:
browser = await self._get_browser()
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
)
context = await browser.new_context(user_agent=_USER_AGENT)
page = await context.new_page()
try:
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
BrowserTool._sessions[session_id] = (context, page)
if wait_for:
try:
await page.wait_for_selector(wait_for, timeout=10_000)
except Exception:
pass # continue even if selector doesn't appear
if url:
await page.goto(url, timeout=_TIMEOUT_MS, wait_until="domcontentloaded")
if operation == "screenshot":
data = await page.screenshot(type="png")
import base64
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
# fetch_page
if extract_selector:
elements = await page.query_selector_all(extract_selector)
text_parts = [await el.inner_text() for el in elements]
text = "\n".join(text_parts)
else:
text = await page.inner_text("body")
text = text[:_MAX_TEXT_CHARS]
text = await sanitize_external_content(text, source="browser")
return ToolResult(success=True, data={"url": url, "text": text, "length": len(text)})
finally:
await context.close()
except Exception as e:
return ToolResult(success=False, error=f"Browser error: {e}")
return page
async def _get_browser(self):
async with BrowserTool._lock:
@@ -129,9 +199,9 @@ class BrowserTool(BaseTool):
logger.info("[browser] Chromium launched")
return BrowserTool._browser
# ── Domain access check ───────────────────────────────────────────────────
async def _check_tier(self, url: str) -> ToolResult | None:
"""Returns ToolResult(success=False) if denied, None if allowed."""
from urllib.parse import urlparse
if await assert_domain_tier1(url):
return None
task_id = current_task_id.get()
@@ -139,11 +209,123 @@ class BrowserTool(BaseTool):
return None
if web_tier2_enabled.get():
return None
from urllib.parse import urlparse
parsed = urlparse(url)
return ToolResult(
success=False,
error=(
f"Domain '{parsed.hostname}' is not in the Tier 1 whitelist. "
"Ask me to fetch a specific external page to enable Tier 2 access."
"Ask me to access a specific external page to enable Tier 2 access."
),
)
# ── Execute ───────────────────────────────────────────────────────────────
async def execute(
self,
operation: str,
url: str = "",
selector: str = "",
value: str = "",
key: str = "",
wait_for: str = "",
extract_selector: str = "",
**_,
) -> ToolResult:
# Read ops require a url
if operation in ("fetch_page", "screenshot") and not url:
return ToolResult(success=False, error=f"'url' is required for {operation}")
try:
from playwright.async_api import async_playwright # noqa: F401
except ImportError:
return ToolResult(
success=False,
error="Playwright is not installed. Run: pip install playwright && playwright install chromium",
)
# Whitelist check
target_url = url
if not target_url:
sid = current_session_id.get() or "default"
data = BrowserTool._sessions.get(sid)
if data:
try:
target_url = data[1].url
except Exception:
pass
if target_url:
denied = await self._check_tier(target_url)
if denied:
return denied
session_id = current_session_id.get() or "default"
try:
page = await self._get_page(session_id, url or None)
if wait_for:
try:
await page.wait_for_selector(wait_for, timeout=10_000)
except Exception:
pass
# ── Read operations ──────────────────────────────────────────────
if operation == "fetch_page":
if extract_selector:
elements = await page.query_selector_all(extract_selector)
text_parts = [await el.inner_text() for el in elements]
text = "\n".join(text_parts)
else:
text = await page.inner_text("body")
text = text[:_MAX_TEXT_CHARS]
text = await sanitize_external_content(text, source="browser")
return ToolResult(success=True, data={"url": page.url, "text": text, "length": len(text)})
if operation == "screenshot":
data = await page.screenshot(type="png")
import base64
return ToolResult(success=True, data={"screenshot_base64": base64.b64encode(data).decode()})
# ── Interactive operations ───────────────────────────────────────
if operation == "click":
if not selector:
return ToolResult(success=False, error="'selector' is required for click")
await page.click(selector, timeout=10_000)
try:
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
except Exception:
pass
preview = (await page.inner_text("body"))[:2000]
return ToolResult(success=True, data={"url": page.url, "page_preview": preview})
if operation == "fill":
if not selector:
return ToolResult(success=False, error="'selector' is required for fill")
await page.fill(selector, value, timeout=10_000)
return ToolResult(success=True, data={"url": page.url, "filled": value})
if operation == "select":
if not selector:
return ToolResult(success=False, error="'selector' is required for select")
await page.select_option(selector, value=value, timeout=10_000)
return ToolResult(success=True, data={"url": page.url, "selected": value})
if operation == "press":
if not key:
return ToolResult(success=False, error="'key' is required for press")
target = selector if selector else "body"
await page.press(target, key, timeout=10_000)
try:
await page.wait_for_load_state("domcontentloaded", timeout=5_000)
except Exception:
pass
preview = (await page.inner_text("body"))[:2000]
return ToolResult(success=True, data={"url": page.url, "page_preview": preview})
return ToolResult(success=False, error=f"Unknown operation: {operation}")
except Exception as e:
return ToolResult(success=False, error=f"Browser error: {e}")

View File

@@ -1,5 +1,21 @@
"""
users.py — User CRUD operations (async, PostgreSQL).
Design decisions:
- User IDs are TEXT (UUID stored as string), NOT the PostgreSQL UUID type.
Reason: PostgreSQL UUID type causes FK mismatch errors with asyncpg when columns
in other tables store user_id as TEXT. Keeping everything TEXT avoids implicit
type casting and makes joins reliable.
- New users get personality files seeded from the global SOUL.md / USER.md.
Admins get the real USER.md verbatim. Non-admins get a blank template so they
can fill in their own context without seeing the admin's personal info.
- User email addresses are automatically added to email_whitelist so the agent
can reply to the user without manual whitelist configuration.
- User folders (provisioned in {users_base_folder}/{username}/) allow non-admin
users to have a private filesystem space without configuring the global whitelist.
"""
from __future__ import annotations
@@ -137,6 +153,12 @@ async def _sync_email_whitelist(pool, old_email: str | None, new_email: str | No
async def create_user(username: str, password: str, role: str = "user", email: str = "") -> dict:
"""
Create a new user. Automatically:
- Seeds per-user personality from global SOUL.md / USER.md
- Adds user's email to email_whitelist (so the agent can reply)
- Provisions a user folder if system:users_base_folder is configured
"""
user_id = str(uuid.uuid4())
now = _now()
pw_hash = hash_password(password)
@@ -222,6 +244,11 @@ async def update_user(user_id: str, **fields) -> bool:
async def delete_user(user_id: str) -> bool:
"""
Delete a user. Nullifies FK references first to avoid constraint violations.
Agents, conversations, and audit entries are preserved (owner set to NULL)
rather than cascade-deleted — important for audit trail integrity.
"""
pool = await get_pool()
# Fetch email before delete for whitelist cleanup
old_row = await pool.fetchrow("SELECT email FROM users WHERE id = $1", user_id)

View File

@@ -380,6 +380,58 @@ async def get_queue_status(request: Request):
return agent_runner.queue_status
# ── Browser trusted domains ───────────────────────────────────────────────────
class BrowserDomainIn(BaseModel):
domain: str
note: Optional[str] = None
@router.get("/my/browser-trusted")
async def list_browser_trusted(request: Request):
from ..database import get_pool as _gp
user = _require_auth(request)
pool = await _gp()
rows = await pool.fetch(
"SELECT id, domain, note, created_at FROM browser_approved_domains "
"WHERE owner_user_id = $1 ORDER BY domain",
user.id,
)
return [dict(r) for r in rows]
@router.post("/my/browser-trusted")
async def add_browser_trusted(request: Request, body: BrowserDomainIn):
from ..database import get_pool as _gp
user = _require_auth(request)
domain = body.domain.lower().strip().lstrip("*.")
if not domain:
raise HTTPException(status_code=400, detail="Invalid domain")
pool = await _gp()
try:
await pool.execute(
"INSERT INTO browser_approved_domains (owner_user_id, domain, note) "
"VALUES ($1, $2, $3) ON CONFLICT (owner_user_id, domain) DO NOTHING",
user.id, domain, body.note or None,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {"ok": True, "domain": domain}
@router.delete("/my/browser-trusted/{domain:path}")
async def remove_browser_trusted(request: Request, domain: str):
from ..database import get_pool as _gp
user = _require_auth(request)
domain = domain.lower().strip().lstrip("*.")
pool = await _gp()
await pool.execute(
"DELETE FROM browser_approved_domains WHERE owner_user_id = $1 AND domain = $2",
user.id, domain,
)
return {"ok": True}
@router.get("/settings/provider")
async def get_default_provider(request: Request):
_require_admin(request)
@@ -814,6 +866,179 @@ async def stop_run(request: Request, run_id: str):
return {"ok": True, "run_id": run_id}
# ── Usage / cost overview ─────────────────────────────────────────────────────
@router.get("/usage")
async def get_usage(
request: Request,
since: str = "7d",
start: str = "",
end: str = "",
):
"""Aggregate token and cost usage by agent for the usage overview page."""
user = _require_auth(request)
if not user.is_admin:
from ..database import user_settings_store as _uss
if await _uss.get(user.id, "use_admin_keys"):
raise HTTPException(status_code=403, detail="Not available on admin API keys")
has_own = (
await _uss.get(user.id, "anthropic_api_key") or
await _uss.get(user.id, "openrouter_api_key") or
await _uss.get(user.id, "openai_api_key")
)
if not has_own:
raise HTTPException(status_code=403, detail="Not available on admin API keys")
from ..database import get_pool as _gp
pool = await _gp()
now = datetime.now(timezone.utc)
since_dt: str | None = None
if start:
since_dt = start
elif since == "today":
since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif since == "7d":
since_dt = (now - timedelta(days=7)).isoformat()
elif since == "30d":
since_dt = (now - timedelta(days=30)).isoformat()
# since == "all" → no date filter
# Build WHERE clauses
clauses: list[str] = ["ar.status IN ('success', 'error', 'stopped')"]
params: list = []
n = 1
# Exclude email handler agents
handler_ids_rows = await pool.fetch(
"SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
)
handler_ids = [str(r["agent_id"]) for r in handler_ids_rows]
if handler_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(handler_ids)))
clauses.append(f"ar.agent_id NOT IN ({placeholders})")
params.extend(handler_ids)
n += len(handler_ids)
if since_dt:
clauses.append(f"ar.started_at >= ${n}"); params.append(since_dt); n += 1
if end:
clauses.append(f"ar.started_at <= ${n}"); params.append(end); n += 1
# Non-admin sees only their own agents
if not user.is_admin:
own_agents = await agent_store.list_agents(owner_user_id=user.id)
own_ids = [a["id"] for a in own_agents]
if own_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(own_ids)))
clauses.append(f"ar.agent_id IN ({placeholders})")
params.extend(own_ids)
n += len(own_ids)
else:
# No agents → empty result
return {"summary": {"runs": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": None}, "by_agent": []}
where = "WHERE " + " AND ".join(clauses)
rows = await pool.fetch(
f"""
SELECT
ar.agent_id,
a.name AS agent_name,
a.model AS agent_model,
COUNT(*) AS runs,
SUM(ar.input_tokens) AS input_tokens,
SUM(ar.output_tokens) AS output_tokens,
SUM(ar.cost_usd) AS cost_usd
FROM agent_runs ar
LEFT JOIN agents a ON a.id = ar.agent_id
{where}
GROUP BY ar.agent_id, a.name, a.model
ORDER BY cost_usd DESC NULLS LAST, (SUM(ar.input_tokens) + SUM(ar.output_tokens)) DESC
""",
*params,
)
by_agent = []
total_input = 0
total_output = 0
total_cost: float | None = None
total_runs = 0
for row in rows:
inp = int(row["input_tokens"] or 0)
out = int(row["output_tokens"] or 0)
cost = float(row["cost_usd"]) if row["cost_usd"] is not None else None
runs = int(row["runs"])
total_input += inp
total_output += out
total_runs += runs
if cost is not None:
total_cost = (total_cost or 0.0) + cost
by_agent.append({
"agent_id": str(row["agent_id"]),
"agent_name": row["agent_name"] or "",
"model": row["agent_model"] or "",
"runs": runs,
"input_tokens": inp,
"output_tokens": out,
"cost_usd": cost,
})
# ── Chat session usage ────────────────────────────────────────────────────
chat_clauses: list[str] = ["task_id IS NULL"] # chat only, not agent/task runs
chat_params: list = []
cn = 1
if since_dt:
chat_clauses.append(f"started_at >= ${cn}"); chat_params.append(since_dt); cn += 1
if end:
chat_clauses.append(f"started_at <= ${cn}"); chat_params.append(end); cn += 1
if not user.is_admin:
chat_clauses.append(f"user_id = ${cn}"); chat_params.append(user.id); cn += 1
chat_where = "WHERE " + " AND ".join(chat_clauses)
chat_row = await pool.fetchrow(
f"""
SELECT
COUNT(*) AS sessions,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(cost_usd) AS cost_usd
FROM conversations
{chat_where}
""",
*chat_params,
)
chat_inp = int(chat_row["input_tokens"] or 0)
chat_out = int(chat_row["output_tokens"] or 0)
chat_cost = float(chat_row["cost_usd"]) if chat_row["cost_usd"] is not None else None
chat_sessions = int(chat_row["sessions"] or 0)
total_input += chat_inp
total_output += chat_out
total_runs += chat_sessions
if chat_cost is not None:
total_cost = (total_cost or 0.0) + chat_cost
return {
"summary": {
"runs": total_runs,
"input_tokens": total_input,
"output_tokens": total_output,
"cost_usd": total_cost,
},
"by_agent": by_agent,
"chat": {
"sessions": chat_sessions,
"input_tokens": chat_inp,
"output_tokens": chat_out,
"cost_usd": chat_cost,
},
}
# ── Inbox triggers ────────────────────────────────────────────────────────────
class InboxTriggerIn(BaseModel):

View File

@@ -215,6 +215,7 @@ function _initPage(url) {
if (path === "/" || path === "") { initChat(); return; }
if (path === "/agents") { initAgents(); return; }
if (path.startsWith("/agents/")) { initAgentDetail(); return; }
if (path === "/usage") { initUsage(); return; }
if (path === "/audit") { initAudit(); return; }
if (path === "/monitors") { initMonitors(); return; }
if (path === "/models") { initModels(); return; }
@@ -1185,6 +1186,122 @@ function respondConfirm(approved) {
document.getElementById("confirm-modal").classList.add("hidden");
}
/* ══════════════════════════════════════════════════════════════════════════
USAGE PAGE
══════════════════════════════════════════════════════════════════════════ */
let _usageRange = "7d";
function setUsageRange(range) {
_usageRange = range;
["today","7d","30d","all"].forEach(r => {
const btn = document.getElementById("usage-range-" + r);
if (!btn) return;
const active = r === range;
btn.style.background = active ? "var(--accent)" : "";
btn.style.color = active ? "#fff" : "";
btn.style.borderColor = active ? "var(--accent)" : "";
});
loadUsage();
}
function _fmtTokens(n) {
if (n === null || n === undefined) return "—";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
return String(n);
}
function _fmtCost(c) {
if (c === null || c === undefined) return "—";
if (c < 0.001) return "< $0.001";
return "$" + c.toFixed(4);
}
async function loadUsage() {
const tbody = document.getElementById("usage-tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>';
const resp = await fetch("/api/usage?since=" + encodeURIComponent(_usageRange));
if (!resp.ok) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--danger)">Failed to load usage data</td></tr>';
return;
}
const data = await resp.json();
const s = data.summary;
// Update summary cards
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
set("u-total-runs", s.runs.toLocaleString());
set("u-input-tokens", _fmtTokens(s.input_tokens));
set("u-output-tokens", _fmtTokens(s.output_tokens));
set("u-total-tokens", _fmtTokens(s.input_tokens + s.output_tokens));
set("u-cost", _fmtCost(s.cost_usd));
// Find max tokens for bar scaling
const agents = data.by_agent;
const maxTokens = agents.reduce((m, a) => Math.max(m, a.input_tokens + a.output_tokens), 1);
let anyNullCost = false;
if (!agents.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">No runs found for this period</td></tr>';
return;
}
tbody.innerHTML = agents.map(a => {
const total = a.input_tokens + a.output_tokens;
const pct = maxTokens > 0 ? Math.round((total / maxTokens) * 100) : 0;
const costStr = _fmtCost(a.cost_usd);
if (a.cost_usd === null) anyNullCost = true;
const modelShort = a.model ? a.model.replace(/^(anthropic|openrouter|openai):/, "") : "—";
const modelTitle = a.model || "";
return `<tr>
<td><a href="/agents/${a.agent_id}" style="color:var(--accent);text-decoration:none">${esc(a.agent_name)}</a></td>
<td style="font-size:12px;color:var(--text-dim)" title="${esc(modelTitle)}">${esc(modelShort)}</td>
<td style="text-align:right">${a.runs.toLocaleString()}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(a.input_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(a.output_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(total)}</td>
<td style="text-align:right;font-size:13px;font-weight:${a.cost_usd !== null ? "600" : "400"}">${costStr}</td>
<td>
<div style="background:var(--bg2);border-radius:3px;height:6px;overflow:hidden">
<div style="background:var(--accent);height:100%;width:${pct}%;transition:width 0.3s"></div>
</div>
</td>
</tr>`;
}).join("");
// Chat section
const chatTbody = document.getElementById("usage-chat-tbody");
if (chatTbody) {
const c = data.chat;
if (!c || c.sessions === 0) {
chatTbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">No chat sessions in this period</td></tr>';
} else {
const chatTotal = c.input_tokens + c.output_tokens;
if (c.cost_usd === null) anyNullCost = true;
chatTbody.innerHTML = `<tr>
<td>Interactive chat</td>
<td style="text-align:right">${c.sessions.toLocaleString()}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(c.input_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(c.output_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(chatTotal)}</td>
<td style="text-align:right;font-size:13px;font-weight:${c.cost_usd !== null ? "600" : "400"}">${_fmtCost(c.cost_usd)}</td>
</tr>`;
}
}
const note = document.getElementById("usage-no-cost-note");
if (note) note.style.display = anyNullCost ? "" : "none";
}
function initUsage() {
if (!document.getElementById("usage-container")) return;
loadUsage();
}
/* ══════════════════════════════════════════════════════════════════════════
AUDIT PAGE
══════════════════════════════════════════════════════════════════════════ */
@@ -1460,6 +1577,7 @@ function switchUserTab(name) {
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
if (name === "browser") { loadMyBrowserTrusted(); }
if (name === "pushover") { loadMyPushover(); }
}
@@ -2426,6 +2544,11 @@ function initUserSettings() {
loadMyProviderKeys();
loadMyPersonality();
loadMyMcpServers();
document.getElementById("my-browser-trusted-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addBrowserTrusted("my-bt-domain", "my-bt-note");
});
}
function initSettings() {
@@ -2452,6 +2575,7 @@ function initSettings() {
loadEmailWhitelist();
loadWebWhitelist();
loadFilesystemWhitelist();
loadBrowserTrusted();
reloadCredList();
loadInboxStatus();
loadInboxTriggers();
@@ -2530,6 +2654,11 @@ function initSettings() {
await addFilesystemPath();
});
document.getElementById("browser-trusted-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addBrowserTrusted("bt-domain", "bt-note");
});
document.getElementById("brain-capture-form")?.addEventListener("submit", async e => {
e.preventDefault();
const content = document.getElementById("brain-capture-text").value.trim();
@@ -3029,6 +3158,75 @@ const _DEDICATED_CRED_KEYS = new Set([
// These are managed by Inbox / Telegram / Brain / Security / Branding tabs
]);
/* ── Browser trusted domains ─────────────────────────────────────────────── */
function _renderBrowserTrustedTable(rows, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No trusted domains</td></tr>";
return;
}
for (const row of rows) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${esc(row.domain)}</code></td>
<td style="color:var(--text-dim)">${esc(row.note || "")}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="removeBrowserTrusted('${esc(row.domain)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function loadBrowserTrusted() {
const r = await fetch("/api/my/browser-trusted");
if (!r.ok) return;
_renderBrowserTrustedTable(await r.json(), "browser-trusted-list");
}
async function loadMyBrowserTrusted() {
const r = await fetch("/api/my/browser-trusted");
if (!r.ok) return;
_renderBrowserTrustedTable(await r.json(), "my-browser-trusted-list");
}
async function addBrowserTrusted(domainInputId, noteInputId) {
const domain = document.getElementById(domainInputId)?.value.trim();
const note = document.getElementById(noteInputId)?.value.trim();
if (!domain) return;
const r = await fetch("/api/my/browser-trusted", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, note }),
});
if (r.ok) {
document.getElementById(domainInputId).value = "";
document.getElementById(noteInputId).value = "";
showFlash("Added ✓");
loadBrowserTrusted();
loadMyBrowserTrusted();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error adding domain");
}
}
async function removeBrowserTrusted(domain) {
if (!confirm(`Remove "${domain}" from trusted domains?`)) return;
const r = await fetch(`/api/my/browser-trusted/${encodeURIComponent(domain)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Removed ✓");
loadBrowserTrusted();
loadMyBrowserTrusted();
} else {
showFlash("Error removing domain");
}
}
/* ── end browser trusted domains ─────────────────────────────────────────── */
async function reloadCredList() {
const r = await fetch("/api/credentials");
if (!r.ok) return;

View File

@@ -609,3 +609,8 @@ tr:hover td { background: var(--bg2); }
.pm-btn:last-child { border-right: none; }
.pm-btn.active { background: var(--accent); color: #fff; }
.pm-btn:hover:not(.active) { background: var(--bg3); color: var(--text); }
/* ── Usage page stat cards ────────────────────────────────────────────────── */
.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); }

View File

@@ -16,7 +16,7 @@
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
<div class="sidebar-logo-text">
<div class="sidebar-logo-name">{{ brand_name }}</div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.1</span></div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.2</span></div>
</div>
</div>
@@ -44,6 +44,12 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M20.188 10.934a8.002 8.002 0 0 1 0 2.132M3.812 13.066a8.002 8.002 0 0 1 0-2.132M15.536 17.121a8 8 0 0 1-1.506.643M9.97 6.236A8 8 0 0 1 11.5 6M17.657 7.757a8 8 0 0 1 .879 1.506M6.343 16.243a8 8 0 0 1-.879-1.506M17.121 15.536a8 8 0 0 1-1.506.879M8.464 6.879A8 8 0 0 1 9.97 6.236"/></svg>
Monitors
</a>
{% if can_view_usage %}
<a class="nav-item" data-page="/usage" href="/usage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Usage
</a>
{% endif %}
<a class="nav-item" data-page="/audit" href="/audit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Audit Log

View File

@@ -43,6 +43,7 @@
<button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button>
<button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button>
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
<button type="button" class="tab-btn" id="ustab-browser" onclick="switchUserTab('browser')">Browser</button>
<button type="button" class="tab-btn" id="ustab-mfa" onclick="switchUserTab('mfa')">Profile</button>
</div>
{% endif %}
@@ -417,6 +418,39 @@
</form>
</section>
<div style="border-top:1px solid var(--border);margin-bottom:36px"></div>
<!-- Browser trusted domains -->
<section>
<h2 class="settings-section-title">Browser Trusted Domains</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
Domains where browser <em>interaction</em> operations (click, fill, select, press) run without
asking for confirmation. Subdomains are automatically included.
Each user manages their own list.
</p>
<div class="table-wrap" style="margin-bottom:16px">
<table>
<thead>
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
</thead>
<tbody id="browser-trusted-list">
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
<form id="browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
<label>Add domain</label>
<input type="text" id="bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
</div>
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
<input type="text" id="bt-note" class="form-input" placeholder="e.g. Work intranet">
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
</form>
</section>
</div><!-- /spane-whitelists -->
<!-- ══════════════════════════════════════════════════════════
@@ -1696,6 +1730,37 @@
<!-- ══════════════════════════════════════════════════════════
USER SETTINGS: Profile
═══════════════════════════════════════════════════════════ -->
<div id="uspane-browser" style="display:none">
<section>
<h2 class="settings-section-title">Browser Trusted Domains</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
Domains where browser <em>interaction</em> operations (click, fill, select, press) run
without asking for confirmation each time. Subdomains are automatically included.
</p>
<div class="table-wrap" style="margin-bottom:16px">
<table>
<thead>
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
</thead>
<tbody id="my-browser-trusted-list">
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
<form id="my-browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
<label>Add domain</label>
<input type="text" id="my-bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
</div>
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
<input type="text" id="my-bt-note" class="form-input" placeholder="e.g. Work intranet">
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
</form>
</section>
</div><!-- /uspane-browser -->
<div id="uspane-mfa" style="display:none">
<!-- Theme picker -->

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}{{ agent_name }} — Usage{% endblock %}
{% block content %}
<div class="page" id="usage-container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
<h1>Usage</h1>
<!-- Time range filter -->
<div style="display:flex;gap:6px">
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
<button class="btn" id="usage-range-all" type="button" onclick="setUsageRange('all')">All time</button>
</div>
</div>
<!-- Summary cards -->
<div id="usage-cards" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:32px">
<div class="stat-card">
<div class="stat-label">Runs</div>
<div class="stat-value" id="u-total-runs"></div>
</div>
<div class="stat-card">
<div class="stat-label">Input tokens</div>
<div class="stat-value" id="u-input-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Output tokens</div>
<div class="stat-value" id="u-output-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Total tokens</div>
<div class="stat-value" id="u-total-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Est. cost</div>
<div class="stat-value" id="u-cost"></div>
</div>
</div>
<!-- Per-agent breakdown -->
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:12px">
By agent
</h2>
<div class="table-wrap">
<table id="usage-table">
<thead>
<tr>
<th style="text-align:left">Agent</th>
<th style="text-align:left">Model</th>
<th style="text-align:right">Runs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total tokens</th>
<th style="text-align:right">Est. cost</th>
<th style="min-width:120px"></th>
</tr>
</thead>
<tbody id="usage-tbody">
<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Chat sessions summary -->
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin:32px 0 12px">
Chat sessions
</h2>
<div id="usage-chat-section">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="text-align:left">Source</th>
<th style="text-align:right">Sessions</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total tokens</th>
<th style="text-align:right">Est. cost</th>
</tr>
</thead>
<tbody id="usage-chat-tbody">
<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<p id="usage-no-cost-note" style="font-size:12px;color:var(--text-dim);margin-top:12px;display:none">
Cost estimates only available for runs recorded after the usage tracking feature was enabled.
Runs on free or unknown models show no cost.
</p>
</div>
{% endblock %}