Changeset 0.7.3 (#41)

This commit is contained in:
2026-02-21 21:59:38 +00:00
parent 416e5439ea
commit 1ec392879b
26 changed files with 1084 additions and 319 deletions

View File

@@ -841,12 +841,12 @@ be compacted independently. Unused branches keep their full history.
**What to implement when:**
Phase 1 (v0.6.x): Add `parent_id` to messages table. Backfill
Phase 1: Add `parent_id` to messages table. Backfill
existing messages with linear parent chains. All new messages created
with proper `parent_id`. Frontend still renders linearly — no branch
UI yet. This is the "do it now while it's cheap" step.
Phase 2 (v0.7.x+): Edit-and-resubmit creates siblings instead of
Phase 2: Edit-and-resubmit creates siblings instead of
replacing. Regenerate creates sibling model responses. Branch indicator
`← 1/2 →` in the UI. `channel_cursors` table for tracking active
branch.
@@ -887,7 +887,7 @@ supports tool calling.
Priority is based on: dependency depth (what blocks other things),
solo-user value (Jeff is the first user), and complexity.
### Phase 1: Channel Foundation + Organization (v0.6.x)
### Phase 1: Channel Foundation + Organization
**chats → channels migration + message tree + Folders + Projects + banners**
- Rename `chats``channels`, add `type`, `channel_members`,
`channel_models`, `participant_type`/`participant_id` on messages.
@@ -907,25 +907,25 @@ solo-user value (Jeff is the first user), and complexity.
- This is the schema foundation everything else builds on.
Do it now while there's one user and a handful of chats.
### Phase 2: Notes + Built-in Tools (v0.7.x)
### Phase 2: Notes + Built-in Tools
**Notes CRUD + note_* tools + tool execution framework**
- Enables the LLM to be genuinely useful beyond ephemeral chat.
- Requires: tool execution in completion handler (the plumbing for
_all_ tools).
- This phase builds the tool calling infrastructure that everything
else uses.
- **Conversation forking UI (v0.7.x):** Edit-and-resubmit creates
- **Conversation forking UI (done, 0.7.2):** Edit-and-resubmit creates
siblings instead of replacing. Regenerate creates sibling model
responses. Branch indicator `← 1/2 →` at fork points. Context
assembly uses active path, not full channel history.
### Phase 3: Web Search + URL Fetch (v0.7.x)
### Phase 3: Web Search + URL Fetch
**web_search + url_fetch tools**
- Sidecar or direct HTTP from backend.
- Paired with Notes: "research X and save findings to my notes."
- Paired with Tasks (future): automated research.
### Phase 4: @mention Routing + Multi-participant (v0.7.x)
### Phase 4: @mention Routing + Multi-participant
**@mention parsing + multi-model channels**
- The channel schema exists from Phase 1. This phase adds the
routing logic: scan messages for @mentions, resolve against
@@ -934,28 +934,28 @@ solo-user value (Jeff is the first user), and complexity.
- Enables: editor mode (multiple model roles), second opinions,
cross-model conversations.
### Phase 5: Embeddings + Knowledge Bases (v0.8.x)
### Phase 5: Embeddings + Knowledge Bases
**Embedding pipeline + pgvector + KB CRUD + kb_search tool**
- Depends on: tool execution framework (Phase 2).
- Notes get embedded too (once pipeline exists).
- Admin configures embedding model.
- KBs attach to channels via `channel_models.kb_ids`.
### Phase 6: Compaction (v0.8.x)
### Phase 6: Compaction
**Auto-compaction service**
- Depends on: utility LLM calling (same as task runner).
- Channel-scoped: compacts any channel that exceeds context threshold.
- Can be built alongside or after KB (similar backend pattern:
background job that calls an LLM).
### Phase 7: Tasks (v0.9.x)
### Phase 7: Tasks
**Scheduler + task runner + task_create tool**
- Creates `type: 'service'` channels with no human members.
- Depends on: completion handler, tool execution, notes, web search.
- The capstone: everything below it combined into autonomous agents.
- Admin controls for resource limits.
### Phase 8: Auth Strategy + Roles/Teams + Permissions (v0.10.x)
### Phase 8: Auth Strategy + Roles/Teams + Permissions
**Enterprise auth modes + RBAC beyond admin/user**
- Auth middleware strategy pattern: `AUTH_MODE` env var selects
`builtin`, `mtls`, or `oidc`. All three resolve to the same

309
BRANDING.md Normal file
View File

@@ -0,0 +1,309 @@
# Branding — Volume Mount Contract
**Version:** 0.7.3
**Status:** Spec
---
## Overview
Chat Switchboard supports white-label branding through a volume mount at
`/branding/` on the frontend container. Deployers provide a ConfigMap (or
bind mount) with their identity assets. The app reads a JSON config on
startup, serves static assets at runtime, and falls back gracefully when
no branding is mounted.
Switchboard provides the hooks. Your brand lives in its own repo.
---
## Mount Path
```
/branding/ ← volume mount root (frontend container)
├── branding.json ← config seed (required if mount exists)
├── favicon.png ← tab/bookmark icon
├── logo.png ← splash page hero, optional sidebar
└── custom.css ← style overrides (power-user escape hatch)
```
All files are optional individually, but `branding.json` is expected if the
mount exists. Missing files degrade gracefully — no broken images, no JS errors.
---
## branding.json — Config Seed
```json
{
"org_name": "Gobha.ai",
"tagline": "Something clever here",
"accent_color": "#4a9eff",
"logo": "logo.png",
"favicon": "favicon.png"
}
```
**Fields:**
| Field | Type | Default | Description |
|----------------|--------|----------------------|---------------------------------------------|
| `org_name` | string | `"Chat Switchboard"` | Displayed in splash, header, auth card, `<title>` |
| `tagline` | string | `"Multi-Model AI Chat"` | Splash page subtitle, auth footer |
| `headline` | string | `null` | Splash hero headline (default preserved if null) |
| `accent_color` | string | `"#4a9eff"` | Primary UI accent (hex) |
| `logo` | string | `null` | Filename relative to `/branding/` |
| `favicon` | string | `null` | Filename relative to `/branding/` |
| `pills` | array | (Switchboard defaults) | Feature pills on splash. `[]` = hide. See below. |
**Pills format:**
```json
"pills": [
{ "icon": "⚡", "text": "Fast inference", "style": "accent" },
{ "icon": "🔒", "text": "Zero trust", "style": "purple" },
{ "icon": "🏢", "text": "Enterprise ready" }
]
```
`style` is optional: `"accent"` uses the accent color, `"purple"` uses purple,
omit for the default neutral pill. Set `"pills": []` to hide the section entirely.
Omit the field to keep the stock Switchboard pills.
**Lifecycle:**
1. Frontend entrypoint (`docker-entrypoint-fe.sh`) reads `/branding/branding.json` on container start
2. Values are injected into `index.html` as a `<script>` block: `window.__BRANDING__`
3. The `branding` key is also upserted into `global_settings` via the backend on startup
(admin panel override layer — if admins change values in the UI, those take precedence
until the next cold deploy with a branding mount)
4. Frontend `initBranding()` applies values from `window.__BRANDING__` (fast, no API call)
then overlays any DB overrides from `App.serverSettings.branding` (from public settings)
**Resolution order:** `branding.json` (fast init) → DB `global_settings.branding` (override layer)
---
## favicon.png — Static Asset
Served at `/branding/favicon.png` by nginx. The frontend `<link rel="icon">` is
set dynamically by `initBranding()`:
```js
// If branding favicon exists, use it; otherwise keep built-in default
document.querySelector('link[rel="icon"]').href = '/branding/favicon.png';
```
**Recommendations:**
- PNG format (modern browsers prefer it over ICO)
- 32×32 minimum, 192×192 recommended (covers PWA + high-DPI)
- Transparent background works best with dark themes
---
## logo.png — Static Asset
Served at `/branding/logo.png`. Used in:
- Splash page hero (replaces the 🔀 emoji)
- Sidebar header (optional, depends on size)
**Recommendations:**
- PNG with transparency
- Max 512×512 (larger files are wasteful; displayed at ~80px on splash)
- Aspect ratio: square or landscape (tall logos will be clamped)
---
## custom.css — Style Extension
Loaded *after* the main stylesheet:
```html
<link rel="stylesheet" href="/branding/custom.css" onerror="this.remove()">
```
The `onerror` handler silently removes the tag if the file doesn't exist (no 404
console noise in unbrandeded deployments).
**What you can do:**
- Override `--accent-color` and any other CSS custom property
- Change fonts (`@import` or `@font-face` with files in `/branding/`)
- Add background textures or patterns
- Hide elements you don't want (`display: none`)
- Override specific component styles
**What you shouldn't do:**
- Rely on internal class names that may change between versions
- Override layout properties (`flex`, `grid`) unless you're testing against the current release
- Import external resources (breaks airgapped deployments)
**Example:**
```css
:root {
--accent-color: #e74c3c;
--bg-primary: #1a1a2e;
}
.splash-logo img {
border-radius: 50%;
}
```
---
## Frontend Touchpoints
These are the DOM elements and CSS properties that branding affects:
| Element / Property | Default Value | Branding Source |
|--------------------------|----------------------------------|-----------------------|
| `<title>` | `Chat Switchboard` | `org_name` |
| `.brand-text` | `Chat Switchboard` | `org_name` |
| `.hero-wordmark` | `Chat Switchboard` | `org_name` |
| `.hero-headline` | `One interface. Every AI model.` | `headline` |
| `.hero-sub` | (default description) | `tagline` |
| `.splash-logo` | SVG switchboard icon | `logo.png``<img>` |
| `link[rel="icon"]` | `favicon-32.png` | `favicon.png` |
| `--accent-color` | `#4a9eff` | `accent_color` |
| `.auth-card-header h2` | `Welcome back` | `Welcome to {org_name}` |
| `.auth-card-header p` | `Sign in to continue to your workspace` | `Sign in to continue` |
| `.auth-footer p` | `Self-hosted AI chat...` | `tagline` |
| `.hero-features` | Switchboard feature pills | `pills` array or `[]` to hide |
---
## Nginx Configuration
The frontend entrypoint adds a branding location block. For path-based deployments,
the block is under `BASE_PATH` so Traefik routes requests to the correct pod:
```nginx
# Root deployment (no BASE_PATH):
location /branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
# Path-based deployment (e.g. /dev, /test):
location ${BASE_PATH}/branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
```
Frontend JS resolves paths via `window.__BASE__ + '/branding/'` so URLs
automatically include the environment prefix.
Short cache (1h) so branding updates via ConfigMap rollout are picked up
without requiring users to hard-refresh.
---
## K8s Deployment
The frontend deployment mounts the branding ConfigMap:
```yaml
spec:
containers:
- name: frontend
volumeMounts:
- name: branding
mountPath: /branding
readOnly: true
volumes:
- name: branding
configMap:
name: switchboard-branding
optional: true # ← app works without it
```
The `optional: true` is critical — Switchboard deploys cleanly with zero
branding config. The `switchboard-gobha-ai` repo (or any deployer's
equivalent) creates this ConfigMap.
---
## Deployer Repo Structure (Example: switchboard-gobha-ai)
```
switchboard-gobha-ai/
├── branding/
│ ├── branding.json
│ ├── favicon.png
│ ├── logo.png
│ └── custom.css
├── k8s/
│ └── configmap.yaml
├── README.md
└── .gitea/
└── workflows/
└── deploy.yaml
```
**configmap.yaml:**
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: switchboard-branding
namespace: ${NAMESPACE}
data:
branding.json: |
{
"org_name": "Gobha.ai",
"tagline": "Your tagline",
"accent_color": "#4a9eff"
}
binaryData:
favicon.png: <base64-encoded>
logo.png: <base64-encoded>
custom.css: <base64-encoded-or-use-data>
```
Note: For binary files in ConfigMaps, use `binaryData` with base64 encoding.
Alternatively, use a script that creates the ConfigMap from files:
```sh
kubectl create configmap switchboard-branding \
--from-file=branding/branding.json \
--from-file=branding/favicon.png \
--from-file=branding/logo.png \
--from-file=branding/custom.css \
--dry-run=client -o yaml | kubectl apply -f -
```
---
## Backend Integration
The backend participates in branding in two ways:
1. **Startup seed** (optional, future): If the backend also mounts `/branding/`,
it can read `branding.json` and upsert into `global_settings` alongside the
admin bootstrap. This enables admin-panel overrides without redeployment.
2. **Public settings**: The `branding` key is added to `publicSettingKeys`,
making it available to non-admin users via `GET /api/v1/settings/public`.
For 0.7.3, the frontend reads branding from the static mount at init time.
Backend DB seeding is a future enhancement for admin-panel editing.
---
## Graceful Degradation
| Condition | Behavior |
|----------------------------------|-----------------------------------------------|
| No `/branding/` mount | All defaults. App looks like stock Switchboard |
| Mount exists, no `branding.json` | Static assets served, no text/color overrides |
| `branding.json` missing fields | Each field falls back to its default |
| `logo` field set, file missing | `<img>` gets 404, `onerror` shows emoji fallback |
| `custom.css` missing | `<link>` tag self-removes via `onerror` |
| `favicon.png` missing | Built-in favicon remains |

View File

@@ -1,5 +1,5 @@
# ============================================
# Chat Switchboard v0.5 - Unified Dockerfile
# Chat Switchboard Unified Dockerfile
# ============================================
# Stage 1: Build Go backend
# Stage 2: Download JS vendor libs (marked, DOMPurify)

View File

@@ -2,7 +2,7 @@
**Version:** 0.1 draft
**Status:** Design
**Applies to:** v0.6.x+
**Applies to:** v0.7.x+
**Companion to:** ARCHITECTURE.md (core services + terminology)
---
@@ -388,7 +388,7 @@ The cost tracker might show different metrics in editor mode vs chat mode.
### 6.4 Jeff's Planned Modes
**Chat Mode** (core — v0.5.x)
**Chat Mode** (core)
- What exists today, plus tool calling and richer message types.
- The "default surface" that everything else builds on.
@@ -572,7 +572,7 @@ publish to the server when something needs persistence or notification.
## 9. Implementation Roadmap
### Phase A: Foundation (v0.6.0)
### Phase A: Foundation
- [ ] `extensions.js` — loader, registry, scoped context
- [ ] `Extensions.register()` API with permission enforcement
- [ ] Manifest format parser and validator
@@ -580,7 +580,7 @@ publish to the server when something needs persistence or notification.
- [ ] Asset serving endpoint
- [ ] Extension settings UI in Settings modal
### Phase B: Browser Tools (v0.6.1)
### Phase B: Browser Tools
- [ ] `ctx.tools.handle()` API for browser tool registration
- [ ] Tool schema collection in completion handler
- [ ] `tool.call.*` / `tool.result.*` WebSocket routing
@@ -588,19 +588,19 @@ publish to the server when something needs persistence or notification.
- [ ] Tool execution in EventBus `WaitFor` pattern
- [ ] Tool use display in chat messages
### Phase C: Surfaces (v0.6.2)
### Phase C: Surfaces
- [ ] Surface registration and activation API
- [ ] Mode selector in sidebar
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management
- [ ] `surface.activated` / `surface.deactivated` events
### Phase D: First Extensions (v0.7.0)
### Phase D: First Extensions
- [ ] Cost tracker (browser, proof of concept)
- [ ] Slash commands (browser, message transforms)
- [ ] Custom renderers (browser, mermaid/latex/etc)
- [ ] Editor mode (browser, with git provider tools)
### Phase E: Server-Side Tiers (v0.8.0)
### Phase E: Server-Side Tiers
- [ ] Starlark runtime integration
- [ ] Sidecar HTTP tool protocol
- [ ] Server-side tool execution in completion handler

View File

@@ -1,96 +0,0 @@
# Message Editing & Forking — Implementation
**Version:** 0.7.x (Phase 2 per ARCHITECTURE.md §8)
**Depends on:** parent_id (006), channel_cursors (008) — both deployed
---
## Status: Complete (backend + frontend)
### Backend (already existed)
- [x] Migration 013 (`deleted_at`, `sibling_index`)
- [x] `tree.go``getActivePath`, `getActiveLeaf`, `getPathToLeaf`, `getSiblings`,
`findLeafFromMessage`, `nextSiblingIndex`, `updateCursor`
- [x] `completion.go``loadConversation` walks active path via recursive CTE,
`persistMessage` is cursor-aware with sibling_index, returns new message ID
- [x] `messages.go``GetActivePath`, `EditMessage`, `Regenerate` (streaming + sibling),
`UpdateCursor`, `ListSiblings`
- [x] Routes wired in `main.go`
### Frontend (new work)
- [x] `api.js``getActivePath`, `editMessage`, `streamRegenerate`, `updateCursor`, `listSiblings`
- [x] `app.js` — tree-aware message flow:
- `selectChat` uses `/path` endpoint
- `sendMessage` reloads path after streaming for server-authoritative IDs
- `regenerateMessage(id)` creates sibling via message-specific endpoint
- `editMessage(id)` → inline edit → `submitEdit` → sibling + completion
- `switchSibling(id, direction)` navigates branches via cursor update
- `reloadActivePath()` refreshes from server after every mutation
- Legacy `regenerate()` button delegates to `regenerateMessage(lastAssistantId)`
- [x] `ui.js`:
- `_messageHTML` renders 1/3 branch indicators at fork points
- Edit button on user messages, Regen button on assistant messages
- `showEditInline` — textarea with Ctrl+Enter submit, Escape cancel
- [x] `styles.css``.branch-nav`, `.branch-arrow`, `.branch-pos`,
`.msg-edit-input`, `.msg-edit-actions`, `.msg-edit-cancel`, `.msg-edit-submit`
---
## API Endpoints
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/channels/:id/path` | Active path with sibling metadata |
| `POST` | `/channels/:id/messages/:msgId/edit` | Create sibling user message |
| `POST` | `/channels/:id/messages/:msgId/regenerate` | Create sibling assistant response (SSE) |
| `PUT` | `/channels/:id/cursor` | Switch active branch |
| `GET` | `/channels/:id/messages/:msgId/siblings` | List siblings at a fork point |
---
## Design Decisions
1. **`sibling_index` column** — explicit integer, not derived from `created_at`. Tree is self-describing.
2. **New `/path` endpoint**`ListMessages` retained for admin/debug. Frontend uses `/path` exclusively.
3. **Two-step edit** — API: edit creates sibling, completion is separate. Frontend chains them.
4. **Uniform arrows** — same UI regardless of how fork was created (edit, regen, explicit).
5. **Message-specific regenerate** — replaces old channel-level stub. Bottom-bar button delegates.
6. **Branch switch → leaf** — clicking mid-tree sibling walks to deepest descendant via `findLeafFromMessage`.
7. **Path reload after every mutation**`reloadActivePath()` called post-send, edit, regen, abort.
---
## User Flows
### Edit a message
1. Hover user message → click **Edit**
2. Textarea replaces message text (Ctrl+Enter to submit, Escape to cancel)
3. Submit → `POST /messages/:id/edit` (creates sibling) → `POST /chat/completions` (new response)
4. Branch indicator appears: 1/2
5. Old branch preserved, navigable via arrows
### Regenerate a response
1. Hover assistant message → click **Regen** (or bottom-bar ↻ button)
2. `POST /messages/:id/regenerate` streams new response as sibling
3. Branch indicator appears: 1/2
4. Original response preserved, navigable via arrows
### Navigate branches
1. Click or on any fork point
2. `GET /messages/:id/siblings` → pick adjacent → `PUT /cursor` with sibling ID
3. Backend walks to leaf, returns new path
4. Entire conversation below fork point updates to show selected branch
---
## Testing Checklist
- [ ] Existing linear conversations load and render correctly (backward compat)
- [ ] New messages in linear conversation work as before
- [ ] Regen creates sibling, shows 1/2 , original preserved
- [ ] Multiple regens show 1/3 , all navigable
- [ ] Edit creates sibling user message + triggers new completion
- [ ] Branch arrows navigate correctly (← older, → newer)
- [ ] Context sent to LLM is active path only (no cross-branch pollution)
- [ ] Cursor persists across page reload (reopening chat shows last-viewed branch)
- [ ] Mobile: edit textarea usable, branch arrows tappable

