Changeset 0.37.12 (#224)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-22 11:59:17 +00:00
committed by xcaliber
parent c687002015
commit aeda4fdd0c
31 changed files with 118 additions and 3642 deletions

View File

@@ -6,7 +6,6 @@
// templates/login.html — standalone login page
// templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.)
// templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.)
// templates/admin/*.html — admin sub-pages (roles, routing, providers, etc.)
package pages
import (
@@ -29,7 +28,7 @@ import (
"chat-switchboard/store"
)
//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html templates/admin/*.html
//go:embed templates/*.html templates/components/*.html templates/surfaces/*.html
var templateFS embed.FS
// Engine holds parsed templates and rendering state.
@@ -271,7 +270,6 @@ func (e *Engine) parseTemplates() {
"templates/*.html",
"templates/components/*.html",
"templates/surfaces/*.html",
"templates/admin/*.html",
)
if err != nil {
log.Fatalf("❌ Failed to parse templates: %v", err)

View File

@@ -1,54 +0,0 @@
{{/* Admin models — model catalog table. */}}
{{define "admin-models"}}
<div class="admin-page">
<h2>Model Catalog</h2>
<p class="section-hint">All models synced from providers. Toggle visibility to control what users can access.</p>
<div class="admin-toolbar">
<input type="text" class="admin-search" id="modelSearch" placeholder="Filter models…" oninput="Pages.filterModels(this.value)">
<select class="admin-search" style="min-width:120px;" id="modelTypeFilter" onchange="Pages.filterModels()">
<option value="">All types</option>
<option value="chat">Chat</option>
<option value="embedding">Embedding</option>
<option value="image">Image</option>
</select>
<select class="admin-search" style="min-width:140px;" id="modelProviderFilter" onchange="Pages.filterModels()">
<option value="">All providers</option>
{{range .Data.Providers}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
{{if .Data.Models}}
<table class="admin-table" id="modelTable">
<thead>
<tr>
<th>Model</th>
<th>Provider</th>
<th>Type</th>
<th>Capabilities</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Data.Models}}
<tr data-provider="{{.ProviderConfigID}}" data-type="{{.ModelType}}" data-name="{{.DisplayName}} {{.ModelID}}">
<td>
<strong>{{.DisplayName}}</strong>
{{if ne .DisplayName .ModelID}}<br><span style="font-size:11px;color:var(--text-tertiary,#666);">{{.ModelID}}</span>{{end}}
</td>
<td>{{.ProviderName}}</td>
<td><span class="admin-badge">{{.ModelType}}</span></td>
<td style="font-size:11px;color:var(--text-secondary);"></td>
<td></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-hint">No models in catalog. Sync a provider to populate.</div>
{{end}}
</div>
{{end}}

View File

@@ -1,6 +0,0 @@
{{define "admin-overview"}}
<div class="admin-page">
<h2>Admin Overview</h2>
<p class="section-hint">System administration. Select a section from the navigation.</p>
</div>
{{end}}

View File

@@ -1,97 +0,0 @@
{{/* Admin providers — server-rendered provider list with status. */}}
{{define "admin-providers"}}
<div class="admin-page">
<h2>Providers</h2>
<p class="section-hint">AI provider configurations. Each provider connects to an API endpoint.</p>
<div class="admin-toolbar">
<button class="btn-small btn-primary" onclick="sb.call('Pages.showProviderForm')">+ Add Provider</button>
</div>
<div id="providerFormWrap" style="display:none;">
{{template "admin-provider-form" .}}
</div>
{{if .Data.Providers}}
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Scope</th>
<th>Models</th>
<th>Endpoint</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Data.ProviderDetails}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><span class="admin-badge">{{.Provider}}</span></td>
<td><span class="admin-badge admin-badge-{{.Scope}}">{{.Scope}}</span></td>
<td>{{.ModelCount}}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary);">{{.Endpoint}}</td>
<td>
<button class="btn-small" onclick="sb.call('Pages.syncProvider','{{.ID}}')">Sync</button>
<button class="btn-small" onclick="sb.call('Pages.editProvider','{{.ID}}')">Edit</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-hint">No providers configured. Add one to get started.</div>
{{end}}
</div>
{{end}}
{{define "admin-provider-form"}}
<div class="admin-inline-form" id="providerForm">
<h3 style="margin:0 0 12px;font-size:14px;" id="providerFormTitle">Add Provider</h3>
<input type="hidden" id="providerFormId" value="">
<div class="form-row">
<div class="form-group" style="flex:1;min-width:150px;">
<label>Name</label>
<input type="text" id="providerFormName" placeholder="My OpenAI">
</div>
<div class="form-group" style="flex:1;min-width:150px;">
<label>Type</label>
<select id="providerFormType">
<option value="">— select —</option>
{{range .Data.ProviderTypes}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:2;min-width:200px;">
<label>Endpoint</label>
<input type="text" id="providerFormEndpoint" placeholder="https://api.openai.com/v1">
</div>
<div class="form-group" style="flex:1;min-width:150px;">
<label>API Key</label>
<input type="password" id="providerFormKey" placeholder="sk-...">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;min-width:150px;">
<label>Scope</label>
<select id="providerFormScope">
<option value="global">Global</option>
<option value="team">Team</option>
</select>
</div>
<div class="form-group" style="flex:1;min-width:150px;" id="providerFormTeamWrap" style="display:none;">
<label>Team</label>
{{template "team-select" (dict "FieldName" "providerFormTeamId" "Label" "" "Teams" .Data.Teams "Selected" "" "AllowEmpty" false)}}
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn-small btn-primary" onclick="sb.call('Pages.saveProvider')">Save</button>
<button class="btn-small" onclick="sb.call('Pages.hideProviderForm')">Cancel</button>
</div>
</div>
{{end}}

View File

@@ -1,94 +0,0 @@
{{/*
admin/roles.html — Model Roles configuration page.
Fixes bug #1: embedding models now appear via model_type filtering.
*/}}
{{define "admin-roles"}}
<div class="admin-page">
<h2>Model Roles</h2>
<p class="section-hint">Configure which models serve background tasks. Each role has a primary and optional fallback.</p>
{{$providers := .Data.Providers}}
{{$models := .Data.Models}}
{{range .Data.Roles}}
<section class="settings-section" style="margin-bottom:16px">
<h3 style="font-size:14px;margin:0 0 12px;text-transform:capitalize">{{.Name}} Role</h3>
<p class="section-hint" style="margin-bottom:12px">
{{if eq .Name "utility"}}Summarization, classification, routing — typically a fast, cheap chat model.{{end}}
{{if eq .Name "embedding"}}Vector generation for knowledge base search and semantic note search.{{end}}
</p>
{{$roleName := .Name}}
{{$filterType := roleFilterType .Name}}
{{$primaryProvID := ""}}
{{$primaryModelID := ""}}
{{$fallbackProvID := ""}}
{{$fallbackModelID := ""}}
{{if .Primary}}
{{$primaryProvID = .Primary.ProviderID}}
{{$primaryModelID = .Primary.ModelID}}
{{end}}
{{if .Fallback}}
{{$fallbackProvID = .Fallback.ProviderID}}
{{$fallbackModelID = .Fallback.ModelID}}
{{end}}
<div class="role-config" data-role="{{$roleName}}">
<div style="margin-bottom:8px">
<label style="font-size:12px;color:var(--text-secondary)">Primary</label>
<div class="form-row" style="gap:8px;align-items:center;margin-top:4px">
<select class="model-provider-select"
data-role="{{$roleName}}" data-slot="primary"
data-filter-type="{{$filterType}}"
onchange="Pages.roleProviderChanged(this)"
style="min-width:140px">
<option value="">— none —</option>
{{range $providers}}
<option value="{{.ID}}"{{if eq .ID $primaryProvID}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<select class="model-model-select"
data-role="{{$roleName}}" data-slot="primary"
style="min-width:200px">
<option value="">— select model —</option>
{{range $models}}
{{if and (eq .ProviderConfigID $primaryProvID) (or (eq $filterType "") (eq .ModelType $filterType))}}
<option value="{{.ModelID}}"{{if eq .ModelID $primaryModelID}} selected{{end}}>{{.DisplayName}}</option>
{{end}}
{{end}}
</select>
</div>
</div>
<div style="margin-bottom:8px">
<label style="font-size:12px;color:var(--text-secondary)">Fallback</label>
<div class="form-row" style="gap:8px;align-items:center;margin-top:4px">
<select class="model-provider-select"
data-role="{{$roleName}}" data-slot="fallback"
data-filter-type="{{$filterType}}"
onchange="Pages.roleProviderChanged(this)"
style="min-width:140px">
<option value="">— none —</option>
{{range $providers}}
<option value="{{.ID}}"{{if eq .ID $fallbackProvID}} selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<select class="model-model-select"
data-role="{{$roleName}}" data-slot="fallback"
style="min-width:200px">
<option value="">— select model —</option>
{{range $models}}
{{if and (eq .ProviderConfigID $fallbackProvID) (or (eq $filterType "") (eq .ModelType $filterType))}}
<option value="{{.ModelID}}"{{if eq .ModelID $fallbackModelID}} selected{{end}}>{{.DisplayName}}</option>
{{end}}
{{end}}
</select>
</div>
</div>
<button class="btn-small btn-primary" onclick="sb.call('Pages.saveRole','{{$roleName}}')">Save</button>
</div>
</section>
{{end}}
</div>
{{end}}

View File

@@ -1,80 +0,0 @@
{{/*
admin/routing.html — Routing Policy management.
Fixes bug #2: team scope uses a proper dropdown, not raw text.
*/}}
{{define "admin-routing"}}
<div class="admin-page">
<h2>Routing Policies</h2>
<p class="section-hint">Define rules for model selection, cost limits, and team-specific routing.</p>
<div style="margin-bottom:12px">
<button class="btn-small btn-primary" onclick="sb.call('Pages.showRoutingForm')">+ New Policy</button>
</div>
<div id="routingPolicyForm" style="display:none" class="admin-inline-form">
<input type="hidden" id="routingPolicyId">
<div class="form-row" style="gap:8px">
<div class="form-group" style="flex:1">
<label>Name</label>
<input type="text" id="routingPolicyName" placeholder="e.g. Prefer local provider">
</div>
<div class="form-group" style="width:80px">
<label>Priority</label>
<input type="number" id="routingPolicyPriority" value="100" min="0" max="999">
</div>
</div>
<div class="form-row" style="gap:8px">
<div class="form-group" style="flex:1">
<label>Type</label>
<select id="routingPolicyType">
<option value="provider_prefer">Provider Prefer</option>
<option value="team_route">Team Route</option>
<option value="cost_limit">Cost Limit</option>
<option value="model_alias">Model Alias</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label>Scope</label>
<select id="routingPolicyScope" onchange="Pages.routingScopeChanged(this)">
<option value="global">Global</option>
<option value="team">Team</option>
</select>
</div>
</div>
{{/* Team dropdown — shown when scope=team. Pre-populated from server. */}}
<div class="form-row" id="routingTeamRow" style="display:none">
<div class="form-group" style="flex:1">
<label>Team</label>
<select id="routingPolicyTeamId">
<option value="">— select team —</option>
{{range .Data.Teams}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<div class="form-group">
<label>Config (JSON)</label>
<textarea id="routingPolicyConfig" rows="4" placeholder='{"provider_config_id": "..."}'
style="font-family:monospace;"></textarea>
</div>
<div class="form-row" style="gap:8px">
<label class="checkbox-label">
<input type="checkbox" id="routingPolicyActive" checked> Active
</label>
</div>
<div class="form-row" style="gap:8px;margin-top:8px">
<button class="btn-small btn-primary" onclick="sb.call('Pages.saveRoutingPolicy')">Save</button>
<button class="btn-small" onclick="sb.call('Pages.hideRoutingForm')">Cancel</button>
</div>
</div>
<div id="routingPolicyList">
{{if not .Data.Policies}}
<div class="empty-hint">No routing policies configured.</div>
{{end}}
</div>
</div>
{{end}}

View File

@@ -1,83 +0,0 @@
{{/* Admin settings — core system configuration. */}}
{{define "admin-settings"}}
<div class="admin-page">
<h2>Settings</h2>
<p class="section-hint">Core system configuration. Changes take effect immediately.</p>
{{/* ── Registration ───────────────────── */}}
<div class="settings-section">
<h3 style="font-size:14px;margin:0 0 12px;">Registration</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="settRegEnabled"> Allow new user registration
</label>
</div>
<div class="form-group">
<label>Default new user state</label>
<select id="settRegDefaultState" style="width:180px;">
<option value="active">Active immediately</option>
<option value="pending">Pending approval</option>
</select>
</div>
</div>
{{/* ── User Permissions ─────────────────── */}}
<div class="settings-section">
<h3 style="font-size:14px;margin:0 0 12px;">User Permissions</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="settUserBYOK"> Allow users to add own API keys (BYOK)
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="settUserPersonas"> Allow users to create personas
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="settKBDirect"> Allow direct knowledge base access
</label>
</div>
</div>
{{/* ── Banner ─────────────────────────── */}}
<div class="settings-section">
<h3 style="font-size:14px;margin:0 0 12px;">Environment Banner</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="settBannerEnabled"> Show banner
</label>
</div>
<div id="bannerFields" style="display:none;">
<div class="form-row">
<div class="form-group" style="flex:2;min-width:200px;">
<label>Text</label>
<input type="text" id="settBannerText" placeholder="DEVELOPMENT">
</div>
<div class="form-group" style="flex:1;min-width:100px;">
<label>Background</label>
<input type="color" id="settBannerBg" value="#007a33" style="width:60px;height:30px;padding:2px;">
</div>
<div class="form-group" style="flex:1;min-width:100px;">
<label>Text Color</label>
<input type="color" id="settBannerFg" value="#ffffff" style="width:60px;height:30px;padding:2px;">
</div>
</div>
</div>
</div>
{{/* ── System Prompt ──────────────────── */}}
<div class="settings-section">
<h3 style="font-size:14px;margin:0 0 12px;">Default System Prompt</h3>
<div class="form-group">
<textarea id="settSystemPrompt" rows="4" style="width:100%;resize:vertical;" placeholder="Optional system prompt applied to all new conversations"></textarea>
</div>
</div>
<div style="margin-top:16px;">
<button class="btn-small btn-primary" onclick="sb.call('Pages.saveSettings')">Save Settings</button>
</div>
</div>
{{end}}

View File

@@ -1,62 +0,0 @@
{{/* Admin teams — team list with member counts. */}}
{{define "admin-teams"}}
<div class="admin-page">
<h2>Teams</h2>
<p class="section-hint">Organize users into teams for scoped access to providers, personas, and policies.</p>
<div class="admin-toolbar">
<button class="btn-small btn-primary" onclick="sb.call('Pages.showTeamForm')">+ Create Team</button>
</div>
<div id="teamFormWrap" style="display:none;">
<div class="admin-inline-form" id="teamForm">
<h3 style="margin:0 0 12px;font-size:14px;" id="teamFormTitle">Create Team</h3>
<input type="hidden" id="teamFormId" value="">
<div class="form-row">
<div class="form-group" style="flex:1;min-width:200px;">
<label>Name</label>
<input type="text" id="teamFormName" placeholder="Engineering">
</div>
<div class="form-group" style="flex:2;min-width:250px;">
<label>Description</label>
<input type="text" id="teamFormDescription" placeholder="Optional description">
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn-small btn-primary" onclick="sb.call('Pages.saveTeam')">Save</button>
<button class="btn-small" onclick="sb.call('Pages.hideTeamForm')">Cancel</button>
</div>
</div>
</div>
{{if .Data.Teams}}
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Members</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Data.Teams}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td style="color:var(--text-secondary);">{{.Description}}</td>
<td>{{.MemberCount}}</td>
<td><span class="admin-badge admin-badge-{{if .IsActive}}active{{else}}inactive{{end}}">{{if .IsActive}}active{{else}}inactive{{end}}</span></td>
<td>
<button class="btn-small" onclick="sb.call('Pages.editTeam','{{.ID}}','{{.Name}}','{{.Description}}')">Edit</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-hint">No teams created yet.</div>
{{end}}
</div>
{{end}}

View File

@@ -1,48 +0,0 @@
{{/* Admin users — user management table. */}}
{{define "admin-users"}}
<div class="admin-page">
<h2>Users</h2>
<p class="section-hint">Manage user accounts, roles, and access.</p>
<div class="admin-toolbar">
<input type="text" class="admin-search" id="userSearch" placeholder="Filter users…" oninput="Pages.filterUsers(this.value)">
</div>
{{if .Data.Users}}
<table class="admin-table" id="userTable">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Display Name</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Data.Users}}
<tr data-name="{{.Username}} {{.Email}} {{.DisplayName}}">
<td><strong>{{.Username}}</strong></td>
<td style="color:var(--text-secondary);">{{.Email}}</td>
<td>{{.DisplayName}}</td>
<td><span class="admin-badge admin-badge-{{.Role}}">{{.Role}}</span></td>
<td><span class="admin-badge admin-badge-{{if .IsActive}}active{{else}}inactive{{end}}">{{if .IsActive}}active{{else}}inactive{{end}}</span></td>
<td>
<button class="btn-small" onclick="sb.call('Pages.editUserRole','{{.ID}}','{{.Username}}','{{.Role}}')">Role</button>
{{if .IsActive}}
<button class="btn-small" onclick="sb.call('Pages.toggleUser','{{.ID}}',false)">Disable</button>
{{else}}
<button class="btn-small" onclick="sb.call('Pages.toggleUser','{{.ID}}',true)">Enable</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-hint">No users found.</div>
{{end}}
</div>
{{end}}

