Changeset 0.7.3 (#41)
This commit is contained in:
@@ -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
309
BRANDING.md
Normal 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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
81
ROADMAP.md
81
ROADMAP.md
@@ -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:
|
||||
|
||||
|
||||
102
TOOLS_IMPL.md
102
TOOLS_IMPL.md
@@ -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
|
||||
@@ -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}/;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
6
server/database/migrations/015_preset_avatars.sql
Normal file
6
server/database/migrations/015_preset_avatars.sql
Normal 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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
245
server/handlers/avatar.go
Normal 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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
<div class="hero-wordmark" id="brandWordmark">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">
|
||||
<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 · Your keys, your data</p>
|
||||
<p id="brandAuthFooter">Self-hosted AI chat · 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>
|
||||
|
||||
@@ -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'); },
|
||||
|
||||
262
src/js/app.js
262
src/js/app.js
@@ -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() {
|
||||
|
||||
66
src/js/ui.js
66
src/js/ui.js
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user