feat: custom roles, Tailwind settings pages, pg.css fixes, doc cleanup

Model Registry:
- Add per-user custom roles (add/remove via UI); required roles chat/orchestrator/distill
  are always present and cannot be removed
- Auto-migrate legacy .env-defined roles to custom_roles on first access
- Role config panel (gear): Remove role button moved inside panel; required badge below name
- Role select: Primary + Backup slots only (was three)

Settings pages — Tailwind CSS migration (CDN, preflight: false):
- local_llm.html, settings.html, help.html, notifications.html, tools_settings.html,
  crons.html, integrations.html all migrated; pg-* color tokens; dark/light via data-theme

pg.css fixes:
- input[type=checkbox/radio]: width: auto — prevents pg.css width:100% from stretching checkboxes
- btn-submit: responsive sizing via Tailwind w-full md:w-96 on each button (no longer full-width on desktop)

Documentation:
- MASTER.md, TODO__Agents.md: remove "/ Inara" from titles; description updated to "per-user AI personas"
- HELP.md: persona-agnostic language throughout (NC Talk, Google Chat, push, schedules, HA sections);
  roles section restructured to show required vs. custom roles with examples
- notifications.html: subtitle and HA description use "your persona" not "Inara"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-15 21:03:11 -04:00
parent 070f1ce156
commit 7a27190ffe
13 changed files with 1224 additions and 953 deletions

View File

@@ -268,17 +268,24 @@ The label and context window size auto-fill from the catalog — edit them if yo
### Step 3 — Assign models to roles
Scroll to **Role Assignments** at the bottom of the page. Each role has **Primary**, **Backup 1**, and **Backup 2** slots — Primary is tried first, then backups in order. Changes save automatically.
Scroll to **Role Assignments** at the bottom of the page. Each role has **Primary** and **Backup** slots — Primary is tried first, then backup. Changes save automatically.
**Required roles** (always present, cannot be removed):
| Role | Used for |
|---|---|
| **Chat** | Regular conversation |
| **Orchestrator** | Agent mode tool loop |
| **Distill** | Memory distillation (short / mid / long) |
| **Coder** | Code-focused tasks |
| **Research** | Long-context research tasks |
Leave all slots empty to use the server default.
**Custom roles** — Click **+ Add custom role** to create your own. Each custom role gets its own model selection, tool set, and system prompt addition. Good examples:
| Example | Purpose |
|---|---|
| **Coder** | Code-focused tasks — larger context window, code-aware model |
| **Research** | Long-context research — high-token model, web tools prioritized |
Switch roles via the **Role** selector in the Context & Memory panel (⚙). Leave all slots empty to use the server default.
**Per-role tool sets:** Expand any role card to configure which tool categories the orchestrator can use when that role is active. Unchecked categories are hidden from the model entirely — reducing token overhead on every orchestrated call. Leaving all categories unchecked means all tools the user has access to are available (the default).
@@ -288,7 +295,7 @@ Leave all slots empty to use the server default.
## Nextcloud Talk Bot
Inara is registered as a bot in Nextcloud Talk.
The Cortex bot is registered in Nextcloud Talk.
- Messages sent in enabled Talk conversations are received by Cortex, processed, and replied to.
- The webhook returns `200 OK` immediately; the reply happens asynchronously.
@@ -299,12 +306,12 @@ Inara is registered as a bot in Nextcloud Talk.
## Google Chat Bot
Inara is available as a bot in Google Chat (One Sky IT Workspace).
The Cortex bot is available in Google Chat (One Sky IT Workspace).
- Send Inara a direct message in Google Chat to start a conversation.
- Send the bot a direct message in Google Chat to start a conversation.
- Each DM thread is its own session (`gc_spaces/*` prefix) — history persists across messages.
- Responses are synchronous — Google Chat displays the reply directly in the thread.
- To add Inara to a space: open the space, add a person/app, search for **Inara**.
- To add the bot to a space: open the space, click **Add people & apps**, and search for the Cortex bot.
- Sessions from Google Chat appear as `gc_*` prefixed IDs in the Sessions panel.
---
@@ -339,9 +346,9 @@ Cortex can send browser push notifications — even when the tab is closed.
- Open **☰ → Enable notifications** and accept the browser permission prompt.
- Once enabled, the button shows **Notifications on** (in accent colour).
- Click again to disable. Subscriptions are stored per-device.
- The orchestrator's `web_push` tool lets Inara send you a push proactively (e.g. when a long task completes).
- The orchestrator's `web_push` tool lets your persona send you a push proactively (e.g. when a long task completes).
**Notification channel settings:** ☰ → **Account****Notification settings →** — choose Browser Push, Email, Nextcloud Talk, or Google Chat as the channel Inara uses for scheduled reminders, cron job completions, and memory digests. Use the **Send Test Notification** button to verify your setup, or **Check Reminders Now** to trigger the reminder check immediately.
**Notification channel settings:** ☰ → **Account****Notification settings →** — choose Browser Push, Email, Nextcloud Talk, or Google Chat as the channel your persona uses for scheduled reminders, cron job completions, and memory digests. Use the **Send Test Notification** button to verify your setup, or **Check Reminders Now** to trigger the reminder check immediately.
---
@@ -389,7 +396,7 @@ Distillation builds up the memory layers from raw session logs. Runs automatical
## Scheduled Jobs
Cortex can run recurring jobs on a schedule — reminders, daily briefings, automated research, and more. Manage them by asking Inara to set them up, or go directly to **☰ → Account → Schedules**.
Cortex can run recurring jobs on a schedule — reminders, daily briefings, automated research, and more. Manage them by asking your persona to set them up, or go directly to **☰ → Account → Schedules**.
### Job Types
@@ -424,7 +431,7 @@ Schedules take effect immediately when added or edited — no restart needed. Pa
### Home Assistant Integration
HA automations can trigger Inara via webhook. Configure in **Notifications → Home Assistant → Inbound webhook**:
HA automations can trigger your persona via webhook. Configure in **Notifications → Home Assistant → Inbound webhook**:
- Set a **Webhook ID** (long random string — this is your secret URL component)
- Your endpoint: `https://cortex.dgrzone.com/webhook/ha/{username}/{webhook_id}`

