Changes, bugfixes and updates to the matrix. Added 1.2v batteries

This commit is contained in:
2026-04-13 12:45:50 +02:00
parent beef23d48e
commit 7b140d4079
10 changed files with 179 additions and 62 deletions

View File

@@ -2,32 +2,15 @@
# Copy this file to .env and fill in your values.
# Never commit .env to version control.
# AI provider selection — keys are configured via Settings → Credentials (stored encrypted in DB)
# Set DEFAULT_PROVIDER to the provider you'll use as the default
DEFAULT_PROVIDER=openrouter # anthropic | openrouter | openai
# Override the model (leave empty to use the provider's default)
# DEFAULT_MODEL=claude-sonnet-4-6
# Available models shown in the chat model selector (comma-separated)
# AVAILABLE_MODELS=claude-sonnet-4-6,claude-opus-4-6,claude-haiku-4-5-20251001
# Default model pre-selected in chat UI (defaults to first in AVAILABLE_MODELS)
# DEFAULT_CHAT_MODEL=claude-sonnet-4-6
# Master password for the encrypted credential store (required)
# Choose a strong passphrase — all credentials are encrypted with this.
DB_MASTER_PASSWORD=change-me-to-a-strong-passphrase
# Server
# Server port
PORT=8080
# Agent limits
MAX_TOOL_CALLS=20
MAX_AUTONOMOUS_RUNS_PER_HOUR=10
# Timezone for display (stored internally as UTC)
TIMEZONE=Europe/Oslo
# Default model pre-selected in chat UI (leave empty to use the first available model)
# DEFAULT_CHAT_MODEL=claude-sonnet-4-6
# Main app database — PostgreSQL (shared postgres service)
AIDE_DB_URL=postgresql://aide:change-me@postgres:5432/aide

View File

@@ -97,16 +97,12 @@ def _load() -> Settings:
max_runs = int(_optional("MAX_AUTONOMOUS_RUNS_PER_HOUR", "10"))
timezone = _optional("TIMEZONE", "Europe/Oslo")
def _normalize_model(m: str) -> str:
"""Prepend default_provider if model has no provider prefix."""
parts = m.split(":", 1)
if len(parts) == 2 and parts[0] in _known_providers:
return m
return f"{default_provider}:{m}"
available_models: list[str] = [] # unused; kept for backward compat
available_models: list[str] = []
default_chat_model_raw = _optional("DEFAULT_CHAT_MODEL", "")
default_chat_model = _normalize_model(default_chat_model_raw) if default_chat_model_raw else ""
if default_chat_model_raw and ":" not in default_chat_model_raw:
default_chat_model = f"{default_provider}:{default_chat_model_raw}"
else:
default_chat_model = default_chat_model_raw
aide_db_url = _require("AIDE_DB_URL")

View File