View File

@@ -29,9 +29,8 @@ Longer explanation if needed. Closes #XX
## Current Priorities
1. WebSocket hub (blocks Channels)
2. Channels backend + frontend
3. Plugin architecture design
4. Notes & Knowledge Base
1. UX polish (chat search, keyboard shortcuts, PWA)
2. Teams + RBAC (0.8.0)
3. Audit + Usage tracking (0.8.x)
See [ROADMAP.md](ROADMAP.md) for detail.

View File

@@ -10,7 +10,7 @@ minor = add features (deprecate, don't remove), patch = fix something.
---
## Current State: v0.7.0
## Current State: v0.7.3
### ✅ Done
@@ -42,6 +42,12 @@ minor = add features (deprecate, don't remove), patch = fix something.
- [x] User + admin provider model listing with capabilities
- [x] Model presets (named wrappers: global/personal scope, system prompt, temp, max_tokens)
- [x] Preset unwrap in completion handler (transparent to provider)
- [x] Tool framework: registry, type system, execution loop (tool→result→model)
- [x] Note tools: note_create, note_search, note_update, note_list
- [x] Notes API: CRUD, full-text search (tsvector), folder listing
- [x] Provider tool calling: OpenAI + Anthropic function calling in request/response/stream
- [x] Message tree: edit creates sibling, regenerate creates sibling, cursor tracking
- [x] Avatar system: user + preset avatars, server-side resize to 128×128 PNG
**Frontend (Vanilla JS)**
- [x] Professional splash page (split-panel hero + tabbed auth)
@@ -68,6 +74,11 @@ minor = add features (deprecate, don't remove), patch = fix something.
- [x] Appearance settings tab (UI scale, message font size)
- [x] Mobile responsive layout (hamburger menu, sidebar overlay, dvh)
- [x] Model/preset name in message headers (replaces generic "Assistant")
- [x] Notes modal: list/detail views, folder sidebar, create/edit/delete, search
- [x] Message editing + forking: inline edit, regen, branch navigation 1/2
- [x] Conversation path: active-path context assembly, cursor-aware
- [x] White-label branding: volume mount, org name, logo, favicon, accent color, pills
- [x] Avatar system: user profile upload, preset avatars, avatar in messages/sidebar/dropdown
**CI/CD (Gitea Actions)**
- [x] Three-env pipeline (dev/test/prod)
@@ -106,26 +117,24 @@ org-wide presets, users create personal ones (if user providers are enabled).
White-label support and UX improvements that exploit existing infrastructure.
**Branding**
- [ ] Admin branding settings in global_settings: org name, tagline, accent color
- [ ] `/branding/` volume mount (K8s ConfigMap, optional) for favicon, logo
- [ ] Splash page reads branding config on load, falls back to Switchboard defaults
- [ ] CSS accent color override from branding settings
- [x] Admin branding settings in global_settings: org name, tagline, accent color
- [x] `/branding/` volume mount (K8s ConfigMap, optional) for favicon, logo
- [x] Splash page reads branding config on load, falls back to Switchboard defaults
- [x] CSS accent color override from branding settings
**Profile Pictures / Avatars**
- [ ] `avatar` column on `users` table (base64 PNG, capped ~128x128, nullable)
- [ ] `preset_avatar` column on `model_presets` (alongside existing `icon` emoji field)
- [ ] Avatar upload in user Settings → Profile section (crop/resize client-side)
- [ ] Admin preset form: optional avatar upload (or keep emoji-only)
- [ ] `UI.avatar(msg)` helper: returns `<img>` if avatar set, emoji fallback otherwise
- [ ] Replace hardcoded 👤/🤖 in `_messageHTML`, `streamResponse`, sidebar user area
- [ ] Avatar displayed in message headers, chat list, user flyout
- [ ] Storage: base64 in DB keeps backups self-contained (airgap-friendly)
- [x] `avatar_url` column on `users` table (already existed, now wired)
- [x] `avatar` column on `model_presets` (migration 015)
- [x] Avatar upload in user Settings → Profile section (server-side resize to 128×128)
- [x] Admin/user preset avatar upload endpoint
- [x] `avatarHTML()` helper: returns `<img>` if avatar set, emoji fallback otherwise
- [x] Avatar displayed in message headers, stream, typing indicator, sidebar user area
**Message Editing + Forking**
- [ ] Edit message → creates sibling (uses existing parent_id tree)
- [ ] Regenerate → creates sibling model response
- [ ] Branch indicator at fork points (← 1/2 →)
- [ ] Context assembly follows active path, not full channel history
- [x] Edit message → creates sibling (uses existing parent_id tree)
- [x] Regenerate → creates sibling model response
- [x] Branch indicator at fork points (← 1/2 →)
- [x] Context assembly follows active path, not full channel history
**UX Polish**
- [ ] Chat search / filter in sidebar
@@ -191,28 +200,28 @@ Required for enterprise and compliance. Cheap to build, expensive to retrofit.
---
## 0.9.0 — Tool Execution + Notes
## ~~0.9.0 — Tool Execution + Notes~~ ✅ (pulled into 0.7.x)
The tool-calling infrastructure that everything downstream depends on.
Pulled forward and shipped ahead of schedule. See TOOLS_IMPL.md and FORKING_IMPL.md.
**Tool Framework**
- [ ] Tool calling pipeline in completion handler
- [ ] Tool registry (built-in + future plugin tools)
- [ ] Tool execution loop: model requests tool → backend executes → result fed back
- [x] Tool calling pipeline in completion handler (OpenAI + Anthropic)
- [x] Tool registry (built-in + future plugin tools)
- [x] Tool execution loop: model requests tool → backend executes → result fed back
- [ ] Tool permission model (which tools enabled per preset/channel)
**Notes**
- [ ] `notes` table: title, content, folder_id, tags, team_id (nullable), created_by
- [ ] Notes CRUD endpoints
- [ ] `note_create`, `note_update`, `note_search`, `note_list` tools
- [ ] Full-text search (PostgreSQL `tsvector`)
- [ ] Markdown editor in frontend
- [ ] Team-scoped notes (schema ready from 0.8.0)
- [x] `notes` table: title, content, folder, tags, source_channel_id, full-text search
- [x] Notes CRUD endpoints + search
- [x] `note_create`, `note_update`, `note_search`, `note_list` tools
- [x] Full-text search (PostgreSQL `tsvector` + `ts_rank` + `ts_headline`)
- [x] Notes UI: modal with list/detail views, folder sidebar, Markdown editor
- [ ] Team-scoped notes (awaits 0.8.0 teams)
**Conversation Forking UI**
- [ ] Edit-and-resubmit creates siblings (tree structure from 0.6.0 schema)
- [ ] Branch indicator ← 1/2 → at fork points
- [ ] Context assembly follows active path
- [x] Edit-and-resubmit creates siblings (tree structure)
- [x] Branch indicator ← 1/2 → at fork points
- [x] Context assembly follows active path
## 0.9.x — Context Management
@@ -228,7 +237,7 @@ Usability before compaction exists — long conversations shouldn't silently fai
## 0.10.0 — Web Search + URL Fetch
First real external tools, built on 0.9.0 framework.
First external tools, using the tool framework shipped in 0.7.x.
- [ ] `web_search` tool: search provider abstraction (DuckDuckGo, SearXNG, Brave)
- [ ] `url_fetch` tool: retrieve and extract content from URLs
@@ -268,14 +277,14 @@ storage, metadata and search indexes live in PostgreSQL.
- [ ] Paste-to-upload (clipboard image support)
*Note: media generation (image gen, video models) is a separate concern —
those are tool-use actions that depend on the tool framework (0.9.0) and
those are tool-use actions that depend on the tool framework and
produce attachments as output. Tracked under Future.*
---
## 0.12.0 — @mention Routing + Multi-model
The channel schema exists from 0.6.0. This phase adds the routing logic.
The channel schema already supports multiple models. This phase adds the routing logic.
- [ ] @mention parsing in messages (users and AI models)
- [ ] Resolve mentions against `channel_models`
@@ -292,7 +301,7 @@ The channel schema exists from 0.6.0. This phase adds the routing logic.
- [ ] `knowledge_bases` table: name, description, team_id (nullable), created_by
- [ ] KB document storage via 0.11.0 storage backend (same S3/PVC abstraction)
- [ ] KB CRUD endpoints + admin UI
- [ ] `kb_search` tool (built on 0.9.0 tool framework)
- [ ] `kb_search` tool (uses existing tool framework)
- [ ] Context injection in completion flow
- [ ] Notes get embedded too (once pipeline exists)
- [ ] Team admins manage team KBs (permission layer from 0.8.0)
@@ -391,7 +400,7 @@ could pull left based on need.
## Extension / Plugin Architecture
**Deferred to post-1.0.** The tool execution framework (0.9.0) provides the
**Deferred to post-1.0.** The tool execution framework (0.7.x) provides the
internal hook points. A formal plugin API, manifest format, and marketplace
are tracked in the dedicated design documents:

View File

@@ -1,102 +0,0 @@
# Notes & Tool Execution Framework — Implementation
**Version:** 0.7.2 (Phase 2 per ARCHITECTURE.md §10)
**Depends on:** channels (006), messages with tree (013) — both deployed
---
## Status: Complete
### Migration
- [x] `014_notes.sql` — notes table, tsvector full-text search, folder/tag indexes, auto-update trigger
### Notes API (handlers/notes.go)
- [x] `POST /notes` — create note with title, content, folder, tags, source_channel_id
- [x] `GET /notes` — list with folder/tag filter, pagination
- [x] `GET /notes/:id` — get single note
- [x] `PUT /notes/:id` — update with replace/append/prepend modes
- [x] `DELETE /notes/:id` — delete
- [x] `GET /notes/search?q=` — full-text search with ts_rank + ts_headline
- [x] `GET /notes/folders` — list distinct folder paths with counts
### Tool Type System (tools/types.go)
- [x] `ToolDef` — name, description, JSON Schema parameters (sent to LLM)
- [x] `ToolCall` — ID, name, arguments (from LLM response)
- [x] `ToolResult` — tool_call_id, name, content, is_error (fed back)
- [x] `Tool` interface — `Definition()` + `Execute(ctx, execCtx, argsJSON)`
- [x] `ExecutionContext` — userID, channelID for scoped execution
- [x] JSON Schema helpers: `Prop`, `PropEnum`, `PropArray`, `JSONSchema`
### Tool Registry (tools/registry.go)
- [x] `Register(Tool)` — global registry, called from init()
- [x] `Get(name)` — lookup by name
- [x] `AllDefinitions()` — all registered tool schemas for LLM requests
- [x] `ExecuteCall()` — runs a single tool, returns ToolResult (never errors)
- [x] `ExecuteAll()` — batch execution
- [x] `HasTools()` — check if any tools registered
### Note Tools (tools/notes.go)
- [x] `note_create` — create with title, content, folder, tags; links source_channel_id
- [x] `note_search` — full-text search with relevance ranking and excerpts
- [x] `note_update` — update by note_id with replace/append/prepend modes
- [x] `note_list` — list by folder/tag filter
- All registered via init()
### Provider Tool Calling Support
- [x] `providers.Message` — extended with ToolCalls, ToolCallID, Name fields
- [x] `providers.CompletionRequest` — Tools field for function definitions
- [x] `providers.CompletionResponse` — ToolCalls field for function calls
- [x] `providers.StreamEvent` — ToolCalls accumulated on final event
- [x] OpenAI provider — full tool_calls in request/response/stream
- [x] Anthropic provider — tool_use/tool_result content blocks, input_json_delta streaming, consecutive user message merging
- [x] OpenRouter — inherits from OpenAI (delegation)
- [x] Venice — inherits from OpenAI (delegation)
### Completion Handler Tool Loop (handlers/completion.go)
- [x] `buildToolDefs()` — converts registered tools to provider format
- [x] Tools attached when `caps.ToolCalling && tools.HasTools()`
- [x] Streaming: tool loop with max 10 iterations
- Stream text deltas normally
- On `finish_reason: "tool_calls"`: execute tools, send SSE events
- Custom SSE events: `event: tool_use` and `event: tool_result`
- Re-call provider with tool results appended to messages
- Final text response streamed normally
- [x] Non-streaming: same loop, tools executed synchronously
- [x] Safety: `maxToolIterations = 10` prevents infinite loops
---
## SSE Events for Tool Execution
The streaming completion sends custom named SSE events during tool execution.
Standard data events continue to carry OpenAI-compatible content deltas.
| Event | Payload | When |
|-------|---------|------|
| `tool_use` | `[{"id":"...","type":"function","function":{"name":"note_create","arguments":"..."}}]` | LLM requests tool calls |
| `tool_result` | `{"tool_call_id":"...","name":"note_create","content":"...","is_error":false}` | Tool execution complete |
The frontend can listen for these to show tool execution status.
Existing frontends that only handle `data:` events are unaffected.
---
## Design Decisions
1. **Separate packages**`tools/` is independent of `handlers/`. Tools don't know about HTTP. The completion handler bridges them.
2. **init() registration** — Tools self-register. Adding a new tool = create file with init(), done. No central wiring.
3. **JSON Schema parameters** — Tool definitions use standard JSON Schema, matching OpenAI's function calling spec.
4. **Non-streaming tool loop** — After tool execution, re-calls use streaming. Each iteration streams text deltas.
5. **Anthropic content blocks** — Full translation: `tool_use` blocks, `tool_result` as user messages, consecutive user message merging for alternating-role requirement.
6. **Folder paths** — Normalized to `/path/` format. Root is `/`. Tree structure without a folders table.
7. **Search** — PostgreSQL tsvector with weighted ranks (title=A, content=B). No external search infrastructure needed.
---
## Frontend Work
- [x] Notes panel/modal in UI
- [x] Notes CRUD forms (create, edit, list, search)
- [x] Tool execution indicators during streaming
- [x] Parse `event: tool_use` / `event: tool_result` SSE events
- [x] Display tool activity in message bubbles

View File

@@ -1 +1 @@
0.7.2
0.7.3

View File

@@ -26,11 +26,22 @@ else
BASE_HREF="${BASE_PATH}/"
fi
# ── Read branding config ────────────────────
BRANDING_JSON="{}"
if [ -f /branding/branding.json ]; then
# Compact to single line for safe sed injection
BRANDING_JSON=$(tr -d '\n' < /branding/branding.json | sed 's/ */ /g')
echo "✅ Branding config loaded from /branding/branding.json"
else
echo " No branding mount — using defaults"
fi
# ── Inject into index.html ──────────────────
sed -i \
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
-e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
/usr/share/nginx/html/index.html
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION}"
@@ -57,6 +68,14 @@ server {
add_header Cache-Control "public, immutable";
}
# Branding assets — served from volume mount, 404s gracefully
location /branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
@@ -94,6 +113,14 @@ server {
}
}
# Branding assets — under BASE_PATH so Traefik routes them here
location ${BASE_PATH}/branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files \$uri =404;
}
# Redirect bare path to trailing slash
location = ${BASE_PATH} {
return 301 ${BASE_PATH}/;

View File

@@ -38,6 +38,10 @@ spec:
env:
- name: BASE_PATH
value: "${BASE_PATH}"
volumeMounts:
- name: branding
mountPath: /branding
readOnly: true
resources:
requests:
memory: "${FE_MEMORY_REQUEST}"
@@ -61,6 +65,11 @@ spec:
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
volumes:
- name: branding
configMap:
name: switchboard-branding
optional: true
---
apiVersion: v1
kind: Service

View File

@@ -1,29 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@@ -0,0 +1,6 @@
-- Avatar support for model presets.
-- Users table already has avatar_url from 001_full_schema.sql.
ALTER TABLE model_presets ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT '';
COMMENT ON COLUMN model_presets.avatar IS 'Base64 data URI of preset avatar image (128x128 PNG), empty = use icon emoji';

View File

@@ -283,6 +283,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
var publicSettingKeys = map[string]bool{
"banner": true,
"branding": true,
"user_providers_enabled": true,
"registration_enabled": true,
"registration_default_state": true,

View File

@@ -435,6 +435,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
IsPreset bool `json:"is_preset,omitempty"`
PresetID string `json:"preset_id,omitempty"`
PresetScope string `json:"preset_scope,omitempty"`
PresetAvatar string `json:"preset_avatar,omitempty"`
}
models := make([]enabledModel, 0)
@@ -538,7 +539,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
// ── 3. Active presets (global + user's personal + shared) ──
presetRows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.icon, mp.scope, mp.temperature, mp.max_tokens,
mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens,
COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
@@ -553,12 +554,12 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
if err == nil {
defer presetRows.Close()
for presetRows.Next() {
var presetID, name, description, baseModelID, icon, scope, provID, provName string
var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName string
var apiConfigID *string
var temp *float64
var maxTok *int
if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID,
&icon, &scope, &temp, &maxTok, &provID, &provName); err != nil {
&icon, &avatar, &scope, &temp, &maxTok, &provID, &provName); err != nil {
continue
}
@@ -615,6 +616,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
IsPreset: true,
PresetID: presetID,
PresetScope: scope,
PresetAvatar: avatar,
})
}
}

