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:**
|
**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
|
existing messages with linear parent chains. All new messages created
|
||||||
with proper `parent_id`. Frontend still renders linearly — no branch
|
with proper `parent_id`. Frontend still renders linearly — no branch
|
||||||
UI yet. This is the "do it now while it's cheap" step.
|
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
|
replacing. Regenerate creates sibling model responses. Branch indicator
|
||||||
`← 1/2 →` in the UI. `channel_cursors` table for tracking active
|
`← 1/2 →` in the UI. `channel_cursors` table for tracking active
|
||||||
branch.
|
branch.
|
||||||
@@ -887,7 +887,7 @@ supports tool calling.
|
|||||||
Priority is based on: dependency depth (what blocks other things),
|
Priority is based on: dependency depth (what blocks other things),
|
||||||
solo-user value (Jeff is the first user), and complexity.
|
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**
|
**chats → channels migration + message tree + Folders + Projects + banners**
|
||||||
- Rename `chats` → `channels`, add `type`, `channel_members`,
|
- Rename `chats` → `channels`, add `type`, `channel_members`,
|
||||||
`channel_models`, `participant_type`/`participant_id` on messages.
|
`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.
|
- This is the schema foundation everything else builds on.
|
||||||
Do it now while there's one user and a handful of chats.
|
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**
|
**Notes CRUD + note_* tools + tool execution framework**
|
||||||
- Enables the LLM to be genuinely useful beyond ephemeral chat.
|
- Enables the LLM to be genuinely useful beyond ephemeral chat.
|
||||||
- Requires: tool execution in completion handler (the plumbing for
|
- Requires: tool execution in completion handler (the plumbing for
|
||||||
_all_ tools).
|
_all_ tools).
|
||||||
- This phase builds the tool calling infrastructure that everything
|
- This phase builds the tool calling infrastructure that everything
|
||||||
else uses.
|
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
|
siblings instead of replacing. Regenerate creates sibling model
|
||||||
responses. Branch indicator `← 1/2 →` at fork points. Context
|
responses. Branch indicator `← 1/2 →` at fork points. Context
|
||||||
assembly uses active path, not full channel history.
|
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**
|
**web_search + url_fetch tools**
|
||||||
- Sidecar or direct HTTP from backend.
|
- Sidecar or direct HTTP from backend.
|
||||||
- Paired with Notes: "research X and save findings to my notes."
|
- Paired with Notes: "research X and save findings to my notes."
|
||||||
- Paired with Tasks (future): automated research.
|
- 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**
|
**@mention parsing + multi-model channels**
|
||||||
- The channel schema exists from Phase 1. This phase adds the
|
- The channel schema exists from Phase 1. This phase adds the
|
||||||
routing logic: scan messages for @mentions, resolve against
|
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,
|
- Enables: editor mode (multiple model roles), second opinions,
|
||||||
cross-model conversations.
|
cross-model conversations.
|
||||||
|
|
||||||
### Phase 5: Embeddings + Knowledge Bases (v0.8.x)
|
### Phase 5: Embeddings + Knowledge Bases
|
||||||
**Embedding pipeline + pgvector + KB CRUD + kb_search tool**
|
**Embedding pipeline + pgvector + KB CRUD + kb_search tool**
|
||||||
- Depends on: tool execution framework (Phase 2).
|
- Depends on: tool execution framework (Phase 2).
|
||||||
- Notes get embedded too (once pipeline exists).
|
- Notes get embedded too (once pipeline exists).
|
||||||
- Admin configures embedding model.
|
- Admin configures embedding model.
|
||||||
- KBs attach to channels via `channel_models.kb_ids`.
|
- KBs attach to channels via `channel_models.kb_ids`.
|
||||||
|
|
||||||
### Phase 6: Compaction (v0.8.x)
|
### Phase 6: Compaction
|
||||||
**Auto-compaction service**
|
**Auto-compaction service**
|
||||||
- Depends on: utility LLM calling (same as task runner).
|
- Depends on: utility LLM calling (same as task runner).
|
||||||
- Channel-scoped: compacts any channel that exceeds context threshold.
|
- Channel-scoped: compacts any channel that exceeds context threshold.
|
||||||
- Can be built alongside or after KB (similar backend pattern:
|
- Can be built alongside or after KB (similar backend pattern:
|
||||||
background job that calls an LLM).
|
background job that calls an LLM).
|
||||||
|
|
||||||
### Phase 7: Tasks (v0.9.x)
|
### Phase 7: Tasks
|
||||||
**Scheduler + task runner + task_create tool**
|
**Scheduler + task runner + task_create tool**
|
||||||
- Creates `type: 'service'` channels with no human members.
|
- Creates `type: 'service'` channels with no human members.
|
||||||
- Depends on: completion handler, tool execution, notes, web search.
|
- Depends on: completion handler, tool execution, notes, web search.
|
||||||
- The capstone: everything below it combined into autonomous agents.
|
- The capstone: everything below it combined into autonomous agents.
|
||||||
- Admin controls for resource limits.
|
- 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**
|
**Enterprise auth modes + RBAC beyond admin/user**
|
||||||
- Auth middleware strategy pattern: `AUTH_MODE` env var selects
|
- Auth middleware strategy pattern: `AUTH_MODE` env var selects
|
||||||
`builtin`, `mtls`, or `oidc`. All three resolve to the same
|
`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 1: Build Go backend
|
||||||
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Version:** 0.1 draft
|
**Version:** 0.1 draft
|
||||||
**Status:** Design
|
**Status:** Design
|
||||||
**Applies to:** v0.6.x+
|
**Applies to:** v0.7.x+
|
||||||
**Companion to:** ARCHITECTURE.md (core services + terminology)
|
**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
|
### 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.
|
- What exists today, plus tool calling and richer message types.
|
||||||
- The "default surface" that everything else builds on.
|
- 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
|
## 9. Implementation Roadmap
|
||||||
|
|
||||||
### Phase A: Foundation (v0.6.0)
|
### Phase A: Foundation
|
||||||
- [ ] `extensions.js` — loader, registry, scoped context
|
- [ ] `extensions.js` — loader, registry, scoped context
|
||||||
- [ ] `Extensions.register()` API with permission enforcement
|
- [ ] `Extensions.register()` API with permission enforcement
|
||||||
- [ ] Manifest format parser and validator
|
- [ ] Manifest format parser and validator
|
||||||
@@ -580,7 +580,7 @@ publish to the server when something needs persistence or notification.
|
|||||||
- [ ] Asset serving endpoint
|
- [ ] Asset serving endpoint
|
||||||
- [ ] Extension settings UI in Settings modal
|
- [ ] 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
|
- [ ] `ctx.tools.handle()` API for browser tool registration
|
||||||
- [ ] Tool schema collection in completion handler
|
- [ ] Tool schema collection in completion handler
|
||||||
- [ ] `tool.call.*` / `tool.result.*` WebSocket routing
|
- [ ] `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 execution in EventBus `WaitFor` pattern
|
||||||
- [ ] Tool use display in chat messages
|
- [ ] Tool use display in chat messages
|
||||||
|
|
||||||
### Phase C: Surfaces (v0.6.2)
|
### Phase C: Surfaces
|
||||||
- [ ] Surface registration and activation API
|
- [ ] Surface registration and activation API
|
||||||
- [ ] Mode selector in sidebar
|
- [ ] Mode selector in sidebar
|
||||||
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management
|
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management
|
||||||
- [ ] `surface.activated` / `surface.deactivated` events
|
- [ ] `surface.activated` / `surface.deactivated` events
|
||||||
|
|
||||||
### Phase D: First Extensions (v0.7.0)
|
### Phase D: First Extensions
|
||||||
- [ ] Cost tracker (browser, proof of concept)
|
- [ ] Cost tracker (browser, proof of concept)
|
||||||
- [ ] Slash commands (browser, message transforms)
|
- [ ] Slash commands (browser, message transforms)
|
||||||
- [ ] Custom renderers (browser, mermaid/latex/etc)
|
- [ ] Custom renderers (browser, mermaid/latex/etc)
|
||||||
- [ ] Editor mode (browser, with git provider tools)
|
- [ ] Editor mode (browser, with git provider tools)
|
||||||
|
|
||||||
### Phase E: Server-Side Tiers (v0.8.0)
|
### Phase E: Server-Side Tiers
|
||||||
- [ ] Starlark runtime integration
|
- [ ] Starlark runtime integration
|
||||||
- [ ] Sidecar HTTP tool protocol
|
- [ ] Sidecar HTTP tool protocol
|
||||||
- [ ] Server-side tool execution in completion handler
|
- [ ] 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
|
## Current Priorities
|
||||||
|
|
||||||
1. WebSocket hub (blocks Channels)
|
1. UX polish (chat search, keyboard shortcuts, PWA)
|
||||||
2. Channels backend + frontend
|
2. Teams + RBAC (0.8.0)
|
||||||
3. Plugin architecture design
|
3. Audit + Usage tracking (0.8.x)
|
||||||
4. Notes & Knowledge Base
|
|
||||||
|
|
||||||
See [ROADMAP.md](ROADMAP.md) for detail.
|
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
|
### ✅ Done
|
||||||
|
|
||||||
@@ -42,6 +42,12 @@ minor = add features (deprecate, don't remove), patch = fix something.
|
|||||||
- [x] User + admin provider model listing with capabilities
|
- [x] User + admin provider model listing with capabilities
|
||||||
- [x] Model presets (named wrappers: global/personal scope, system prompt, temp, max_tokens)
|
- [x] Model presets (named wrappers: global/personal scope, system prompt, temp, max_tokens)
|
||||||
- [x] Preset unwrap in completion handler (transparent to provider)
|
- [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)**
|
**Frontend (Vanilla JS)**
|
||||||
- [x] Professional splash page (split-panel hero + tabbed auth)
|
- [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] Appearance settings tab (UI scale, message font size)
|
||||||
- [x] Mobile responsive layout (hamburger menu, sidebar overlay, dvh)
|
- [x] Mobile responsive layout (hamburger menu, sidebar overlay, dvh)
|
||||||
- [x] Model/preset name in message headers (replaces generic "Assistant")
|
- [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)**
|
**CI/CD (Gitea Actions)**
|
||||||
- [x] Three-env pipeline (dev/test/prod)
|
- [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.
|
White-label support and UX improvements that exploit existing infrastructure.
|
||||||
|
|
||||||
**Branding**
|
**Branding**
|
||||||
- [ ] Admin branding settings in global_settings: org name, tagline, accent color
|
- [x] Admin branding settings in global_settings: org name, tagline, accent color
|
||||||
- [ ] `/branding/` volume mount (K8s ConfigMap, optional) for favicon, logo
|
- [x] `/branding/` volume mount (K8s ConfigMap, optional) for favicon, logo
|
||||||
- [ ] Splash page reads branding config on load, falls back to Switchboard defaults
|
- [x] Splash page reads branding config on load, falls back to Switchboard defaults
|
||||||
- [ ] CSS accent color override from branding settings
|
- [x] CSS accent color override from branding settings
|
||||||
|
|
||||||
**Profile Pictures / Avatars**
|
**Profile Pictures / Avatars**
|
||||||
- [ ] `avatar` column on `users` table (base64 PNG, capped ~128x128, nullable)
|
- [x] `avatar_url` column on `users` table (already existed, now wired)
|
||||||
- [ ] `preset_avatar` column on `model_presets` (alongside existing `icon` emoji field)
|
- [x] `avatar` column on `model_presets` (migration 015)
|
||||||
- [ ] Avatar upload in user Settings → Profile section (crop/resize client-side)
|
- [x] Avatar upload in user Settings → Profile section (server-side resize to 128×128)
|
||||||
- [ ] Admin preset form: optional avatar upload (or keep emoji-only)
|
- [x] Admin/user preset avatar upload endpoint
|
||||||
- [ ] `UI.avatar(msg)` helper: returns `<img>` if avatar set, emoji fallback otherwise
|
- [x] `avatarHTML()` helper: returns `<img>` if avatar set, emoji fallback otherwise
|
||||||
- [ ] Replace hardcoded 👤/🤖 in `_messageHTML`, `streamResponse`, sidebar user area
|
- [x] Avatar displayed in message headers, stream, typing indicator, sidebar user area
|
||||||
- [ ] Avatar displayed in message headers, chat list, user flyout
|
|
||||||
- [ ] Storage: base64 in DB keeps backups self-contained (airgap-friendly)
|
|
||||||
|
|
||||||
**Message Editing + Forking**
|
**Message Editing + Forking**
|
||||||
- [ ] Edit message → creates sibling (uses existing parent_id tree)
|
- [x] Edit message → creates sibling (uses existing parent_id tree)
|
||||||
- [ ] Regenerate → creates sibling model response
|
- [x] Regenerate → creates sibling model response
|
||||||
- [ ] Branch indicator at fork points (← 1/2 →)
|
- [x] Branch indicator at fork points (← 1/2 →)
|
||||||
- [ ] Context assembly follows active path, not full channel history
|
- [x] Context assembly follows active path, not full channel history
|
||||||
|
|
||||||
**UX Polish**
|
**UX Polish**
|
||||||
- [ ] Chat search / filter in sidebar
|
- [ ] 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 Framework**
|
||||||
- [ ] Tool calling pipeline in completion handler
|
- [x] Tool calling pipeline in completion handler (OpenAI + Anthropic)
|
||||||
- [ ] Tool registry (built-in + future plugin tools)
|
- [x] Tool registry (built-in + future plugin tools)
|
||||||
- [ ] Tool execution loop: model requests tool → backend executes → result fed back
|
- [x] Tool execution loop: model requests tool → backend executes → result fed back
|
||||||
- [ ] Tool permission model (which tools enabled per preset/channel)
|
- [ ] Tool permission model (which tools enabled per preset/channel)
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
- [ ] `notes` table: title, content, folder_id, tags, team_id (nullable), created_by
|
- [x] `notes` table: title, content, folder, tags, source_channel_id, full-text search
|
||||||
- [ ] Notes CRUD endpoints
|
- [x] Notes CRUD endpoints + search
|
||||||
- [ ] `note_create`, `note_update`, `note_search`, `note_list` tools
|
- [x] `note_create`, `note_update`, `note_search`, `note_list` tools
|
||||||
- [ ] Full-text search (PostgreSQL `tsvector`)
|
- [x] Full-text search (PostgreSQL `tsvector` + `ts_rank` + `ts_headline`)
|
||||||
- [ ] Markdown editor in frontend
|
- [x] Notes UI: modal with list/detail views, folder sidebar, Markdown editor
|
||||||
- [ ] Team-scoped notes (schema ready from 0.8.0)
|
- [ ] Team-scoped notes (awaits 0.8.0 teams)
|
||||||
|
|
||||||
**Conversation Forking UI**
|
**Conversation Forking UI**
|
||||||
- [ ] Edit-and-resubmit creates siblings (tree structure from 0.6.0 schema)
|
- [x] Edit-and-resubmit creates siblings (tree structure)
|
||||||
- [ ] Branch indicator ← 1/2 → at fork points
|
- [x] Branch indicator ← 1/2 → at fork points
|
||||||
- [ ] Context assembly follows active path
|
- [x] Context assembly follows active path
|
||||||
|
|
||||||
## 0.9.x — Context Management
|
## 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
|
## 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)
|
- [ ] `web_search` tool: search provider abstraction (DuckDuckGo, SearXNG, Brave)
|
||||||
- [ ] `url_fetch` tool: retrieve and extract content from URLs
|
- [ ] `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)
|
- [ ] Paste-to-upload (clipboard image support)
|
||||||
|
|
||||||
*Note: media generation (image gen, video models) is a separate concern —
|
*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.*
|
produce attachments as output. Tracked under Future.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0.12.0 — @mention Routing + Multi-model
|
## 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)
|
- [ ] @mention parsing in messages (users and AI models)
|
||||||
- [ ] Resolve mentions against `channel_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
|
- [ ] `knowledge_bases` table: name, description, team_id (nullable), created_by
|
||||||
- [ ] KB document storage via 0.11.0 storage backend (same S3/PVC abstraction)
|
- [ ] KB document storage via 0.11.0 storage backend (same S3/PVC abstraction)
|
||||||
- [ ] KB CRUD endpoints + admin UI
|
- [ ] 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
|
- [ ] Context injection in completion flow
|
||||||
- [ ] Notes get embedded too (once pipeline exists)
|
- [ ] Notes get embedded too (once pipeline exists)
|
||||||
- [ ] Team admins manage team KBs (permission layer from 0.8.0)
|
- [ ] Team admins manage team KBs (permission layer from 0.8.0)
|
||||||
@@ -391,7 +400,7 @@ could pull left based on need.
|
|||||||
|
|
||||||
## Extension / Plugin Architecture
|
## 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
|
internal hook points. A formal plugin API, manifest format, and marketplace
|
||||||
are tracked in the dedicated design documents:
|
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}/"
|
BASE_HREF="${BASE_PATH}/"
|
||||||
fi
|
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 ──────────────────
|
# ── Inject into index.html ──────────────────
|
||||||
sed -i \
|
sed -i \
|
||||||
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
|
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
|
||||||
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
|
-e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
|
||||||
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
|
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
|
||||||
|
-e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
|
||||||
/usr/share/nginx/html/index.html
|
/usr/share/nginx/html/index.html
|
||||||
|
|
||||||
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION}"
|
echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION}"
|
||||||
@@ -57,6 +68,14 @@ server {
|
|||||||
add_header Cache-Control "public, immutable";
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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
|
# Redirect bare path to trailing slash
|
||||||
location = ${BASE_PATH} {
|
location = ${BASE_PATH} {
|
||||||
return 301 ${BASE_PATH}/;
|
return 301 ${BASE_PATH}/;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: BASE_PATH
|
- name: BASE_PATH
|
||||||
value: "${BASE_PATH}"
|
value: "${BASE_PATH}"
|
||||||
|
volumeMounts:
|
||||||
|
- name: branding
|
||||||
|
mountPath: /branding
|
||||||
|
readOnly: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "${FE_MEMORY_REQUEST}"
|
memory: "${FE_MEMORY_REQUEST}"
|
||||||
@@ -61,6 +65,11 @@ spec:
|
|||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: branding
|
||||||
|
configMap:
|
||||||
|
name: switchboard-branding
|
||||||
|
optional: true
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
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{
|
var publicSettingKeys = map[string]bool{
|
||||||
"banner": true,
|
"banner": true,
|
||||||
|
"branding": true,
|
||||||
"user_providers_enabled": true,
|
"user_providers_enabled": true,
|
||||||
"registration_enabled": true,
|
"registration_enabled": true,
|
||||||
"registration_default_state": true,
|
"registration_default_state": true,
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
IsPreset bool `json:"is_preset,omitempty"`
|
IsPreset bool `json:"is_preset,omitempty"`
|
||||||
PresetID string `json:"preset_id,omitempty"`
|
PresetID string `json:"preset_id,omitempty"`
|
||||||
PresetScope string `json:"preset_scope,omitempty"`
|
PresetScope string `json:"preset_scope,omitempty"`
|
||||||
|
PresetAvatar string `json:"preset_avatar,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
models := make([]enabledModel, 0)
|
models := make([]enabledModel, 0)
|
||||||
@@ -538,7 +539,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
// ── 3. Active presets (global + user's personal + shared) ──
|
// ── 3. Active presets (global + user's personal + shared) ──
|
||||||
presetRows, err := database.DB.Query(`
|
presetRows, err := database.DB.Query(`
|
||||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
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
|
COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name
|
||||||
FROM model_presets mp
|
FROM model_presets mp
|
||||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
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 {
|
if err == nil {
|
||||||
defer presetRows.Close()
|
defer presetRows.Close()
|
||||||
for presetRows.Next() {
|
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 apiConfigID *string
|
||||||
var temp *float64
|
var temp *float64
|
||||||
var maxTok *int
|
var maxTok *int
|
||||||
if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +616,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
IsPreset: true,
|
IsPreset: true,
|
||||||
PresetID: presetID,
|
PresetID: presetID,
|
||||||
PresetScope: scope,
|
PresetScope: scope,
|
||||||
|
PresetAvatar: avatar,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type userResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Avatar *string `json:"avatar,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthHandler holds dependencies for auth endpoints.
|
// AuthHandler holds dependencies for auth endpoints.
|
||||||
@@ -129,9 +130,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
err = database.DB.QueryRow(`
|
err = database.DB.QueryRow(`
|
||||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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(
|
`, 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 err != nil {
|
||||||
if strings.Contains(err.Error(), "duplicate key") {
|
if strings.Contains(err.Error(), "duplicate key") {
|
||||||
@@ -268,12 +269,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
var isActive bool
|
var isActive bool
|
||||||
|
|
||||||
err := database.DB.QueryRow(`
|
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
|
FROM users
|
||||||
WHERE email = $1 OR username = $1
|
WHERE email = $1 OR username = $1
|
||||||
`, req.Login).Scan(
|
`, req.Login).Scan(
|
||||||
&user.ID, &user.Username, &user.Email, &user.DisplayName,
|
&user.ID, &user.Username, &user.Email, &user.DisplayName,
|
||||||
&user.Role, &passwordHash, &isActive,
|
&user.Role, &user.Avatar, &passwordHash, &isActive,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
@@ -352,10 +353,10 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
var user userResponse
|
var user userResponse
|
||||||
var isActive bool
|
var isActive bool
|
||||||
err = database.DB.QueryRow(`
|
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
|
FROM users WHERE id = $1
|
||||||
`, userID).Scan(
|
`, 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 {
|
if err != nil || !isActive {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
|
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,
|
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.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.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(ac.name, '') as provider_name
|
||||||
FROM model_presets mp
|
FROM model_presets mp
|
||||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
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.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
&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 {
|
); err != nil {
|
||||||
continue
|
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,
|
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.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.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(ac.name, '') as provider_name,
|
||||||
COALESCE(u.username, '') as creator_name
|
COALESCE(u.username, '') as creator_name
|
||||||
FROM model_presets mp
|
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.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
&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 {
|
); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type profileResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
Avatar *string `json:"avatar,omitempty"`
|
||||||
Settings map[string]interface{} `json:"settings"`
|
Settings map[string]interface{} `json:"settings"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -50,11 +51,11 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
|||||||
var p profileResponse
|
var p profileResponse
|
||||||
var settingsRaw string
|
var settingsRaw string
|
||||||
err := database.DB.QueryRow(`
|
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
|
FROM users WHERE id = $1
|
||||||
`, userID).Scan(
|
`, userID).Scan(
|
||||||
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
||||||
&settingsRaw, &p.CreatedAt,
|
&p.Avatar, &settingsRaw, &p.CreatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ func main() {
|
|||||||
protected.GET("/profile", settings.GetProfile)
|
protected.GET("/profile", settings.GetProfile)
|
||||||
protected.PUT("/profile", settings.UpdateProfile)
|
protected.PUT("/profile", settings.UpdateProfile)
|
||||||
protected.POST("/profile/password", settings.ChangePassword)
|
protected.POST("/profile/password", settings.ChangePassword)
|
||||||
|
protected.POST("/profile/avatar", settings.UploadAvatar)
|
||||||
|
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
|
||||||
protected.GET("/settings", settings.GetSettings)
|
protected.GET("/settings", settings.GetSettings)
|
||||||
protected.PUT("/settings", settings.UpdateSettings)
|
protected.PUT("/settings", settings.UpdateSettings)
|
||||||
|
|
||||||
@@ -143,6 +145,8 @@ func main() {
|
|||||||
protected.POST("/presets", presets.CreateUserPreset)
|
protected.POST("/presets", presets.CreateUserPreset)
|
||||||
protected.PUT("/presets/:id", presets.UpdateUserPreset)
|
protected.PUT("/presets/:id", presets.UpdateUserPreset)
|
||||||
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
|
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
|
||||||
|
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||||
|
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
notes := handlers.NewNoteHandler()
|
notes := handlers.NewNoteHandler()
|
||||||
@@ -202,6 +206,8 @@ func main() {
|
|||||||
admin.POST("/presets", presetAdm.CreateAdminPreset)
|
admin.POST("/presets", presetAdm.CreateAdminPreset)
|
||||||
admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset)
|
admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset)
|
||||||
admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset)
|
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"`
|
IsShared bool `json:"is_shared" db:"is_shared"`
|
||||||
IsActive bool `json:"is_active" db:"is_active"`
|
IsActive bool `json:"is_active" db:"is_active"`
|
||||||
Icon string `json:"icon,omitempty" db:"icon"`
|
Icon string `json:"icon,omitempty" db:"icon"`
|
||||||
|
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────
|
// ── Settings ────────────────────────────────
|
||||||
|
|||||||
@@ -276,6 +276,10 @@ a:hover { text-decoration: underline; }
|
|||||||
background: var(--bg-raised); display: flex; align-items: center;
|
background: var(--bg-raised); display: flex; align-items: center;
|
||||||
justify-content: center; font-size: 13px; font-weight: 600;
|
justify-content: center; font-size: 13px; font-weight: 600;
|
||||||
color: var(--accent); flex-shrink: 0; position: relative;
|
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 {
|
.avatar-bug {
|
||||||
position: absolute; bottom: -3px; right: -3px;
|
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.selected { background: var(--bg-hover); color: var(--accent); }
|
||||||
.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
.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); }
|
.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 {
|
.model-caps {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
@@ -408,6 +413,10 @@ a:hover { text-decoration: underline; }
|
|||||||
width: 28px; height: 28px; border-radius: 50%;
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 14px; flex-shrink: 0; margin-top: 2px;
|
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.user .msg-avatar { background: var(--accent-dim); }
|
||||||
.message.assistant .msg-avatar { background: var(--bg-raised); }
|
.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-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-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
|
||||||
.hero-logo-mark { width: 48px; height: 48px; flex-shrink: 0; }
|
.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 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
||||||
.hero-wordmark span { color: var(--accent); }
|
.hero-wordmark span { color: var(--accent); }
|
||||||
|
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; vertical-align: middle; }
|
||||||
.hero-headline {
|
.hero-headline {
|
||||||
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
||||||
letter-spacing: -0.03em; margin-bottom: 1rem;
|
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; }
|
.splash-hero { padding: 1.5rem 1.25rem 1rem; }
|
||||||
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
|
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
|
||||||
.hero-logo-mark { width: 40px; height: 40px; }
|
.hero-logo-mark { width: 40px; height: 40px; }
|
||||||
|
.hero-logo-img { width: 40px; height: 40px; }
|
||||||
.hero-wordmark { font-size: 1.3rem; }
|
.hero-wordmark { font-size: 1.3rem; }
|
||||||
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
|
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
|
||||||
.hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; }
|
.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 { 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: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; }
|
.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 tab bars — shared horizontal scroll with arrow navigation */
|
||||||
.modal-tabs-wrap {
|
.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 { 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; }
|
.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-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-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; }
|
.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
<base href="%%BASE_HREF%%">
|
<base href="%%BASE_HREF%%">
|
||||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||||
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</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>
|
<title>Chat Switchboard</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
<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="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||||
|
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -25,9 +27,9 @@
|
|||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-top">
|
||||||
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
<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>
|
<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>
|
</button>
|
||||||
<div class="split-btn" style="position:relative">
|
<div class="split-btn" style="position:relative">
|
||||||
<button class="split-btn-main" id="newChatBtn" title="New Chat">
|
<button class="split-btn-main" id="newChatBtn" title="New Chat">
|
||||||
@@ -147,7 +149,8 @@
|
|||||||
<div class="splash-hero">
|
<div class="splash-hero">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-logo-row">
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<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"/>
|
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="hero-wordmark">Chat <span>Switchboard</span></div>
|
</div>
|
||||||
|
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero-headline">One interface.<br>Every AI model.</h1>
|
<h1 class="hero-headline" id="brandHeadline">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>
|
<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">
|
<div class="hero-features" id="brandPills">
|
||||||
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
<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 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>
|
<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>
|
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-footer">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,6 +221,17 @@
|
|||||||
<div class="settings-tab-content" id="settingsGeneralTab">
|
<div class="settings-tab-content" id="settingsGeneralTab">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Profile</h3>
|
<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>Display Name</label><input type="text" id="profileDisplayName"></div>
|
||||||
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
|
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
|
||||||
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
|
<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>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 class="form-group"><label>Icon</label><input type="text" id="adminPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
|
||||||
</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-group"><label>Description</label><input type="text" id="adminPresetDesc" placeholder="Reviews code for best practices"></div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>
|
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>
|
||||||
|
|||||||
@@ -228,8 +228,19 @@ const API = {
|
|||||||
|
|
||||||
// ── Profile & Settings ───────────────────
|
// ── 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); },
|
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) {
|
changePassword(current, newPw) {
|
||||||
return this._post('/api/v1/profile/password', { current_password: current, new_password: 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); },
|
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
|
||||||
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
||||||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
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 ─────────────────────────
|
// ── User Presets ─────────────────────────
|
||||||
listPresets() { return this._get('/api/v1/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() {
|
async function init() {
|
||||||
console.log('🔀 Chat Switchboard initializing...');
|
console.log('🔀 Chat Switchboard initializing...');
|
||||||
|
initBranding(); // Apply branding before splash is visible
|
||||||
API.loadTokens();
|
API.loadTokens();
|
||||||
|
|
||||||
let health = null;
|
let health = null;
|
||||||
@@ -102,6 +103,59 @@ async function saveSettings() {
|
|||||||
} catch (e) { console.warn('Settings sync failed:', e.message); }
|
} 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 ───────────────────────────────────
|
// ── Models ───────────────────────────────────
|
||||||
|
|
||||||
// Client-side known model capabilities — used when backend hasn't synced yet.
|
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||||||
@@ -188,6 +242,7 @@ async function fetchModels() {
|
|||||||
isPreset,
|
isPreset,
|
||||||
presetId: m.preset_id || null,
|
presetId: m.preset_id || null,
|
||||||
presetScope: m.preset_scope || null,
|
presetScope: m.preset_scope || null,
|
||||||
|
presetAvatar: m.preset_avatar || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -843,6 +898,41 @@ function initListeners() {
|
|||||||
document.getElementById('profileChangePwForm').style.display = '';
|
document.getElementById('profileChangePwForm').style.display = '';
|
||||||
});
|
});
|
||||||
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
|
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('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
|
||||||
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
|
||||||
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
|
||||||
@@ -907,6 +997,7 @@ function initListeners() {
|
|||||||
document.getElementById('adminPresetIcon').value = '';
|
document.getElementById('adminPresetIcon').value = '';
|
||||||
document.getElementById('adminPresetTemp').value = '';
|
document.getElementById('adminPresetTemp').value = '';
|
||||||
document.getElementById('adminPresetMaxTokens').value = '';
|
document.getElementById('adminPresetMaxTokens').value = '';
|
||||||
|
updatePresetAvatarPreview(null);
|
||||||
const f = document.getElementById('adminAddPresetForm');
|
const f = document.getElementById('adminAddPresetForm');
|
||||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||||
});
|
});
|
||||||
@@ -914,9 +1005,55 @@ function initListeners() {
|
|||||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||||
_editingPresetId = null;
|
_editingPresetId = null;
|
||||||
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
||||||
|
updatePresetAvatarPreview(null);
|
||||||
});
|
});
|
||||||
document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
|
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
|
// Admin — banner controls
|
||||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||||
@@ -1195,6 +1332,8 @@ function editAdminPreset(id) {
|
|||||||
if (modelSel) modelSel.value = p.base_model_id || '';
|
if (modelSel) modelSel.value = p.base_model_id || '';
|
||||||
const cfgSel = document.getElementById('adminPresetConfig');
|
const cfgSel = document.getElementById('adminPresetConfig');
|
||||||
if (cfgSel) cfgSel.value = p.api_config_id || '';
|
if (cfgSel) cfgSel.value = p.api_config_id || '';
|
||||||
|
// Avatar preview
|
||||||
|
updatePresetAvatarPreview(p.avatar || null);
|
||||||
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
|
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1221,13 +1360,24 @@ async function createAdminPreset() {
|
|||||||
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
|
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let presetId = _editingPresetId;
|
||||||
if (_editingPresetId) {
|
if (_editingPresetId) {
|
||||||
await API.adminUpdatePreset(_editingPresetId, preset);
|
await API.adminUpdatePreset(_editingPresetId, preset);
|
||||||
UI.toast('Preset updated', 'success');
|
UI.toast('Preset updated', 'success');
|
||||||
} else {
|
} else {
|
||||||
await API.adminCreatePreset(preset);
|
const result = await API.adminCreatePreset(preset);
|
||||||
|
presetId = result?.id || result?.preset?.id;
|
||||||
UI.toast('Preset created', 'success');
|
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;
|
_editingPresetId = null;
|
||||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||||
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
|
||||||
@@ -1237,6 +1387,7 @@ async function createAdminPreset() {
|
|||||||
document.getElementById('adminPresetIcon').value = '';
|
document.getElementById('adminPresetIcon').value = '';
|
||||||
document.getElementById('adminPresetTemp').value = '';
|
document.getElementById('adminPresetTemp').value = '';
|
||||||
document.getElementById('adminPresetMaxTokens').value = '';
|
document.getElementById('adminPresetMaxTokens').value = '';
|
||||||
|
updatePresetAvatarPreview(null);
|
||||||
await UI.loadAdminPresets();
|
await UI.loadAdminPresets();
|
||||||
await fetchModels();
|
await fetchModels();
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
@@ -1563,6 +1714,115 @@ async function deleteNote() {
|
|||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} 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 ──────────────────────────────────
|
// ── Banners ──────────────────────────────────
|
||||||
|
|
||||||
async function initBanners() {
|
async function initBanners() {
|
||||||
|
|||||||
66
src/js/ui.js
66
src/js/ui.js
@@ -2,6 +2,26 @@
|
|||||||
// Chat Switchboard – UI
|
// 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 = {
|
const UI = {
|
||||||
|
|
||||||
// ── Sidebar ──────────────────────────────
|
// ── Sidebar ──────────────────────────────
|
||||||
@@ -132,7 +152,7 @@ const UI = {
|
|||||||
return `
|
return `
|
||||||
<div class="message ${msg.role}" data-msg-id="${msgId}">
|
<div class="message ${msg.role}" data-msg-id="${msgId}">
|
||||||
<div class="msg-inner">
|
<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-body">
|
||||||
<div class="msg-head">
|
<div class="msg-head">
|
||||||
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
|
||||||
@@ -208,11 +228,13 @@ const UI = {
|
|||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
|
||||||
const label = modelName || 'Assistant';
|
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');
|
const div = document.createElement('div');
|
||||||
div.className = 'message assistant';
|
div.className = 'message assistant';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="msg-inner">
|
<div class="msg-inner">
|
||||||
<div class="msg-avatar">🤖</div>
|
<div class="msg-avatar">${avatarHTML('assistant', streamAvatar)}</div>
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
|
||||||
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
<div class="msg-tools" id="streamTools" style="display:none"></div>
|
||||||
@@ -394,7 +416,8 @@ const UI = {
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'model-dropdown-item';
|
div.className = 'model-dropdown-item';
|
||||||
div.dataset.value = m.id;
|
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', () => {
|
div.addEventListener('click', () => {
|
||||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||||
UI._closeModelDropdown();
|
UI._closeModelDropdown();
|
||||||
@@ -475,7 +498,31 @@ const UI = {
|
|||||||
updateUser() {
|
updateUser() {
|
||||||
const name = API.user?.display_name || API.user?.username || 'User';
|
const name = API.user?.display_name || API.user?.username || 'User';
|
||||||
const letter = (name[0] || '?').toUpperCase();
|
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;
|
document.getElementById('userName').textContent = name;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -490,12 +537,14 @@ const UI = {
|
|||||||
document.getElementById('sendBtn').disabled = on;
|
document.getElementById('sendBtn').disabled = on;
|
||||||
if (on) {
|
if (on) {
|
||||||
const container = document.getElementById('chatMessages');
|
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');
|
const typing = document.createElement('div');
|
||||||
typing.id = 'typingIndicator';
|
typing.id = 'typingIndicator';
|
||||||
typing.className = 'message assistant';
|
typing.className = 'message assistant';
|
||||||
typing.innerHTML = `
|
typing.innerHTML = `
|
||||||
<div class="msg-inner">
|
<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 class="msg-body"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
container.appendChild(typing);
|
container.appendChild(typing);
|
||||||
@@ -624,6 +673,7 @@ const UI = {
|
|||||||
const p = await API.getProfile();
|
const p = await API.getProfile();
|
||||||
document.getElementById('profileDisplayName').value = p.display_name || '';
|
document.getElementById('profileDisplayName').value = p.display_name || '';
|
||||||
document.getElementById('profileEmail').value = p.email || '';
|
document.getElementById('profileEmail').value = p.email || '';
|
||||||
|
updateAvatarPreview(p.avatar || null);
|
||||||
} catch (e) { /* optional */ }
|
} catch (e) { /* optional */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -805,12 +855,14 @@ const UI = {
|
|||||||
UI._presetCache = {};
|
UI._presetCache = {};
|
||||||
el.innerHTML = list.map(p => {
|
el.innerHTML = list.map(p => {
|
||||||
UI._presetCache[p.id] = 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 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>';
|
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||||
return `<div class="admin-preset-row">
|
return `<div class="admin-preset-row">
|
||||||
<div class="preset-info">
|
<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>
|
<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>` : ''}
|
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user