View File

@@ -7,9 +7,36 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
/* ── Server-generated table + badges ── */
.cron-table {
width: 100%; border-collapse: collapse; font-size: 0.82rem;
margin-bottom: 1.5rem;
@@ -47,15 +74,14 @@
font-family: inherit;
}
.btn-cron:hover { border-color: var(--pg-accent); color: var(--pg-accent); }
.btn-cron-del:hover { border-color: var(--pg-danger, #ef4444); color: var(--pg-danger, #ef4444); }
.btn-cron-del { color: var(--pg-dimmer); }
.btn-cron-del:hover { border-color: #ef4444; color: #ef4444; }
.payload-cell {
max-width: 240px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; color: var(--pg-dimmer);
}
.persona-group { margin-bottom: 0.25rem; }
.persona-group-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--pg-dimmer); margin: 1.25rem 0 0.5rem;
@@ -67,11 +93,6 @@
border: 1px dashed var(--pg-border); border-radius: 8px;
margin-bottom: 1.5rem;
}
.add-form-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.75rem;
}
.add-form-grid .field-full { grid-column: 1 / -1; }
</style>
</head>
<body>
@@ -79,6 +100,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link active">Schedules</a>
@@ -99,11 +121,11 @@
<!-- Cron list -->
{{ cron_list_html }}
<!-- Add new cron -->
<!-- Add new schedule -->
<div class="section">
<h2>Add schedule</h2>
<form method="POST" action="/settings/crons/add">
<div class="add-form-grid">
<div class="grid grid-cols-2 gap-x-3">
<div class="field">
<label for="add_persona">Persona</label>
<select id="add_persona" name="persona">
@@ -136,13 +158,13 @@
monthly · monthly:DD · monthly:DD:HH:MM · yearly:MM:DD · yearly:MM:DD:HH:MM
</p>
</div>
<div class="field field-full">
<div class="field col-span-2">
<label for="add_payload">Payload / prompt</label>
<textarea id="add_payload" name="payload" rows="3"
placeholder="Check my open tasks and send a summary." required></textarea>
</div>
</div>
<button type="submit" class="btn-submit">Add schedule</button>
<button type="submit" class="btn-submit w-full md:w-96">Add schedule</button>
</form>
</div>
</div>

View File