View File

@@ -64,6 +64,7 @@ type userResponse struct {
Email string `json:"email"`
DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"`
}
// AuthHandler holds dependencies for auth endpoints.
@@ -129,9 +130,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role
RETURNING id, username, email, display_name, role, avatar_url
`, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar,
)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
@@ -268,12 +269,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
var isActive bool
err := database.DB.QueryRow(`
SELECT id, username, email, display_name, role, password_hash, is_active
SELECT id, username, email, display_name, role, avatar_url, password_hash, is_active
FROM users
WHERE email = $1 OR username = $1
`, req.Login).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName,
&user.Role, &passwordHash, &isActive,
&user.Role, &user.Avatar, &passwordHash, &isActive,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
@@ -352,10 +353,10 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
var user userResponse
var isActive bool
err = database.DB.QueryRow(`
SELECT id, username, email, display_name, role, is_active
SELECT id, username, email, display_name, role, avatar_url, is_active
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &isActive,
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar, &isActive,
)
if err != nil || !isActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})

245
server/handlers/avatar.go Normal file
View File

@@ -0,0 +1,245 @@
package handlers
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"net/http"
"strings"
// Register decoders so image.Decode works for common formats
_ "image/gif"
_ "image/jpeg"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
const avatarSize = 128
const maxUploadBytes = 2 * 1024 * 1024 // 2MB raw input limit
type uploadAvatarRequest struct {
Image string `json:"image" binding:"required"` // base64 data URI or raw base64
}
// ── Upload Avatar ───────────────────────────
// POST /api/v1/profile/avatar
// Accepts { "image": "data:image/png;base64,..." } or { "image": "<raw base64>" }
// Decodes, resizes to 128×128 PNG, stores as data URI.
func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
userID := getUserID(c)
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
// Strip data URI prefix if present
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
// Decode base64
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
// Decode image (supports PNG, JPEG, GIF via registered decoders)
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
// Resize to 128×128
resized := resizeBilinear(src, avatarSize, avatarSize)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
// Build data URI
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
// Store in DB
_, err = database.DB.Exec(
`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`,
dataURI, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// ── Delete Avatar ───────────────────────────
// DELETE /api/v1/profile/avatar
func (h *SettingsHandler) DeleteAvatar(c *gin.Context) {
userID := getUserID(c)
_, err := database.DB.Exec(
`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = $1`,
userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}
// ── Bilinear Resize ─────────────────────────
// Pure stdlib bilinear interpolation. Quality is fine for 128×128 avatars.
func resizeBilinear(src image.Image, w, h int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
sb := src.Bounds()
sw := float64(sb.Dx())
sh := float64(sb.Dy())
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// Map destination pixel to source coordinates
sx := (float64(x) + 0.5) * sw / float64(w) - 0.5
sy := (float64(y) + 0.5) * sh / float64(h) - 0.5
x0 := int(sx)
y0 := int(sy)
xf := sx - float64(x0)
yf := sy - float64(y0)
// Clamp
if x0 < sb.Min.X { x0 = sb.Min.X }
if y0 < sb.Min.Y { y0 = sb.Min.Y }
x1 := x0 + 1
y1 := y0 + 1
if x1 >= sb.Max.X { x1 = sb.Max.X - 1 }
if y1 >= sb.Max.Y { y1 = sb.Max.Y - 1 }
// Sample 4 neighbors
c00 := src.At(x0, y0)
c10 := src.At(x1, y0)
c01 := src.At(x0, y1)
c11 := src.At(x1, y1)
dst.Set(x, y, bilinearMix(c00, c10, c01, c11, xf, yf))
}
}
return dst
}
func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
r00, g00, b00, a00 := c00.RGBA()
r10, g10, b10, a10 := c10.RGBA()
r01, g01, b01, a01 := c01.RGBA()
r11, g11, b11, a11 := c11.RGBA()
mix := func(v00, v10, v01, v11 uint32) uint8 {
top := float64(v00)*(1-xf) + float64(v10)*xf
bot := float64(v01)*(1-xf) + float64(v11)*xf
return uint8((top*(1-yf) + bot*yf) / 256)
}
return color.RGBA{
R: mix(r00, r10, r01, r11),
G: mix(g00, g10, g01, g11),
B: mix(b00, b10, b01, b11),
A: mix(a00, a10, a01, a11),
}
}
// ── Preset Avatar Upload ────────────────────
// POST /api/v1/presets/:id/avatar (user) or /api/v1/admin/presets/:id/avatar (admin)
func UploadPresetAvatar(c *gin.Context) {
presetID := c.Param("id")
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
resized := resizeBilinear(src, avatarSize, avatarSize)
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
result, err := database.DB.Exec(
`UPDATE model_presets SET avatar = $1, updated_at = NOW() WHERE id = $2`,
dataURI, presetID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// DeletePresetAvatar clears a preset's avatar.
func DeletePresetAvatar(c *gin.Context) {
presetID := c.Param("id")
result, err := database.DB.Exec(
`UPDATE model_presets SET avatar = '', updated_at = NOW() WHERE id = $1`,
presetID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}

View File

@@ -67,7 +67,7 @@ func (h *PresetHandler) ListUserPresets(c *gin.Context) {
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.created_at, mp.updated_at,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
@@ -92,7 +92,7 @@ func (h *PresetHandler) ListUserPresets(c *gin.Context) {
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
); err != nil {
continue
}
@@ -281,7 +281,7 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.created_at, mp.updated_at,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name,
COALESCE(u.username, '') as creator_name
FROM model_presets mp
@@ -307,7 +307,7 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName,
); err != nil {
continue
}

View File

@@ -30,6 +30,7 @@ type profileResponse struct {
Email string `json:"email"`
DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"`
Settings map[string]interface{} `json:"settings"`
CreatedAt string `json:"created_at"`
}
@@ -50,11 +51,11 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
var p profileResponse
var settingsRaw string
err := database.DB.QueryRow(`
SELECT id, username, email, display_name, role, settings::text, created_at
SELECT id, username, email, display_name, role, avatar_url, settings::text, created_at
FROM users WHERE id = $1
`, userID).Scan(
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
&settingsRaw, &p.CreatedAt,
&p.Avatar, &settingsRaw, &p.CreatedAt,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})

View File

@@ -134,6 +134,8 @@ func main() {
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
protected.POST("/profile/avatar", settings.UploadAvatar)
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
@@ -143,6 +145,8 @@ func main() {
protected.POST("/presets", presets.CreateUserPreset)
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Notes
notes := handlers.NewNoteHandler()
@@ -202,6 +206,8 @@ func main() {
admin.POST("/presets", presetAdm.CreateAdminPreset)
admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset)
admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
}
}

View File

@@ -174,6 +174,7 @@ type ModelPreset struct {
IsShared bool `json:"is_shared" db:"is_shared"`
IsActive bool `json:"is_active" db:"is_active"`
Icon string `json:"icon,omitempty" db:"icon"`
Avatar string `json:"avatar,omitempty" db:"avatar"`
}
// ── Settings ────────────────────────────────

View File

@@ -276,6 +276,10 @@ a:hover { text-decoration: underline; }
background: var(--bg-raised); display: flex; align-items: center;
justify-content: center; font-size: 13px; font-weight: 600;
color: var(--accent); flex-shrink: 0; position: relative;
overflow: hidden;
}
.user-avatar-img {
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
}
.avatar-bug {
position: absolute; bottom: -3px; right: -3px;
@@ -369,6 +373,7 @@ a:hover { text-decoration: underline; }
.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); }
.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); }
.dropdown-avatar { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.model-caps {
display: flex; align-items: center; gap: 4px;
@@ -408,6 +413,10 @@ a:hover { text-decoration: underline; }
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0; margin-top: 2px;
overflow: hidden;
}
.msg-avatar-img {
width: 100%; height: 100%; object-fit: cover; border-radius: 50%;
}
.message.user .msg-avatar { background: var(--accent-dim); }
.message.assistant .msg-avatar { background: var(--bg-raised); }
@@ -725,8 +734,10 @@ button { font-family: var(--font); cursor: pointer; }
.hero-content { position: relative; z-index: 1; max-width: 520px; }
.hero-logo-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; }
.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; }
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
.hero-wordmark span { color: var(--accent); }
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; vertical-align: middle; }
.hero-headline {
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
letter-spacing: -0.03em; margin-bottom: 1rem;
@@ -808,6 +819,7 @@ button { font-family: var(--font); cursor: pointer; }
.splash-hero { padding: 1.5rem 1.25rem 1rem; }
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
.hero-logo-mark { width: 40px; height: 40px; }
.hero-logo-img { width: 40px; height: 40px; }
.hero-wordmark { font-size: 1.3rem; }
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
.hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; }
@@ -891,6 +903,17 @@ button { font-family: var(--font); cursor: pointer; }
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
.avatar-upload-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1rem; }
.avatar-preview {
width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised);
display: flex; align-items: center; justify-content: center;
font-size: 22px; font-weight: 600; color: var(--accent); flex-shrink: 0;
overflow: hidden; border: 2px solid var(--border);
}
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
.avatar-actions { display: flex; flex-direction: column; gap: 4px; }
.avatar-actions .form-hint { font-size: 11px; margin-left: 0; }
.avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; }
/* Modal tab bars — shared horizontal scroll with arrow navigation */
.modal-tabs-wrap {
@@ -1001,6 +1024,8 @@ button { font-family: var(--font); cursor: pointer; }
.admin-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
.admin-preset-row .preset-info { flex: 1; min-width: 0; }
.preset-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
.preset-row-icon { margin-right: 4px; }
.admin-preset-row .preset-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.admin-preset-row .preset-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }

View File

@@ -6,11 +6,13 @@
<base href="%%BASE_HREF%%">
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
<title>Chat Switchboard</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
</head>
<body>
@@ -25,9 +27,9 @@
<aside class="sidebar" id="sidebar">
<div class="sidebar-top">
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
<span class="brand-logo">🔀</span>
<span class="brand-logo" id="brandSidebarLogo">🔀</span>
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
<span class="sb-label brand-text">Chat Switchboard</span>
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
</button>
<div class="split-btn" style="position:relative">
<button class="split-btn-main" id="newChatBtn" title="New Chat">
@@ -147,7 +149,8 @@
<div class="splash-hero">
<div class="hero-content">
<div class="hero-logo-row">
<svg class="hero-logo-mark" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<div class="hero-logo-mark" id="brandLogo">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
@@ -155,11 +158,12 @@
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
</svg>
<div class="hero-wordmark">Chat <span>Switchboard</span></div>
</div>
<h1 class="hero-headline">One interface.<br>Every AI model.</h1>
<p class="hero-sub">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
<div class="hero-features">
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
</div>
<h1 class="hero-headline" id="brandHeadline">One interface.<br>Every AI model.</h1>
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
<div class="hero-features" id="brandPills">
<div class="hero-pill accent"><span class="pill-icon"></span> Streaming responses</div>
<div class="hero-pill purple"><span class="pill-icon">🔀</span> Multi-provider routing</div>
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
@@ -196,7 +200,7 @@
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
</div>
<div class="auth-footer">
<p>Self-hosted AI chat &middot; Your keys, your data</p>
<p id="brandAuthFooter">Self-hosted AI chat &middot; Your keys, your data</p>
</div>
</div>
</div>
@@ -217,6 +221,17 @@
<div class="settings-tab-content" id="settingsGeneralTab">
<section class="settings-section">
<h3>Profile</h3>
<div class="avatar-upload-row">
<div class="avatar-preview" id="avatarPreview">
<span id="avatarPreviewLetter">?</span>
</div>
<div class="avatar-actions">
<button class="btn-small" id="avatarUploadBtn">Upload Photo</button>
<button class="btn-small btn-danger" id="avatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
<div class="form-hint">Auto-resized to 128×128</div>
</div>
</div>
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
@@ -350,6 +365,17 @@
<div class="form-group"><label>Name</label><input type="text" id="adminPresetName" placeholder="Code Reviewer"></div>
<div class="form-group"><label>Icon</label><input type="text" id="adminPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
</div>
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
<div class="avatar-preview avatar-preview-sm" id="presetAvatarPreview">
<span id="presetAvatarPlaceholder">🤖</span>
</div>
<div class="avatar-actions">
<button class="btn-small" type="button" id="presetAvatarUploadBtn">Upload Avatar</button>
<button class="btn-small btn-danger" type="button" id="presetAvatarRemoveBtn" style="display:none">Remove</button>
<input type="file" id="presetAvatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
<div class="form-hint">Optional · 128×128 · shown in chat</div>
</div>
</div>
<div class="form-group"><label>Description</label><input type="text" id="adminPresetDesc" placeholder="Reviews code for best practices"></div>
<div class="form-row">
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>

