From 1ec392879be345dd24c9a09052f84a332aa1c623 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sat, 21 Feb 2026 21:59:38 +0000 Subject: [PATCH] Changeset 0.7.3 (#41) --- ARCHITECTURE.md | 22 +- BRANDING.md | 309 ++++++++++++++++++ Dockerfile | 2 +- EXTENSIONS.md | 14 +- FORKING_IMPL.md | 96 ------ ISSUES.md | 7 +- ROADMAP.md | 81 +++-- TOOLS_IMPL.md | 102 ------ VERSION | 2 +- docker-entrypoint-fe.sh | 27 ++ k8s/frontend.yaml | 9 + nginx.frontend.conf | 29 -- .../migrations/015_preset_avatars.sql | 6 + server/handlers/admin.go | 1 + server/handlers/apiconfigs.go | 8 +- server/handlers/auth.go | 13 +- server/handlers/avatar.go | 245 ++++++++++++++ server/handlers/presets.go | 8 +- server/handlers/settings.go | 5 +- server/main.go | 6 + server/models/models.go | 1 + src/css/styles.css | 25 ++ src/index.html | 42 ++- src/js/api.js | 15 +- src/js/app.js | 262 ++++++++++++++- src/js/ui.js | 66 +++- 26 files changed, 1084 insertions(+), 319 deletions(-) create mode 100644 BRANDING.md delete mode 100644 FORKING_IMPL.md delete mode 100644 TOOLS_IMPL.md delete mode 100644 nginx.frontend.conf create mode 100644 server/database/migrations/015_preset_avatars.sql create mode 100644 server/handlers/avatar.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11e69e1..35b9cd0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/BRANDING.md b/BRANDING.md new file mode 100644 index 0000000..0dd8af0 --- /dev/null +++ b/BRANDING.md @@ -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, `` | +| `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 | diff --git a/Dockerfile b/Dockerfile index 0a07d3c..1d4919d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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) diff --git a/EXTENSIONS.md b/EXTENSIONS.md index e495603..0ba8e26 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -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 diff --git a/FORKING_IMPL.md b/FORKING_IMPL.md deleted file mode 100644 index 5de14f3..0000000 --- a/FORKING_IMPL.md +++ /dev/null @@ -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 diff --git a/ISSUES.md b/ISSUES.md index 170ea20..138cea0 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 2b4875e..187bcee 100644 --- a/ROADMAP.md +++ b/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: diff --git a/TOOLS_IMPL.md b/TOOLS_IMPL.md deleted file mode 100644 index 4bab1c1..0000000 --- a/TOOLS_IMPL.md +++ /dev/null @@ -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 diff --git a/VERSION b/VERSION index 7486fdb..f38fc53 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.2 +0.7.3 diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh index 0d2d18c..e785d96 100644 --- a/docker-entrypoint-fe.sh +++ b/docker-entrypoint-fe.sh @@ -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}/; diff --git a/k8s/frontend.yaml b/k8s/frontend.yaml index 6fe3921..53a81ce 100644 --- a/k8s/frontend.yaml +++ b/k8s/frontend.yaml @@ -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 diff --git a/nginx.frontend.conf b/nginx.frontend.conf deleted file mode 100644 index 813757c..0000000 --- a/nginx.frontend.conf +++ /dev/null @@ -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; -} diff --git a/server/database/migrations/015_preset_avatars.sql b/server/database/migrations/015_preset_avatars.sql new file mode 100644 index 0000000..be40fb1 --- /dev/null +++ b/server/database/migrations/015_preset_avatars.sql @@ -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'; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 92ef6e9..9be9d76 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -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, diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 4e0bc24..8513698 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -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, }) } } diff --git a/server/handlers/auth.go b/server/handlers/auth.go index a159e19..e964a03 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -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"}) diff --git a/server/handlers/avatar.go b/server/handlers/avatar.go new file mode 100644 index 0000000..0522cbf --- /dev/null +++ b/server/handlers/avatar.go @@ -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"}) +} diff --git a/server/handlers/presets.go b/server/handlers/presets.go index ca713c1..52da00d 100644 --- a/server/handlers/presets.go +++ b/server/handlers/presets.go @@ -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 } diff --git a/server/handlers/settings.go b/server/handlers/settings.go index 5f01b34..cce0502 100644 --- a/server/handlers/settings.go +++ b/server/handlers/settings.go @@ -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"}) diff --git a/server/main.go b/server/main.go index 842015d..5ad54c5 100644 --- a/server/main.go +++ b/server/main.go @@ -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) } } diff --git a/server/models/models.go b/server/models/models.go index 1dfea8e..0dd8479 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -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 ──────────────────────────────── diff --git a/src/css/styles.css b/src/css/styles.css index 26d3754..10cc991 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -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; } diff --git a/src/index.html b/src/index.html index 6e586e1..8000938 100644 --- a/src/index.html +++ b/src/index.html @@ -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 + @@ -25,9 +27,9 @@