@@ -8,38 +8,40 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
.page { max-width: 860px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
/* ── Header ── */
header { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--pg-border); }
header h1 { font-size: 1.5rem; font-weight: 700; color: var(--pg-accent); }
header p { font-size: 0.85rem; color: var(--pg-muted); margin-top: 0.25rem; }
/* ── Tabs ── */
.tab-bar {
display: flex; gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--pg-border);
padding-bottom: 0;
}
.tab-btn {
padding: 0.45rem 1rem;
font-size: 0.85rem; font-weight: 500;
color: var(--pg-dim);
background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; transition: color 0.15s, border-color 0.15s;
margin-bottom: -1px;
}
.tab-btn:hover { color: var(--pg-bright); }
.tab-btn.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
/* ── Tab panels (JS-toggled display) ── */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ── Content ── */
/* ── Dynamically-rendered markdown content ── */
.help-body { line-height: 1.7; }
details {
@@ -83,8 +85,6 @@
.help-body pre { background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px; padding: 0.75rem 1rem; overflow-x: auto; margin: 0.5rem 0; }
.help-body pre code { background: none; border: none; padding: 0; font-size: 0.85em; color: var(--pg-muted); }
.help-body hr { border: none; border-top: 1px solid var(--pg-border); margin: 0.5rem 0; }
.empty-state { color: var(--pg-dim); font-size: 0.9rem; padding: 2rem 0; text-align: center; }
</style>
</head>
<body>
@@ -92,6 +92,7 @@
<a id="nav-chat" href="/" class="nav-link">← Chat</a>
<a href="/help" class="nav-link active">Help</a>
<a href="/settings" class="nav-link" id="nav-settings">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
@@ -99,23 +100,33 @@
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="page">
<header>
<h1>Help &amp; Reference</h1>
<p id="persona-label"></p>
</header>
<div class="tab-bar">
<button class="tab-btn active" data-tab="ui">UI Guide</button>
<button class="tab-btn" data-tab="tools">Tools</button>
<button class="tab-btn" data-tab="persona" id="tab-btn-persona">Persona</button>
<div class="max-w-3xl mx-auto px-6 py-8 pb-16">
<div class="mb-6 pb-4 border-b border-pg-border">
<h1 class="text-xl font-bold text-pg-accent">Help &amp; Reference</h1>
<p id="persona-label" class="text-xs text-pg-muted mt-1"></p>
</div>
<div id="tab-ui" class="tab-panel active"><div class="help-body"><p class="empty-state">Loading…</p></div></div>
<div id="tab-tools" class="tab-panel"> <div class="help-body"><p class="empty-state">Loading…</p></div></div>
<div id="tab-persona" class="tab-panel"> <div class="help-body"><p class="empty-state">Loading…</p></div></div>
<!-- Tab bar -->
<div class="flex gap-1 mb-5 border-b border-pg-border -mb-px">
<button class="tab-btn px-4 py-2 text-sm font-medium text-pg-dim border-b-2 border-transparent -mb-px cursor-pointer transition-colors hover:text-pg-bright active"
data-tab="ui">UI Guide</button>
<button class="tab-btn px-4 py-2 text-sm font-medium text-pg-dim border-b-2 border-transparent -mb-px cursor-pointer transition-colors hover:text-pg-bright"
data-tab="tools">Tools</button>
<button class="tab-btn px-4 py-2 text-sm font-medium text-pg-dim border-b-2 border-transparent -mb-px cursor-pointer transition-colors hover:text-pg-bright"
data-tab="persona" id="tab-btn-persona">Persona</button>
</div>
<div id="tab-ui" class="tab-panel active"><div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
<div id="tab-tools" class="tab-panel"> <div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
<div id="tab-persona" class="tab-panel"> <div class="help-body"><p class="text-pg-dimmer text-sm text-center py-8">Loading…</p></div></div>
</div>
<style>
/* Active tab indicator — must be CSS since Tailwind can't match .active sibling state */
.tab-btn.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
</style>
<script>
const cfg = window.HELP_CONFIG || {};
const user = cfg.user || 'scott';
@@ -186,13 +197,13 @@
fetch('/static/HELP.md')
.then(r => r.ok ? r.text() : Promise.reject(r.status))
.then(md => render('tab-ui', md, false, UI_OPEN))
.catch(e => { document.querySelector('#tab-ui .help-body').innerHTML = `<p class="empty-state">Failed to load: ${e}</p>`; });
.catch(e => { document.querySelector('#tab-ui .help-body').innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">Failed to load: ${e}</p>`; });
// Tools
fetch('/static/TOOLS.md')
.then(r => r.ok ? r.text() : Promise.reject(r.status))
.then(md => render('tab-tools', md, true, null))
.catch(e => { document.querySelector('#tab-tools .help-body').innerHTML = `<p class="empty-state">Failed to load: ${e}</p>`; });
.catch(e => { document.querySelector('#tab-tools .help-body').innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">Failed to load: ${e}</p>`; });
// Persona-specific HELP.md
const personaPanel = document.querySelector('#tab-persona .help-body');
@@ -204,13 +215,13 @@
if (content) {
render('tab-persona', content, true, null);
} else {
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet. Edit <code>HELP.md</code> in the Files panel to add them.</p>`;
personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet. Edit <code>HELP.md</code> in the Files panel to add them.</p>`;
}
} else {
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet.</p>`;
personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet.</p>`;
}
} catch (_) {
personaPanel.innerHTML = `<p class="empty-state">No ${persona}-specific notes yet.</p>`;
personaPanel.innerHTML = `<p class="text-pg-dimmer text-sm text-center py-8">No ${persona}-specific notes yet.</p>`;
}
}

View File

@@ -7,19 +7,35 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
details.channel-block {
border: 1px solid var(--pg-border); border-radius: 8px;
margin-bottom: 0.75rem; overflow: hidden;
}
details.channel-block summary {
padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--pg-muted); cursor: pointer; list-style: none;
display: flex; align-items: center; gap: 0.5rem;
user-select: none; background: var(--pg-bg);
}
details.channel-block summary::-webkit-details-marker { display: none; }
details.channel-block summary::before {
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
@@ -27,16 +43,6 @@
}
details.channel-block[open] summary::before { transform: rotate(90deg); }
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
.channel-block-body { padding: 1rem 1rem 0.25rem; }
.channel-hint {
font-size: 0.75rem; color: var(--pg-dimmer);
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.5;
}
.field-row {
display: grid; grid-template-columns: 1fr auto; gap: 0.75rem; align-items: end;
}
.field-row .field { margin-bottom: 0; }
.field-narrow input { max-width: 120px; }
</style>
</head>
<body>
@@ -44,6 +50,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
@@ -60,7 +67,6 @@
<form method="POST" action="/settings/integrations">
<!-- Aether Platform Database -->
<div class="section">
<h2>Aether Platform Database</h2>
<p class="section-note">
@@ -69,14 +75,18 @@
Only SELECT, SHOW, DESCRIBE, and EXPLAIN are permitted — no writes possible.
</p>
<details class="channel-block" {{ ae_db_host and 'open' or '' }}>
<summary>Connection</summary>
<div class="channel-block-body">
<p class="channel-hint">
Use the same credentials as <code>agents_sync/mcp/scripts/sql_inspector.py</code>.
The password field is left blank in the form — leave it blank to keep the stored value.
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
{{ ae_db_host and 'open' or '' }}>
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
Connection
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer mb-4 -mt-1 leading-relaxed">
Use the same credentials as
<code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1 text-xs">agents_sync/mcp/scripts/sql_inspector.py</code>.
Leave the password blank to keep the stored value.
</p>
<div class="field-row">
<div class="grid grid-cols-[1fr_7rem] gap-3 items-start">
<div class="field">
<label for="ae_db_host">Host</label>
<input type="text" id="ae_db_host" name="ae_db_host"
@@ -84,7 +94,7 @@
placeholder="192.168.64.5"
autocomplete="off" spellcheck="false">
</div>
<div class="field field-narrow">
<div class="field">
<label for="ae_db_port">Port</label>
<input type="number" id="ae_db_port" name="ae_db_port"
value="{{ ae_db_port }}"
@@ -117,7 +127,7 @@
</details>
</div>
<button type="submit" class="btn-submit">Save integrations</button>
<button type="submit" class="btn-submit w-full md:w-96">Save integrations</button>
</form>
</div>
</body>

File diff suppressed because it is too large Load Diff

View File

@@ -7,38 +7,36 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
/* ── Test action buttons ── */
.test-btn-row { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.test-btn {
flex: 1; padding: 0.6rem 0.75rem;
border: 1px solid var(--pg-border); border-radius: 6px;
background: var(--pg-bg); color: var(--pg-text);
font-size: 0.85rem; font-weight: 500; cursor: pointer;
transition: border-color 0.15s, color 0.15s; text-align: center;
}
.test-btn:hover { border-color: var(--pg-action); color: var(--pg-accent); }
.test-btn:disabled { opacity: 0.5; cursor: default; }
.test-result {
margin-top: 0.75rem; padding: 0.6rem 0.8rem; border-radius: 6px;
font-size: 0.82rem; line-height: 1.5; display: none;
}
.test-result.ok { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.25); }
.test-result.err { background: rgba(248,113,113,0.1); color: #f87171; border: 1px solid rgba(248,113,113,0.25); }
/* ── Channel config collapsible blocks ── */
details.channel-block {
border: 1px solid var(--pg-border); border-radius: 8px;
margin-bottom: 0.75rem; overflow: hidden;
}
details.channel-block summary {
padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600;
color: var(--pg-muted); cursor: pointer; list-style: none;
display: flex; align-items: center; gap: 0.5rem;
user-select: none; background: var(--pg-bg);
}
/* ── Channel collapsible arrow ── */
details.channel-block summary::-webkit-details-marker { display: none; }
details.channel-block summary::before {
content: '▶'; font-size: 0.65rem; color: var(--pg-dimmer);
@@ -46,11 +44,9 @@
}
details.channel-block[open] summary::before { transform: rotate(90deg); }
details.channel-block[open] summary { border-bottom: 1px solid var(--pg-border); }
.channel-block-body { padding: 1rem 1rem 0.25rem; }
.channel-hint {
font-size: 0.75rem; color: var(--pg-dimmer);
margin-top: -0.6rem; margin-bottom: 1rem; line-height: 1.5;
}
/* ── Test result feedback (JS-toggled display) ── */
#test-result { display: none; }
</style>
</head>
<body>
@@ -58,6 +54,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link active">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
@@ -67,7 +64,7 @@
</nav>
<div class="page-wrap">
<h1 class="page-title">Notifications</h1>
<p class="page-subtitle">How Inara reaches out proactively — reminders, cron jobs, and memory digests.</p>
<p class="page-subtitle">How your persona reaches out proactively — reminders, cron jobs, and memory digests.</p>
<!-- SUCCESS -->
<!-- ERROR -->
@@ -90,8 +87,9 @@
<p class="hint">Used for reminder alerts, distillation summaries, and cron job notifications.</p>
</div>
<div class="field">
<label for="notification_email">Email address override
<span style="color:var(--pg-dim); font-weight:400;">(optional)</span>
<label for="notification_email">
Email address override
<span class="font-normal text-pg-dim">(optional)</span>
</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ notify_email_override }}"
@@ -110,12 +108,15 @@
requires a Nextcloud username and app password.
</p>
<details class="channel-block" {{ nc_url and 'open' or '' }}>
<summary>Bot credentials (sending)</summary>
<div class="channel-block-body">
<p class="channel-hint">
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
{{ nc_url and 'open' or '' }}>
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
Bot credentials (sending)
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
Set these up in your Nextcloud Talk room → Bot settings.
See the <a href="/help" style="color:var(--pg-accent);">setup guide</a> for step-by-step instructions.
See the <a href="/help" class="text-pg-accent">setup guide</a> for step-by-step instructions.
</p>
<div class="field">
<label for="nc_url">Nextcloud URL</label>
@@ -143,10 +144,13 @@
</div>
</details>
<details class="channel-block" {{ nc_username and 'open' or '' }}>
<summary>API credentials (reading history)</summary>
<div class="channel-block-body">
<p class="channel-hint">
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
{{ nc_username and 'open' or '' }}>
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
API credentials (reading history)
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
Required for the <code>nc_talk_history</code> orchestrator tool.
Generate an app password in Nextcloud → Settings → Security → App passwords.
</p>
@@ -172,15 +176,18 @@
<div class="section">
<h2>Home Assistant</h2>
<p class="section-note">
Receive events from HA automations and let Inara call the HA REST API
Receive events from HA automations and let your persona call the HA REST API
(read states, control devices). Webhook ID is the shared secret used in your
HA <code>rest_command</code> URL.
</p>
<details class="channel-block" {{ ha_url and 'open' or '' }}>
<summary>Connection</summary>
<div class="channel-block-body">
<p class="channel-hint">
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
{{ ha_url and 'open' or '' }}>
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
Connection
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
HA URL and a Long-Lived Access Token (Profile → scroll to bottom →
Long-Lived Access Tokens → Create Token).
</p>
@@ -201,10 +208,13 @@
</div>
</details>
<details class="channel-block" {{ ha_webhook_id and 'open' or '' }}>
<summary>Inbound webhook (HA → Cortex)</summary>
<div class="channel-block-body">
<p class="channel-hint">
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
{{ ha_webhook_id and 'open' or '' }}>
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
Inbound webhook (HA → Cortex)
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
The webhook ID is the shared secret in your HA <code>rest_command</code> URL.
Your endpoint: <code>https://cortex.dgrzone.com/webhook/ha/{{ ha_username }}/&lt;webhook_id&gt;</code>
</p>
@@ -235,10 +245,13 @@
Incoming messages are handled separately via the Google Chat Add-on.
</p>
<details class="channel-block" {{ gc_webhook and 'open' or '' }}>
<summary>Outbound webhook</summary>
<div class="channel-block-body">
<p class="channel-hint">
<details class="channel-block border border-pg-border rounded-lg overflow-hidden mb-3"
{{ gc_webhook and 'open' or '' }}>
<summary class="flex items-center gap-2 px-4 py-3 text-sm font-semibold text-pg-muted cursor-pointer select-none bg-pg-bg">
Outbound webhook
</summary>
<div class="px-4 pt-4 pb-2">
<p class="text-xs text-pg-dimmer -mt-1 mb-4 leading-relaxed">
Create a webhook in your Google Chat space → Manage webhooks. Paste the full URL here.
</p>
<div class="field">
@@ -252,7 +265,7 @@
</details>
</div>
<button type="submit" class="btn-submit">Save notification settings</button>
<button type="submit" class="btn-submit w-full md:w-96">Save notification settings</button>
</form>
<!-- Test -->
@@ -262,11 +275,14 @@
Fire a notification via your configured channel or run the reminder check
immediately — no need to wait for the daily 09:00 scheduler job.
</p>
<div class="test-btn-row">
<button class="test-btn" id="btn-test-notify">Send Test Notification</button>
<button class="test-btn" id="btn-check-reminders">Check Reminders Now</button>
<div class="flex gap-3 mt-2">
<button class="flex-1 px-3 py-2.5 text-sm font-medium border border-pg-border rounded-md bg-pg-bg text-pg-text hover:border-pg-action hover:text-pg-accent transition-colors cursor-pointer disabled:opacity-50"
id="btn-test-notify">Send Test Notification</button>
<button class="flex-1 px-3 py-2.5 text-sm font-medium border border-pg-border rounded-md bg-pg-bg text-pg-text hover:border-pg-action hover:text-pg-accent transition-colors cursor-pointer disabled:opacity-50"
id="btn-check-reminders">Check Reminders Now</button>
</div>
<div class="test-result" id="test-result"></div>
<div id="test-result"
class="mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed"></div>
</div>
</div>
@@ -287,7 +303,9 @@
function showResult(ok, msg) {
resultEl.textContent = msg;
resultEl.className = 'test-result ' + (ok ? 'ok' : 'err');
resultEl.className = ok
? 'mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed bg-green-950 text-green-400 border border-green-800'
: 'mt-3 px-3 py-2.5 rounded-md text-sm leading-relaxed bg-red-950 text-red-400 border border-red-800';
resultEl.style.display = 'block';
}