View File

@@ -15,7 +15,7 @@
{{/* chat.css removed in v0.37.10 — replaced by sw-chat-surface.css + sw-chat-pane.css */}}
{{/* panels.css, pane-container.css, chat-pane.css removed in v0.37.10 */}}
<link rel="stylesheet" href="{{.BasePath}}/css/surfaces.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/splash.css?v={{.Version}}">
{{/* splash.css removed in v0.37.12 — login is Preact with sw-login.css */}}
<link rel="stylesheet" href="{{.BasePath}}/css/sw-primitives.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-chat-pane.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-notes-pane.css?v={{.Version}}">
@@ -98,18 +98,15 @@
should migrate to the Preact SDK (sw/sdk/). */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives.js?v={{.Version}}"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/virtual-scroll.js?v={{.Version}}"></script>
{{/* marked.min.js + purify.min.js removed from base in v0.37.12 — surfaces load their own copies */}}
{{/* ui-format.js, virtual-scroll.js removed in v0.37.12 */}}
{{/* ui-core.js + pages.js removed in v0.37.10 */}}
{{/* v0.25.0: Component scripts — available on all surfaces */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/user-menu.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/model-selector.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/file-tree.js?v={{.Version}}"></script>
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/code-editor.js?v={{.Version}}"></script>
{{/* chat-pane.js + pane-container.js removed in v0.37.10 — see sw/components/chat-pane/ */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/drag-resize.js?v={{.Version}}"></script>
{{/* model-selector.js, drag-resize.js removed in v0.37.12 */}}
{{/* v0.28.5: SDK — composition layer over globals. Must load after all
primitives/components it wraps, before extensions.js. */}}
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/switchboard-sdk.js?v={{.Version}}"></script>

View File

@@ -1,3 +1,3 @@
package main
const Version = "0.37.11"
const Version = "0.37.12"