@@ -24,10 +24,19 @@ async def _resolve_key(provider: str, user_id: str | None = None) -> str:
return await credential_store.get(f"system:{provider}_api_key") or ""
async def _get_default_provider() -> str:
"""Return the default provider name: credential_store → settings → 'anthropic'."""
from ..database import credential_store
from ..config import settings
val = await credential_store.get("system:default_provider")
if val and val in {"anthropic", "openrouter", "openai"}:
return val
return settings.default_provider
async def get_provider(user_id: str | None = None) -> AIProvider:
"""Return the default provider, with keys resolved for the given user."""
from ..config import settings
return await get_provider_for_name(settings.default_provider, user_id=user_id)
return await get_provider_for_name(await _get_default_provider(), user_id=user_id)
async def get_provider_for_name(name: str, user_id: str | None = None) -> AIProvider:
@@ -64,15 +73,13 @@ async def get_provider_for_model(model_str: str, user_id: str | None = None) ->
"openrouter:openai/gpt-4o" → (OpenRouterProvider, "openai/gpt-4o")
"claude-sonnet-4-6" → (default_provider, "claude-sonnet-4-6")
"""
from ..config import settings
_known = {"anthropic", "openrouter", "openai"}
if ":" in model_str:
prefix, bare = model_str.split(":", 1)
if prefix in _known:
return await get_provider_for_name(prefix, user_id=user_id), bare
# No recognised prefix — use default provider, full string as model ID
return await get_provider_for_name(settings.default_provider, user_id=user_id), model_str
return await get_provider_for_name(await _get_default_provider(), user_id=user_id), model_str
async def get_available_providers(user_id: str | None = None) -> list[str]:

View File

@@ -380,6 +380,33 @@ async def get_queue_status(request: Request):
return agent_runner.queue_status
@router.get("/settings/provider")
async def get_default_provider(request: Request):
_require_admin(request)
from ..providers.registry import get_available_providers
from ..config import settings as _settings
val = await credential_store.get("system:default_provider")
available = await get_available_providers()
current = val or _settings.default_provider
# If the saved provider no longer has a key, fall back to the first available
if current not in available and available:
current = available[0]
return {"default_provider": current, "available_providers": available}
class ProviderIn(BaseModel):
default_provider: str
@router.post("/settings/provider")
async def set_default_provider(request: Request, body: ProviderIn):
_require_admin(request)
if body.default_provider not in {"anthropic", "openrouter", "openai"}:
raise HTTPException(status_code=400, detail="Invalid provider. Use: anthropic, openrouter, openai")
await credential_store.set("system:default_provider", body.default_provider, "Default AI provider")
return {"default_provider": body.default_provider}
@router.get("/settings/default-models")
async def get_default_models(request: Request):
_require_admin(request)

View File

@@ -1202,8 +1202,6 @@ function initAudit() {
const el = document.getElementById(id);
if (el) el.value = "";
});
const cb = document.getElementById("filter-confirmed");
if (cb) cb.checked = false;
const banner = document.getElementById("audit-task-filter-banner");
if (banner) {
banner.textContent = `Showing entries for run ${_auditTaskId.slice(0, 8)}`;
@@ -1218,7 +1216,6 @@ function initAudit() {
const el = document.getElementById(id);
if (el) el.value = "";
});
document.getElementById("filter-confirmed").checked = false;
_auditTaskId = "";
const banner = document.getElementById("audit-task-filter-banner");
if (banner) banner.style.display = "none";
@@ -1235,8 +1232,6 @@ async function loadAudit(page) {
const end = document.getElementById("filter-end")?.value;
const tool = document.getElementById("filter-tool")?.value;
const session = document.getElementById("filter-session")?.value;
const confirmed = document.getElementById("filter-confirmed")?.checked;
const startISO = parseEuDate(start);
const endISO = parseEuDate(end, true);
if (startISO) params.set("start", startISO);
@@ -1244,7 +1239,6 @@ async function loadAudit(page) {
if (tool) params.set("tool_name", tool);
if (session) params.set("session_id", session);
if (_auditTaskId) params.set("task_id", _auditTaskId);
if (confirmed) params.set("confirmed_only", "true");
params.set("page", auditPage);
params.set("per_page", 50);
@@ -1265,7 +1259,6 @@ function renderAuditTable(data) {
<td><code>${esc(e.tool_name)}</code></td>
<td class="args-cell">${esc(JSON.stringify(e.arguments).slice(0, 80))}</td>
<td>${esc((e.result_summary || "").slice(0, 80))}</td>
<td>${e.confirmed ? '<span class="badge badge-green">✓</span>' : '<span class="badge badge-red">✗</span>'}</td>
<td><code>${esc((e.session_id || "").slice(0,8))}</code></td>
`;
tr.addEventListener("click", () => openAuditDetail(e));
@@ -1292,9 +1285,6 @@ function openAuditDetail(e) {
document.getElementById("audit-detail-ts").textContent = formatDate(e.timestamp);
document.getElementById("audit-detail-session").textContent = e.session_id || "—";
document.getElementById("audit-detail-task").textContent = e.task_id || "—";
document.getElementById("audit-detail-confirmed").innerHTML = e.confirmed
? '<span class="badge badge-green">Yes</span>'
: '<span class="badge badge-red">No</span>';
let _args = e.arguments;
if (typeof _args === "string") { try { _args = JSON.parse(_args); } catch {} }
document.getElementById("audit-detail-args").textContent =
@@ -1411,6 +1401,7 @@ async function saveProviderKey(provider) {
document.getElementById(`provider-key-${provider}-input`).value = "";
showFlash(`${provider} key saved.`);
loadProviderKeys();
loadDefaultProvider();
} else {
const d = await r.json().catch(() => ({}));
showFlash(d.detail || "Error saving key.");
@@ -1420,7 +1411,7 @@ async function saveProviderKey(provider) {
async function clearProviderKey(provider) {
if (!confirm(`Clear the ${provider} API key? This may break agent runs that rely on it.`)) return;
const r = await fetch(`/api/settings/provider-keys/${provider}`, { method: "DELETE" });
if (r.ok) { showFlash(`${provider} key cleared.`); loadProviderKeys(); }
if (r.ok) { showFlash(`${provider} key cleared.`); loadProviderKeys(); loadDefaultProvider(); }
else showFlash("Error clearing key.");
}
@@ -2448,6 +2439,7 @@ function initSettings() {
loadApiKeyStatus();
loadProviderKeys();
loadUsersBaseFolder();
loadDefaultProvider();
loadLimits();
loadDefaultModels();
loadProxyTrust();
@@ -2785,13 +2777,56 @@ async function loadLimits() {
if (mcrEl) mcrEl.value = data.max_concurrent_runs;
if (defaultsEl) {
defaultsEl.textContent =
`.env defaults: max_tool_calls=${data.defaults.max_tool_calls}, ` +
`max_autonomous_runs_per_hour=${data.defaults.max_autonomous_runs_per_hour}, ` +
`max_concurrent_runs=${data.defaults.max_concurrent_runs}`;
`Built-in defaults: tool_calls=${data.defaults.max_tool_calls}, ` +
`runs/hour=${data.defaults.max_autonomous_runs_per_hour}, ` +
`concurrent=${data.defaults.max_concurrent_runs}`;
}
} catch { /* ignore */ }
}
async function loadDefaultProvider() {
const sel = document.getElementById("default-provider-select");
if (!sel) return;
try {
const r = await fetch("/api/settings/provider");
if (!r.ok) return;
const data = await r.json();
const labels = { anthropic: "Anthropic", openrouter: "OpenRouter", openai: "OpenAI" };
const available = data.available_providers || [];
sel.innerHTML = "";
if (available.length === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "No providers configured";
sel.appendChild(opt);
sel.disabled = true;
sel.closest("section")?.querySelector(".btn")?.setAttribute("disabled", "");
return;
}
for (const p of available) {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = labels[p] || p;
sel.appendChild(opt);
}
sel.disabled = false;
sel.closest("section")?.querySelector(".btn")?.removeAttribute("disabled");
sel.value = data.default_provider || available[0];
} catch { /* ignore */ }
}
async function saveDefaultProvider() {
const sel = document.getElementById("default-provider-select");
if (!sel) return;
const r = await fetch("/api/settings/provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_provider: sel.value }),
});
if (r.ok) showFlash("Default provider saved ✓");
else showFlash("Error saving default provider");
}
async function loadDefaultModels() {
const defSel = document.getElementById("dm-default");
const freeSel = document.getElementById("dm-free");
@@ -5725,3 +5760,15 @@ document.addEventListener("DOMContentLoaded", () => {
initNav();
_initPage(location.pathname);
});
// Close any open modal on Escape
document.addEventListener("keydown", e => {
if (e.key !== "Escape") return;
const overlay = [...document.querySelectorAll(".modal-overlay")]
.find(el => el.style.display !== "none" && !el.classList.contains("hidden"));
if (!overlay) return;
// Find the ✕ close button, or any button whose onclick closes the modal
const closeBtn = [...overlay.querySelectorAll("button")]
.find(b => b.textContent.trim() === "✕" || /^close/i.test((b.getAttribute("onclick") || "").trim()));
closeBtn?.click();
});

View File

@@ -30,11 +30,6 @@
<label>Session ID</label>
<input type="text" id="filter-session" class="form-input" placeholder="prefix…">
</div>
<div class="form-group" style="margin:0;display:flex;align-items:flex-end;gap:8px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="filter-confirmed"> Confirmed only
</label>
</div>
<div style="display:flex;gap:8px;align-items:flex-end">
<button class="btn btn-primary" id="filter-btn">Filter</button>
<button class="btn btn-ghost" id="filter-reset">Reset</button>
@@ -55,12 +50,11 @@
<th>Tool</th>
<th>Arguments</th>
<th>Result</th>
<th>Confirmed</th>
<th>Session</th>
</tr>
</thead>
<tbody>
<tr><td colspan="6" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
@@ -72,15 +66,14 @@
<!-- Audit entry detail modal -->
<div class="modal-overlay" id="audit-detail-modal" style="display:none" onclick="if(event.target===this)closeAuditDetail()">
<div class="modal" style="max-width:680px;width:100%">
<div class="modal-header">
<h3 id="audit-detail-tool" style="font-family:monospace;font-size:15px"></h3>
<div class="modal-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<h3 id="audit-detail-tool" style="font-family:monospace;font-size:15px;margin:0"></h3>
<button class="btn btn-ghost btn-small" onclick="closeAuditDetail()"></button>
</div>
<div class="modal-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px 24px;margin-bottom:16px;font-size:13px">
<div><span style="color:var(--text-dim)">Timestamp</span><br><span id="audit-detail-ts"></span></div>
<div><span style="color:var(--text-dim)">Session</span><br><code id="audit-detail-session"></code></div>
<div><span style="color:var(--text-dim)">Confirmed</span><br><span id="audit-detail-confirmed"></span></div>
<div><span style="color:var(--text-dim)">Task ID</span><br><code id="audit-detail-task" style="font-size:11px;word-break:break-all"></code></div>
</div>
<div style="margin-bottom:12px">

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</span></div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.1</span></div>
</div>
</div>

View File

@@ -34,6 +34,12 @@
<li><a href="#agents-subagents">Sub-agents</a></li>
</ul>
</li>
<li><a href="#monitors">Monitors</a>
<ul>
<li><a href="#monitors-pages">Page Watchers</a></li>
<li><a href="#monitors-rss">RSS Feeds</a></li>
</ul>
</li>
<li><a href="#mcp">MCP Servers</a></li>
<li>
<a href="#settings">Settings</a>
@@ -298,6 +304,51 @@
</p>
</section>
<!-- ── 5. Monitors ───────────────────────────────────────────────── -->
<section id="monitors" data-section>
<h1>Monitors</h1>
<p>
The <a href="/monitors">Monitors</a> page lets you watch web pages and RSS feeds for changes, then automatically dispatch an agent or send a Pushover notification when something new appears. Monitors run on a schedule in the background — no manual checking needed.
</p>
<h2 id="monitors-pages">Page Watchers</h2>
<p>
A page watcher fetches a URL on a schedule, hashes the content (or a CSS-selected portion of it), and triggers an action when the content changes.
</p>
<p>Fields when creating a page watcher:</p>
<ul>
<li><strong>Name</strong> — displayed in the monitor list</li>
<li><strong>URL</strong> — the page to watch</li>
<li><strong>Schedule</strong> — cron expression (e.g. <code>0 * * * *</code> = every hour)</li>
<li><strong>CSS Selector</strong> — optional; restricts the hash to a specific element on the page (e.g. <code>#price</code> or <code>.headline</code>). Leave blank to watch the entire page.</li>
<li><strong>Agent</strong> — agent to dispatch when a change is detected</li>
<li><strong>Notification mode</strong><code>agent</code> (dispatch the agent), <code>pushover</code> (send a push notification), or <code>both</code></li>
</ul>
<p>
The table shows <strong>Last checked</strong> and <strong>Last changed</strong> timestamps. Use the <strong>Check now</strong> button to force an immediate check outside the schedule.
</p>
<p class="help-note">
Page watchers use plain HTTP (not a real browser). For JavaScript-heavy pages where the interesting content is rendered client-side, the CSS selector approach may not work — the agent's browser tool is better suited for those.
</p>
<h2 id="monitors-rss">RSS Feeds</h2>
<p>
An RSS feed monitor fetches a feed on a schedule and triggers an action for each new item since the last run.
</p>
<p>Fields when creating an RSS monitor:</p>
<ul>
<li><strong>Name</strong> — displayed in the monitor list</li>
<li><strong>Feed URL</strong> — any RSS or Atom feed URL</li>
<li><strong>Schedule</strong> — cron expression (e.g. <code>0 */4 * * *</code> = every 4 hours)</li>
<li><strong>Agent</strong> — agent to dispatch for new items</li>
<li><strong>Max items per run</strong> — cap on how many new items trigger the agent in one run (default: 5)</li>
<li><strong>Notification mode</strong><code>agent</code>, <code>pushover</code>, or <code>both</code></li>
</ul>
<p>
Already-seen item IDs are tracked so the same item never triggers twice. The monitor sends <code>ETag</code> / <code>If-Modified-Since</code> headers to avoid downloading unchanged feeds unnecessarily. Use the <strong>Fetch now</strong> button to force an immediate run.
</p>
</section>
<!-- ── 5. MCP Servers ─────────────────────────────────────────────── -->
<section id="mcp" data-section>
<h1>MCP Servers</h1>

View File

@@ -109,7 +109,7 @@
<section>
<h2 class="settings-section-title">Runtime Limits</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
Override the defaults from <code>.env</code>. Changes take effect immediately — no restart needed.
Changes take effect immediately — no restart needed.
Individual agents can further override <em>Max tool calls</em> on their own settings page.
</p>
<form id="limits-form" style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;max-width:680px">
@@ -250,6 +250,19 @@
</div>
</section>
<!-- Default Provider -->
<section style="margin-bottom:32px">
<h2 class="settings-section-title">Default Provider</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:16px">
The AI provider used when no provider prefix is specified in a model string.
Changes take effect immediately — no restart needed.
</p>
<div style="display:flex;gap:8px;align-items:center;max-width:400px">
<select id="default-provider-select" class="form-input" style="flex:1"></select>
<button class="btn btn-primary" onclick="saveDefaultProvider()">Save</button>
</div>
</section>
<!-- Provider API Keys (admin) -->
<section style="margin-bottom:32px">
<h2 class="settings-section-title">Provider API Keys</h2>

View File

@@ -12,8 +12,8 @@ from datetime import datetime, timezone
from ..database import _rowcount, get_pool
def _utcnow() -> str:
return datetime.now(timezone.utc).isoformat()
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
async def create_endpoint(