View File

@@ -91,6 +91,7 @@ input, select, textarea {
input:focus, select:focus, textarea:focus { border-color: var(--pg-action); }
input[readonly] { color: var(--pg-muted); cursor: default; }
input[type="password"] { font-family: monospace; letter-spacing: 0.05em; }
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
textarea {
font-family: 'SF Mono', 'Fira Mono', 'Menlo', monospace;
@@ -99,12 +100,12 @@ textarea {
/* ── Buttons ── */
/* Full-width primary form submit */
/* Primary form submit */
.btn-submit {
width: 100%; padding: 0.7rem; margin-top: 0.25rem;
padding: 0.6rem 1.5rem; margin-top: 0.25rem;
background: var(--pg-action); border: none; border-radius: 6px;
color: #fff; font-size: 1rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
color: #fff; font-size: 0.9rem; font-weight: 600;
cursor: pointer; transition: opacity 0.15s;
}
.btn-submit:hover { opacity: 0.88; }

View File

@@ -7,10 +7,36 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
/* ── Persona list ── */
/* ── Server-generated persona list ── */
.persona-list {
list-style: none; display: flex; flex-direction: column;
gap: 0.5rem; margin-top: 0.5rem;
@@ -37,13 +63,8 @@
border-color: var(--pg-action); font-size: 0.9rem;
}
.persona-rename-form .btn-save { padding: 0.3rem 0.75rem; font-size: 0.85rem; }
.add-persona {
display: inline-block; margin-top: 0.75rem;
font-size: 0.8rem; color: var(--pg-muted); text-decoration: none;
}
.add-persona:hover { color: var(--pg-accent); }
/* ── Role badge ── */
/* ── Server-generated role badge ── */
.role-badge {
display: inline-block; padding: 0.25rem 0.75rem;
border-radius: 20px; font-size: 0.78rem; font-weight: 600;
@@ -58,26 +79,8 @@
border: 1px solid var(--pg-border);
}
/* ── OpenRouter quickstart warning card ── */
#openrouter-quickstart {
display: none; background: #1c1a0a; border: 1px solid #78350f;
border-radius: 8px; padding: 1rem; margin-bottom: 1rem;
}
#openrouter-quickstart .qs-title {
font-size: 0.82rem; color: #fbbf24; font-weight: 600; margin-bottom: 0.4rem;
}
#openrouter-quickstart .qs-body {
font-size: 0.8rem; color: #d97706; margin-bottom: 0.75rem; line-height: 1.5;
}
.action-link.action-link-amber {
background: #92400e; color: #fef3c7; font-size: 0.85rem; padding: 0.5rem 0.9rem;
}
.action-link.action-link-amber:hover { opacity: 0.9; background: #78350f; }
/* ── Inline result feedback spans ── */
.result-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
/* ── Usage table wrapper ── */
/* ── JS-toggled states ── */
#clear-ls-ok { display: none; margin-left: 0.75rem; font-size: 0.8rem; color: #4ade80; }
.usage-wrap { overflow-x: auto; }
</style>
</head>
@@ -86,6 +89,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link active">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
@@ -100,6 +104,21 @@
<!-- SUCCESS -->
<!-- ERROR -->
<!-- OpenRouter quickstart (shown by JS when no model is configured) -->
<div id="openrouter-quickstart"
class="hidden rounded-xl border border-amber-800 bg-amber-950 p-4 mb-5">
<p class="text-xs font-semibold text-amber-400 mb-1">⚡ You're on the server default model</p>
<p class="text-xs text-amber-600 mb-3 leading-relaxed">
You can chat now, but adding your own model gives you more choices, lets you pick
role-specific models, and tracks your usage separately.
OpenRouter is the easiest way to get started — one key, many models.
</p>
<a href="/setup/model"
class="inline-block px-3 py-2 rounded-md bg-amber-900 text-amber-100 text-sm font-medium hover:bg-amber-800 transition-colors">
Set up OpenRouter →
</a>
</div>
<!-- Account info -->
<div class="section">
<h2>Account</h2>
@@ -156,7 +175,7 @@
placeholder=".*@example\.com&#10;alice@example\.com"
spellcheck="false">{{ email_allowlist }}</textarea>
</div>
<button type="submit" class="btn-submit">Save allowlist</button>
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
</form>
</div>
@@ -174,28 +193,10 @@
placeholder="https://ha.dgrzone.com/api/webhook/&#10;https://n8n.dgrzone.com/webhook/"
spellcheck="false">{{ http_allowlist }}</textarea>
</div>
<button type="submit" class="btn-submit">Save allowlist</button>
<button type="submit" class="btn-submit w-full md:w-96">Save allowlist</button>
</form>
</div>
<!-- Notifications -->
<div class="section">
<h2>Notifications</h2>
<p class="section-note">
Configure how Inara reaches out proactively — reminders, cron jobs, and memory digests.
</p>
<a href="/settings/notifications" class="action-link">Notification settings →</a>
</div>
<!-- Tool Permissions → /settings/tools -->
<div class="section">
<h2>Tool Permissions</h2>
<p class="section-note">
Configure tool access, risk policy, and confirmation gate overrides on the Tools page.
</p>
<a href="/settings/tools" class="action-link">Tool settings →</a>
</div>
<!-- Usage summary -->
<div class="section" id="usage-section">
<h2>Usage</h2>
@@ -216,28 +217,7 @@
theme, font size, and context tier. Does not sign you out.
</p>
<button type="button" id="clear-ls-btn" class="btn-secondary">Clear browser cache</button>
<span id="clear-ls-ok" class="result-ok">Cleared.</span>
</div>
<!-- Model Registry -->
<div class="section">
<h2>Model Registry</h2>
<div id="openrouter-quickstart">
<p class="qs-title">⚡ You're on the server default model</p>
<p class="qs-body">
You can chat now, but adding your own model gives you more choices, lets you pick
role-specific models, and tracks your usage separately.
OpenRouter is the easiest way to get started — one key, many models.
</p>
<a href="/setup/model" class="action-link action-link-amber">Set up OpenRouter →</a>
</div>
<p class="section-note">
Configure AI providers (Anthropic, Google), local hosts (Open WebUI, Ollama, OpenRouter, etc.),
and assign models to roles — chat, orchestrator, distill, and more.
</p>
<a href="/settings/models" class="action-link">Manage models →</a>
<span id="clear-ls-ok">Cleared.</span>
</div>
<!-- Change Password -->
@@ -259,7 +239,7 @@
<input type="password" id="confirm_password" name="confirm_password"
autocomplete="new-password" required>
</div>
<button type="submit" class="btn-submit">Update password</button>
<button type="submit" class="btn-submit w-full md:w-96">Update password</button>
</form>
</div>
@@ -271,7 +251,9 @@
Only unnamed sessions are affected — existing names are left alone.
</p>
<button type="button" id="backfill-names-btn" class="btn-secondary">Auto-name old sessions</button>
<span id="backfill-names-ok" class="result-ok"></span>
<span id="backfill-names-ok"
class="ml-3 text-xs hidden"
style="color:#4ade80"></span>
</div>
<!-- Personas -->
@@ -280,7 +262,10 @@
<ul class="persona-list">
{{ persona_items }}
</ul>
<a href="/setup/persona" class="add-persona">+ Add new persona</a>
<a href="/setup/persona"
class="inline-block mt-3 text-xs text-pg-muted hover:text-pg-accent transition-colors">
+ Add new persona
</a>
</div>
</div>
@@ -317,7 +302,9 @@
try {
const d = await fetch('/backend').then(r => r.json());
if ((d.available_roles || []).length === 0) {
document.getElementById('openrouter-quickstart').style.display = 'block';
const el = document.getElementById('openrouter-quickstart');
el.classList.remove('hidden');
el.style.display = 'block';
}
} catch (_) {}
})();
@@ -375,10 +362,12 @@
const n = data.named ?? 0;
ok.textContent = `Named ${n} session${n !== 1 ? 's' : ''}.`;
ok.style.display = 'inline';
ok.classList.remove('hidden');
} catch (e) {
ok.textContent = 'Error — check console.';
ok.style.color = '#f87171';
ok.style.display = 'inline';
ok.classList.remove('hidden');
}
btn.textContent = 'Auto-name old sessions';
btn.disabled = false;