View File

@@ -228,8 +228,19 @@ const API = {
// ── Profile & Settings ───────────────────
getProfile() { return this._get('/api/v1/profile'); },
async getProfile() {
const data = await this._get('/api/v1/profile');
// Sync user object with profile data (including avatar)
if (data && this.user) {
this.user.avatar = data.avatar || null;
this.user.display_name = data.display_name;
this.saveTokens();
}
return data;
},
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
changePassword(current, newPw) {
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
},
@@ -271,6 +282,8 @@ const API = {
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
// ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); },

View File

@@ -24,6 +24,7 @@ const App = {
async function init() {
console.log('🔀 Chat Switchboard initializing...');
initBranding(); // Apply branding before splash is visible
API.loadTokens();
let health = null;
@@ -102,6 +103,59 @@ async function saveSettings() {
} catch (e) { console.warn('Settings sync failed:', e.message); }
}
// ── Avatar Preview ──────────────────────────
function updateAvatarPreview(dataURI) {
const preview = document.getElementById('avatarPreview');
const letter = document.getElementById('avatarPreviewLetter');
const removeBtn = document.getElementById('avatarRemoveBtn');
const existingImg = preview.querySelector('img');
if (dataURI) {
letter.style.display = 'none';
if (existingImg) {
existingImg.src = dataURI;
} else {
const img = document.createElement('img');
img.src = dataURI;
img.alt = 'Avatar';
preview.insertBefore(img, letter);
}
removeBtn.style.display = '';
} else {
if (existingImg) existingImg.remove();
const name = API.user?.display_name || API.user?.username || '?';
letter.textContent = name[0].toUpperCase();
letter.style.display = '';
removeBtn.style.display = 'none';
}
}
// Preset avatar preview (admin form)
function updatePresetAvatarPreview(dataURI) {
const preview = document.getElementById('presetAvatarPreview');
const placeholder = document.getElementById('presetAvatarPlaceholder');
const removeBtn = document.getElementById('presetAvatarRemoveBtn');
if (!preview) return;
const existingImg = preview.querySelector('img');
if (dataURI) {
placeholder.style.display = 'none';
if (existingImg) {
existingImg.src = dataURI;
} else {
const img = document.createElement('img');
img.src = dataURI;
img.alt = 'Preset avatar';
preview.insertBefore(img, placeholder);
}
removeBtn.style.display = '';
} else {
if (existingImg) existingImg.remove();
placeholder.style.display = '';
removeBtn.style.display = 'none';
}
}
// ── Models ───────────────────────────────────
// Client-side known model capabilities — used when backend hasn't synced yet.
@@ -188,6 +242,7 @@ async function fetchModels() {
isPreset,
presetId: m.preset_id || null,
presetScope: m.preset_scope || null,
presetAvatar: m.preset_avatar || null,
};
});
@@ -843,6 +898,41 @@ function initListeners() {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Avatar upload
document.getElementById('avatarUploadBtn').addEventListener('click', () => {
document.getElementById('avatarFileInput').click();
});
document.getElementById('avatarFileInput').addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
const data = await API.uploadAvatar(reader.result);
if (data.avatar) {
API.user.avatar = data.avatar;
API.saveTokens();
updateAvatarPreview(data.avatar);
UI.updateUser();
UI.toast('Avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('avatarRemoveBtn').addEventListener('click', async () => {
try {
await API.deleteAvatar();
API.user.avatar = null;
API.saveTokens();
updateAvatarPreview(null);
UI.updateUser();
UI.toast('Avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
});
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
@@ -907,6 +997,7 @@ function initListeners() {
document.getElementById('adminPresetIcon').value = '';
document.getElementById('adminPresetTemp').value = '';
document.getElementById('adminPresetMaxTokens').value = '';
updatePresetAvatarPreview(null);
const f = document.getElementById('adminAddPresetForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
@@ -914,9 +1005,55 @@ function initListeners() {
document.getElementById('adminAddPresetForm').style.display = 'none';
_editingPresetId = null;
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
updatePresetAvatarPreview(null);
});
document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
// Admin — preset avatar
document.getElementById('presetAvatarUploadBtn')?.addEventListener('click', () => {
document.getElementById('presetAvatarFileInput').click();
});
document.getElementById('presetAvatarFileInput')?.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
if (!_editingPresetId) {
// For new presets: show preview, upload after create
const reader = new FileReader();
reader.onload = () => updatePresetAvatarPreview(reader.result);
reader.readAsDataURL(file);
this.value = '';
return;
}
// For existing presets: upload immediately
const reader = new FileReader();
reader.onload = async () => {
try {
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
if (data.avatar) {
updatePresetAvatarPreview(data.avatar);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('presetAvatarRemoveBtn')?.addEventListener('click', async () => {
if (!_editingPresetId) {
updatePresetAvatarPreview(null);
return;
}
try {
await API.adminDeletePresetAvatar(_editingPresetId);
updatePresetAvatarPreview(null);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
});
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
@@ -1195,6 +1332,8 @@ function editAdminPreset(id) {
if (modelSel) modelSel.value = p.base_model_id || '';
const cfgSel = document.getElementById('adminPresetConfig');
if (cfgSel) cfgSel.value = p.api_config_id || '';
// Avatar preview
updatePresetAvatarPreview(p.avatar || null);
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
}
@@ -1221,13 +1360,24 @@ async function createAdminPreset() {
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
try {
let presetId = _editingPresetId;
if (_editingPresetId) {
await API.adminUpdatePreset(_editingPresetId, preset);
UI.toast('Preset updated', 'success');
} else {
await API.adminCreatePreset(preset);
const result = await API.adminCreatePreset(preset);
presetId = result?.id || result?.preset?.id;
UI.toast('Preset created', 'success');
}
// Upload pending avatar for new presets
const pendingImg = document.querySelector('#presetAvatarPreview img');
if (!_editingPresetId && presetId && pendingImg?.src?.startsWith('data:')) {
try {
await API.adminUploadPresetAvatar(presetId, pendingImg.src);
} catch (e) { console.warn('Preset avatar upload failed:', e.message); }
}
_editingPresetId = null;
document.getElementById('adminAddPresetForm').style.display = 'none';
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
@@ -1237,6 +1387,7 @@ async function createAdminPreset() {
document.getElementById('adminPresetIcon').value = '';
document.getElementById('adminPresetTemp').value = '';
document.getElementById('adminPresetMaxTokens').value = '';
updatePresetAvatarPreview(null);
await UI.loadAdminPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
@@ -1563,6 +1714,115 @@ async function deleteNote() {
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Branding ────────────────────────────────
function initBranding() {
const b = window.__BRANDING__;
if (!b || typeof b !== 'object') return;
const brandBase = (window.__BASE__ || '') + '/branding/';
// Accent color → CSS custom property
if (b.accent_color) {
document.documentElement.style.setProperty('--accent-color', b.accent_color);
}
// Page title
if (b.org_name) {
document.title = b.org_name;
}
// Sidebar brand text
const sidebarText = document.getElementById('brandSidebarText');
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
// Splash hero wordmark
const wordmark = document.getElementById('brandWordmark');
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
// Splash headline — replace if branding provides it
if (b.headline) {
const headline = document.getElementById('brandHeadline');
if (headline) headline.textContent = b.headline;
}
// Splash tagline
const tagline = document.getElementById('brandTagline');
if (tagline && b.tagline) tagline.textContent = b.tagline;
// Auth card text
if (b.org_name) {
const authHeader = document.querySelector('.auth-card-header h2');
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
const authSub = document.querySelector('.auth-card-header p');
if (authSub) authSub.textContent = 'Sign in to continue';
}
const authFooter = document.getElementById('brandAuthFooter');
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
// Logo — replace SVG with <img> in splash hero
if (b.logo) {
const logoUrl = brandBase + b.logo;
const logoEl = document.getElementById('brandLogo');
if (logoEl) {
const img = document.createElement('img');
img.src = logoUrl;
img.alt = b.org_name || 'Logo';
img.className = 'hero-logo-img';
img.onerror = function() {
this.style.display = 'none';
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = '';
};
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = 'none';
logoEl.appendChild(img);
}
// Sidebar logo — replace emoji with small img
const sidebarLogo = document.getElementById('brandSidebarLogo');
if (sidebarLogo) {
const originalEmoji = sidebarLogo.textContent;
const img = document.createElement('img');
img.src = logoUrl;
img.alt = '';
img.className = 'brand-logo-img';
img.onerror = function() {
this.remove();
sidebarLogo.textContent = originalEmoji;
};
sidebarLogo.textContent = '';
sidebarLogo.appendChild(img);
}
}
// Favicon
if (b.favicon) {
const favUrl = brandBase + b.favicon;
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
link.href = favUrl;
});
}
// Feature pills — custom array replaces defaults, empty array hides them
if (b.pills !== undefined) {
const pillsEl = document.getElementById('brandPills');
if (pillsEl) {
if (!Array.isArray(b.pills) || b.pills.length === 0) {
pillsEl.style.display = 'none';
} else {
pillsEl.innerHTML = b.pills.map(p => {
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
}).join('');
}
}
}
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
}
// ── Banners ──────────────────────────────────
async function initBanners() {

View File

@@ -2,6 +2,26 @@
// Chat Switchboard UI
// ==========================================
// ── Avatar Helper ────────────────────────────
// Returns HTML for a message avatar: <img> if data URI available, emoji fallback.
function avatarHTML(role, avatarDataURI) {
if (avatarDataURI) {
return `<img src="${avatarDataURI}" alt="" class="msg-avatar-img">`;
}
return role === 'user' ? '👤' : '🤖';
}
// Look up the current model/preset avatar for assistant messages.
function assistantAvatarURI(msg) {
// Try to find preset avatar from the model that generated this message
const modelId = msg?.model || msg?.preset_id;
if (modelId) {
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
if (m?.presetAvatar) return m.presetAvatar;
}
return null;
}
const UI = {
// ── Sidebar ──────────────────────────────
@@ -132,7 +152,7 @@ const UI = {
return `
<div class="message ${msg.role}" data-msg-id="${msgId}">
<div class="msg-inner">
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="msg-avatar">${avatarHTML(msg.role, isUser ? API.user?.avatar : assistantAvatarURI(msg))}</div>
<div class="msg-body">
<div class="msg-head">
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
@@ -208,11 +228,13 @@ const UI = {
document.getElementById('typingIndicator')?.remove();
const label = modelName || 'Assistant';
const currentModel = App.models.find(m => m.id === App.settings.model);
const streamAvatar = currentModel?.presetAvatar || null;
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</div>
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
<div class="msg-tools" id="streamTools" style="display:none"></div>
@@ -394,7 +416,8 @@ const UI = {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
div.innerHTML = `<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown();
@@ -475,7 +498,31 @@ const UI = {
updateUser() {
const name = API.user?.display_name || API.user?.username || 'User';
const letter = (name[0] || '?').toUpperCase();
document.getElementById('avatarLetter').textContent = letter;
const avatarEl = document.getElementById('userAvatar');
const letterEl = document.getElementById('avatarLetter');
// Show avatar image or initial letter
let existingImg = avatarEl.querySelector('.user-avatar-img');
if (API.user?.avatar) {
letterEl.style.display = 'none';
if (existingImg) {
existingImg.src = API.user.avatar;
} else {
const img = document.createElement('img');
img.src = API.user.avatar;
img.alt = '';
img.className = 'user-avatar-img';
img.onerror = function() {
this.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
};
avatarEl.insertBefore(img, letterEl);
}
} else {
if (existingImg) existingImg.remove();
letterEl.style.display = '';
letterEl.textContent = letter;
}
document.getElementById('userName').textContent = name;
},
@@ -490,12 +537,14 @@ const UI = {
document.getElementById('sendBtn').disabled = on;
if (on) {
const container = document.getElementById('chatMessages');
const currentModel = App.models.find(m => m.id === App.settings.model);
const typingAvatar = currentModel?.presetAvatar || null;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
typing.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</div>
<div class="msg-avatar">${avatarHTML('assistant', typingAvatar)}</div>
<div class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
</div>`;
container.appendChild(typing);
@@ -624,6 +673,7 @@ const UI = {
const p = await API.getProfile();
document.getElementById('profileDisplayName').value = p.display_name || '';
document.getElementById('profileEmail').value = p.email || '';
updateAvatarPreview(p.avatar || null);
} catch (e) { /* optional */ }
},
@@ -805,12 +855,14 @@ const UI = {
UI._presetCache = {};
el.innerHTML = list.map(p => {
UI._presetCache[p.id] = p;
const icon = p.icon ? p.icon + ' ' : '';
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
: (p.icon ? `<span class="preset-row-icon">${p.icon}</span>` : '');
const scope = p.scope === 'global' ? '<span class="badge-admin">global</span>' : '<span class="badge-user">personal</span>';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
<div class="preset-info">
<strong>${icon}${esc(p.name)}</strong> ${scope} ${status}
<strong>${avatarEl}${esc(p.name)}</strong> ${scope} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>