View File

@@ -7,42 +7,36 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }
}
}
}
</script>
<link rel="stylesheet" href="/static/pg.css">
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
/* ── Policy cards (bordered sections on tools page) ── */
.policy-card {
background: var(--pg-surface); border: 1px solid var(--pg-border);
border-radius: 0.75rem; padding: 1.25rem 1.5rem; margin-bottom: 1.75rem;
}
.policy-card h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; }
.policy-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.policy-label { font-size: 0.875rem; font-weight: 500; min-width: 6rem; }
.policy-note { font-size: 0.8rem; color: var(--pg-muted); margin-top: 0.35rem; line-height: 1.5; }
/* Compact selects and inputs inside policy cards */
.policy-card select, .policy-card input[type="text"] {
padding: 0.4rem 0.65rem; font-size: 0.875rem;
}
/* Two-column layout for allow/deny textareas */
.col-split { display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start; }
.col-half { flex: 1; min-width: 200px; }
.col-half label { font-size: 0.8rem; font-weight: 600; margin-bottom: 0.35rem; }
.col-half textarea {
font-size: 0.82rem; border-radius: 0.375rem; padding: 0.45rem 0.65rem;
}
/* Save button (compact, not full-width) */
.save-btn {
background: var(--pg-action); color: #fff; border: none;
border-radius: 0.5rem; padding: 0.5rem 1.4rem;
font-size: 0.875rem; font-weight: 600; cursor: pointer;
margin-top: 0.5rem; transition: opacity 0.15s;
}
.save-btn:hover { opacity: 0.88; }
/* ── Tool table ── */
/* ── Server-generated tool table ── */
.table-section-label {
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--pg-dimmer);
@@ -65,7 +59,7 @@
.tool-table tr:hover td { background: rgba(124,58,237,0.04); }
.tool-name { font-family: monospace; font-size: 0.82rem; }
/* Risk badges */
/* Risk badges (server-generated) */
.risk { display: inline-block; font-size: 0.7rem; font-weight: 700;
padding: 0.15rem 0.45rem; border-radius: 9999px; letter-spacing: 0.04em; }
.risk-low { background: rgba(34,197,94,0.12); color: #4ade80; }
@@ -75,7 +69,7 @@
[data-theme="light"] .risk-medium { background: rgba(234,179,8,0.15); color: #ca8a04; }
[data-theme="light"] .risk-high { background: rgba(239,68,68,0.15); color: #dc2626; }
/* Auto status pill */
/* Auto-status pill (server-generated, updated by JS) */
.auto-pill {
display: inline-block; font-size: 0.68rem; font-weight: 600;
padding: 0.12rem 0.4rem; border-radius: 9999px;
@@ -84,19 +78,13 @@
.auto-off { background: rgba(148,163,184,0.12); color: var(--pg-dimmer); }
[data-theme="light"] .auto-on { color: #7c3aed; }
/* Override select */
/* Override select (server-generated) */
.override-sel {
font-size: 0.78rem; padding: 0.25rem 0.5rem;
border-radius: 0.3rem; min-width: 7rem; width: auto;
}
.override-sel.forced-on { border-color: #7c3aed; color: #7c3aed; }
.override-sel.forced-off { border-color: #dc2626; color: #dc2626; }
/* Legend */
.legend { display: flex; gap: 1.25rem; flex-wrap: wrap; margin-bottom: 1.25rem; font-size: 0.8rem; color: var(--pg-muted); }
.legend-dot { display: inline-block; width: 0.55rem; height: 0.55rem; border-radius: 50%; margin-right: 0.3rem; }
.legend-dot.on { background: #a78bfa; }
.legend-dot.off { background: var(--pg-dimmer); }
</style>
</head>
<body>
@@ -105,6 +93,7 @@
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link active">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
@@ -125,55 +114,55 @@
<form method="POST" action="/settings/tools" id="tools-form">
<!-- Risk policy -->
<div class="policy-card">
<h2>Risk Policy</h2>
<div class="policy-row">
<span class="policy-label">Max risk level</span>
<select name="max_risk" id="max-risk-sel">
<!-- Risk policy card -->
<div class="rounded-xl border border-pg-border bg-pg-surface p-5 mb-5">
<h2 class="text-sm font-semibold text-pg-bright mb-4">Risk Policy</h2>
<div class="flex items-center gap-4 flex-wrap mb-3">
<span class="text-sm font-medium text-pg-text min-w-[6rem]">Max risk level</span>
<select name="max_risk" id="max-risk-sel" class="w-auto">
<option value="" {{ sel_none }}>No filter — use all role-permitted tools</option>
<option value="low" {{ sel_low }}>Low — read-only and sandboxed tools only</option>
<option value="medium" {{ sel_medium }}>Medium — low + medium risk (recommended)</option>
<option value="high" {{ sel_high }}>High — all tools including destructive ones</option>
</select>
</div>
<p class="policy-note">
<strong>Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br>
<strong>Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br>
<strong>High</strong> tools affect external systems or the host (shell exec, email, device control, service restart).
<p class="text-xs text-pg-muted leading-relaxed mb-2">
<strong class="text-pg-text">Low</strong> tools are read-only and sandboxed (web search, project file reads, HA status checks).<br>
<strong class="text-pg-text">Medium</strong> tools write to local data or send notifications to you (cron jobs, scratch, task management).<br>
<strong class="text-pg-text">High</strong> tools affect external systems or the host (shell exec, email, device control, service restart).
</p>
<p class="policy-note" style="margin-top:0.75rem;">
<p class="text-xs text-pg-muted leading-relaxed">
The <em>Auto</em> column below shows each tool's status at your current max risk level.
Use the override column to force-include or force-exclude individual tools.
</p>
</div>
<!-- Legend -->
<div class="legend">
<span><span class="legend-dot on"></span>Auto-included by risk level</span>
<span><span class="legend-dot off"></span>Auto-excluded by risk level</span>
<div class="flex gap-5 flex-wrap mb-4 text-xs text-pg-muted">
<span><span class="inline-block w-2 h-2 rounded-full bg-[#a78bfa] mr-1.5"></span>Auto-included by risk level</span>
<span><span class="inline-block w-2 h-2 rounded-full bg-pg-dimmer mr-1.5"></span>Auto-excluded by risk level</span>
</div>
<!-- Tool table -->
<!-- Tool table (server-generated) -->
{{ tool_table_html }}
<!-- Confirmation gate -->
<div class="policy-card" style="margin-top:1.75rem;">
<h2>Confirmation Gate</h2>
<p class="policy-note">
<!-- Confirmation gate card -->
<div class="rounded-xl border border-pg-border bg-pg-surface p-5 mt-5 mb-5">
<h2 class="text-sm font-semibold text-pg-bright mb-2">Confirmation Gate</h2>
<p class="text-xs text-pg-muted leading-relaxed mb-4">
Some tools require explicit confirmation before executing. Override the defaults here.<br>
Tools requiring confirmation by default: <code>{{ confirm_required_tools }}</code>
Tools requiring confirmation by default: <code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">{{ confirm_required_tools }}</code>
</p>
<div class="col-split" style="margin-top:0.85rem;">
<div class="col-half">
<label>Allow list — bypass confirmation</label>
<div class="flex gap-6 flex-wrap items-start">
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-semibold text-pg-muted mb-1">Allow list — bypass confirmation</label>
<textarea name="allow_list" rows="4"
placeholder="reminders_clear&#10;cron_remove"
autocomplete="off" spellcheck="false">{{ tool_allow }}</textarea>
<p class="hint">One tool name per line. These tools skip the confirmation prompt.</p>
</div>
<div class="col-half">
<label>Deny list — always block</label>
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-semibold text-pg-muted mb-1">Deny list — always block</label>
<textarea name="deny_list" rows="4"
placeholder="shell_exec&#10;file_write"
autocomplete="off" spellcheck="false">{{ tool_deny }}</textarea>
@@ -182,8 +171,8 @@
</div>
</div>
<div style="margin-top:1.5rem;">
<button type="submit" class="save-btn">Save tool settings</button>
<div class="mt-4">
<button type="submit" class="btn-submit w-full md:w-96">Save tool settings</button>
</div>
</form>
</div>