Changeset 0.22.7 (#149)
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -2,6 +2,48 @@
|
||||
|
||||
All notable changes to Chat Switchboard.
|
||||
|
||||
## [0.22.8] — 2026-03-04
|
||||
|
||||
### Changed
|
||||
- **Naming cleanup: preset → persona.** Unified domain terminology across the entire codebase. Routes `/presets` → `/personas` (user, team, admin). JSON response keys, request fields, Go struct fields, handler names, JS functions, CSS classes, DOM IDs, template strings, display text — all consistently use "persona". File renamed: `presets.go` → `personas.go`. Dual JSON keys (`"personas"` + `"presets"`) collapsed to single `"personas"` key. Config key `user_presets` → `user_personas`. Banner color presets intentionally left as "presets" (different domain concept).
|
||||
- **Naming cleanup: APIConfigID → ProviderConfigID.** All Go structs and JSON fields now use `provider_config_id` consistently.
|
||||
- **Removed aliases.** `chat_id` field removed from completion handler. `/models` route alias removed (use `/models/enabled`).
|
||||
- **Shared app-state.js.** Extracted `App` state object, `fetchModels()`, and `resolveCapabilities()` from chat-only `app.js` into new `app-state.js` loaded by `base.html` for all surfaces. Settings and admin surfaces now have access to `App` state for model dropdowns, settings, and user context.
|
||||
- **Auth tokens loaded for all surfaces.** `API.loadTokens()` now runs in `base.html` inline script so settings, admin, editor, and notes surfaces can make authenticated API calls.
|
||||
- **Script loading consolidated in base.html.** `ui-core.js` moved from per-surface loads to `base.html`. `ui-primitives-additions.js` (never loaded by any surface) now loaded in `base.html`. All surfaces get the complete shared stack: app-state → api → events → ui-primitives → ui-primitives-additions → ui-core → pages.
|
||||
|
||||
### Added
|
||||
- **Settings surface functions.** `UI.loadGeneralSettings()` populates the general settings form (model dropdown, system prompt, temperature, max tokens, thinking toggle) with server data. `UI.saveAppearance()` persists theme, scale, and font size. `UI.loadTeamsSettings()` renders team membership on the settings teams tab.
|
||||
- **Design documentation.** `docs/DESIGN-SURFACES.md` (four-layer surface architecture, extension hooks, trust model), `docs/ICD-API.md` (domain-organized API contract, 240 routes), `docs/ICD-AUDIT.md` (cross-reference audit with resolution status).
|
||||
|
||||
## [0.22.7] — 2026-03-03
|
||||
|
||||
### Added
|
||||
- **Theme system.** New `theme.css` provides complete light/dark theming via CSS custom properties with `[data-theme]` attribute switching. Variables cover backgrounds, surfaces, text, accents, borders, shadows, and component-specific tokens. System preference auto-detection via `prefers-color-scheme` media query. Old variables (`--bg-primary`, `--text-primary`) bridged via fallback chains (`var(--bg, var(--bg-primary, #0e0e10))`) — zero breakage of existing CSS.
|
||||
- **ChatPane component.** New `chat-pane.js` provides a mountable, self-contained chat pane factory replacing the hardcoded singleton pattern. `ChatPane.create(opts)` returns an instance with `renderMessages()`, `appendChunk()`, `finalizeStream()`, `appendTyping()`, `removeTyping()`, `scrollToBottom()`, `showWelcome()`, `clear()`, `getInputValue()`, `setInputValue()`, `focusInput()`, `destroy()`. Lookup via `ChatPane.get(id)`, `ChatPane.forChannel(channelId)`, `ChatPane.active()`. Each instance owns its own DOM elements and channel binding.
|
||||
- **Chat pane template component.** New `components/chat-pane.html` Go template partial: `{{template "chat-pane" dict "ID" "main"}}` renders a complete chat scaffold with messages container, input bar, model selector, send button, and toolbar mount points. Used by editor surface assist pane.
|
||||
- **Splash/login surface.** Login page rewritten from minimal form to split-panel splash layout: animated grid canvas on hero side, tabbed auth card (Login + Register) on right. Registration form includes avatar upload, display name, email. Powered by new `pages-splash.js` module with `Pages.initSplash()`, grid animation, and `POST /api/v1/auth/register` + `PUT /api/v1/users/me/avatar` integration.
|
||||
- **UI primitives additions.** New `ui-primitives-additions.js` extends the shared primitive library: `Toast` (show/success/error/warning/info with auto-dismiss stacking), `renderBadge()` (6 color variants), `renderIcon()` (20+ SVG icon set), `renderIconBtn()` (icon button factory with active state), `Theme` (init/set/get/resolved/renderToggle with localStorage persistence), `showConfirmDialog()` (enhanced with variant/callbacks), `mountAvatarUpload()` (file input with preview).
|
||||
- **Settings BYOK gate.** Settings surface nav now conditionally shows "My Providers", "Model Roles", and "Usage" tabs only when BYOK is enabled. Server-side gate via `{{if .Data.BYOKEnabled}}` reads `GlobalConfig` key `user_providers.enabled`. Includes BYOK status indicator in nav footer with UEK encryption notice.
|
||||
- **Settings User Personas gate.** Settings surface conditionally shows "My Personas" tab when user-created personas are enabled. Server-side gate via `{{if .Data.UserPersonasEnabled}}` reads `GlobalConfig` key `user_presets.enabled`.
|
||||
- **Settings Models tab.** New "Models" nav link in settings surface for user model visibility preferences.
|
||||
- **Editor assist pane.** Editor surface now includes a `chat-pane` template component for the right-side assist pane with split handle, wired up via `ChatPane.create()` on DOM ready.
|
||||
|
||||
### Changed
|
||||
- **`base.html` template.** Added `data-theme` attribute on `<html>` element for theme system. Google Fonts preconnect + DM Sans / JetBrains Mono stylesheet. `theme.css` loaded before `styles.css`. New shared script tags for `ui-primitives-additions.js` and `chat-pane.js` (available on all surfaces). Added `window.__SETTINGS__` global from `PageData.SurfaceSettings`.
|
||||
- **`PageData` struct** (`pages.go`). New fields: `Theme` (string), `SurfaceSettings` (any, serialized to `__SETTINGS__`), `InstanceName`, `LogoURL`, `Tagline`, `RegistrationOpen`. `Render()` defaults `Theme` to `"dark"` when unset.
|
||||
- **`RenderLogin()`** (`pages.go`). Now calls `loadBranding()` and `isRegistrationOpen()` to populate splash/login template fields from `GlobalConfig` keys `branding.*` and `registration.enabled`.
|
||||
- **`SettingsPageData`** (`loaders.go`). Added `BYOKEnabled` and `UserPersonasEnabled` boolean fields. `settingsLoader()` reads from `GlobalConfig` keys `user_providers.enabled` and `user_presets.enabled`.
|
||||
- **`settings.html` template.** Reorganized nav: base tabs (General, Appearance, Models, My Presets) always visible; BYOK section (My Providers, Model Roles, Usage) gated; User Personas gated; then Knowledge, Memory, Notifications, Teams. Added `my-personas` to dynamic section loaders.
|
||||
- **`editor.html` template.** Added `chat-pane` component in right-side assist pane with split handle and `ChatPane.create()` initialization.
|
||||
|
||||
### New Files
|
||||
- `src/css/theme.css` — 305 lines: CSS custom properties, shared components, surface layouts
|
||||
- `src/js/chat-pane.js` — 162 lines: mountable chat pane factory
|
||||
- `src/js/ui-primitives-additions.js` — 199 lines: Toast, Badge, Icons, Theme, confirm, avatar
|
||||
- `src/js/pages-splash.js` — 154 lines: splash init, login, register, grid canvas
|
||||
- `server/pages/templates/components/chat-pane.html` — 23 lines: reusable template partial
|
||||
|
||||
## [0.22.6] — 2026-03-03
|
||||
|
||||
### Fixed
|
||||
|
||||
2
IMPLEMENTATION.md
Normal file
2
IMPLEMENTATION.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# v0.22.7 Changeset
|
||||
See files for details.
|
||||
746
docs/DESIGN-SURFACES.md
Normal file
746
docs/DESIGN-SURFACES.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# DESIGN — Surface & Extension Architecture
|
||||
|
||||
**Status:** Accepted
|
||||
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
|
||||
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane)
|
||||
**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The UI is a four-layer architecture: Primitives, Components, Surfaces,
|
||||
and Banners. Each layer has a single responsibility and a strict
|
||||
dependency direction — primitives know nothing, components compose
|
||||
primitives, surfaces compose components, banners are platform chrome
|
||||
outside the surface boundary.
|
||||
|
||||
Extensions participate at every layer through six defined hooks.
|
||||
Admin-installed extensions are privileged (full DOM, direct API access,
|
||||
own routes). Themes are pure CSS custom property overrides that
|
||||
propagate through every layer uniformly.
|
||||
|
||||
This document defines the layer contracts, extension hook system,
|
||||
display content model, trust boundaries, and theme architecture.
|
||||
|
||||
---
|
||||
|
||||
## Definitions
|
||||
|
||||
A **surface** is a full-page layout — a composition of components with
|
||||
a route, a data loader, and a boot script. Chat is a surface. The
|
||||
editor is a surface. A custom triage intake form is a surface.
|
||||
|
||||
An **extension** is a package — a manifest plus code plus assets that
|
||||
participates in the platform through hooks. An extension can do one,
|
||||
some, or all of:
|
||||
|
||||
- Register tools the LLM can call (Hook 5: Tool Bridge)
|
||||
- Transform content during streaming (Hook 2: Stream Processing)
|
||||
- Enhance rendered messages (Hook 3: Post-Render)
|
||||
- Attach visual content to messages (Hook 4: Display Content)
|
||||
- Inject panels or sections into existing surfaces (Hook 6: Surface Injection)
|
||||
- Create entirely new surfaces with their own routes (Surface Registration)
|
||||
|
||||
The five **core surfaces** (Chat, Editor, Notes, Admin, Settings) exist
|
||||
without extensions. They are built from the same primitive and component
|
||||
layers that extensions use, but they are registered directly in Go
|
||||
rather than through a manifest.
|
||||
|
||||
An extension that creates a surface gets its own route (`/s/:slug`),
|
||||
its own data loader, and the full primitive/component library. An
|
||||
extension that only registers a post-render hook (like a block
|
||||
renderer) doesn't create any surfaces at all.
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Extension | Creates Surface? | Hooks Used |
|
||||
|-----------|-----------------|------------|
|
||||
| Mermaid renderer | No | Post-Render |
|
||||
| KaTeX renderer | No | Post-Render |
|
||||
| Calculator tool | No | Tool Bridge |
|
||||
| Image Generator | Yes (`/s/gallery`) | Tool Bridge, Stream Processing, Post-Render, Display Content, Surface |
|
||||
| Custom Dashboard | Yes (`/s/dashboard`) | Surface, Surface Injection (admin section) |
|
||||
|
||||
---
|
||||
|
||||
## Existing Extension Mapping
|
||||
|
||||
The current extension mechanisms map directly into this architecture:
|
||||
|
||||
| Current Mechanism | Architecture Equivalent |
|
||||
|-------------------|------------------------|
|
||||
| Block renderers (`ctx.renderers.register()`) | Hook 3: Post-Render |
|
||||
| Tool bridge (`ctx.tools.register()`) | Hook 5: Tool Bridge |
|
||||
| `ctx.ui.toast()`, `ctx.ui.openPreview()` | Primitive layer (unchanged API) |
|
||||
| `ctx.ui.isDark()`, `ctx.ui.isMobile()` | Theme layer queries |
|
||||
| `ctx.surfaces.getCurrent()` | Returns `window.__SURFACE__` |
|
||||
|
||||
The manifest schema gains optional new fields (`hooks`, `surfaces`,
|
||||
`surface_injections`, `theme`). Existing fields retain their meaning.
|
||||
|
||||
`Extensions.boot()` is the single entry point for extension
|
||||
initialization on every surface. It loads manifests, registers
|
||||
renderers, and initializes tool bridges — same sequence, available
|
||||
on every surface rather than only chat.
|
||||
|
||||
---
|
||||
|
||||
## Layer Model
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Banner (top) │ ← Platform config
|
||||
├──────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Surface │ ← Layout + lifecycle
|
||||
│ ┌─────────────┬──────────────────────────┐ │
|
||||
│ │ Component │ Component │ │ ← Domain-aware
|
||||
│ │ (ChatPane) │ (NoteEditor) │ │
|
||||
│ │ ┌─────────┐ │ ┌────────┐ ┌──────────┐ │ │
|
||||
│ │ │Primitive│ │ │Primitv.│ │Primitive │ │ │ ← Atomic UI
|
||||
│ │ │ (input) │ │ │ (menu) │ │ (toggle) │ │ │
|
||||
│ │ └─────────┘ │ └────────┘ └──────────┘ │ │
|
||||
│ └─────────────┴──────────────────────────┘ │
|
||||
│ │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Banner (bottom) │ ← Platform config
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Four layers, strict dependency direction: Primitives know nothing.
|
||||
Components use Primitives. Surfaces compose Components. Banners are
|
||||
global chrome outside the surface boundary.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1: Primitives
|
||||
|
||||
Atomic UI elements. No domain logic. A toggle doesn't know if it's
|
||||
toggling a KB or a theme — it takes a label, a state, and a callback.
|
||||
|
||||
Styled entirely via CSS custom properties (the theme contract). Every
|
||||
primitive reads from the same property namespace, so theme changes
|
||||
propagate instantly.
|
||||
|
||||
### Catalog
|
||||
|
||||
| Primitive | Description |
|
||||
|-----------|-------------|
|
||||
| `Input` | Text, textarea, number, password, with label + validation |
|
||||
| `Select` | Dropdown, single or multi |
|
||||
| `Toggle` | Boolean switch with label |
|
||||
| `Checkbox` | Checkbox with label |
|
||||
| `Button` | Text button, variants: primary, secondary, danger, ghost |
|
||||
| `IconButton` | Icon-only button with tooltip |
|
||||
| `ColorPicker` | Color input with hex text field |
|
||||
| `Menu` | Dropdown or context menu, item list with icons + shortcuts |
|
||||
| `Dialog` | Modal: confirm, prompt, or custom form content |
|
||||
| `Toast` | Transient notification: success, error, warning, info |
|
||||
| `Badge` | Inline label: accent, success, danger, warning, muted |
|
||||
| `Avatar` | Image circle with upload affordance |
|
||||
| `Tabs` | Tab bar with content switching |
|
||||
| `FormGroup` | Label + input + validation message + help text |
|
||||
| `SectionHeader` | Titled section divider |
|
||||
| `EmptyState` | Placeholder with icon + message + action |
|
||||
| `Spinner` | Loading indicator |
|
||||
| `Table` | Sortable, paginated data table |
|
||||
|
||||
### Implementation
|
||||
|
||||
Each primitive is a factory function that returns a DOM element (or
|
||||
attaches to an existing one). No classes, no inheritance — just
|
||||
functions.
|
||||
|
||||
```js
|
||||
// Example — not prescriptive API, just the pattern:
|
||||
Primitives.toggle({ label: 'Auto-search', value: true, onChange: fn })
|
||||
Primitives.menu({ anchor: el, items: [...], onSelect: fn })
|
||||
Primitives.dialog({ title: 'Confirm', body: el, onConfirm: fn })
|
||||
```
|
||||
|
||||
Current locations: `ui-primitives.js`, `ui-primitives-additions.js`,
|
||||
and Go template components (`model-select.html`, `team-select.html`,
|
||||
`file-upload.html`). These converge into one coherent set.
|
||||
|
||||
### Go Template Primitives
|
||||
|
||||
Some primitives need server-rendered initial state (e.g., model-select
|
||||
needs the model list, team-select needs the team list). These are Go
|
||||
template partials that render the HTML + initial data, then JS hydrates
|
||||
interactivity on load.
|
||||
|
||||
```html
|
||||
{{template "model-select" dict "ID" "channelModel" "Models" .Models "Type" "chat"}}
|
||||
```
|
||||
|
||||
The JS primitive attaches to the server-rendered DOM by ID, not by
|
||||
replacing it.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2: Components
|
||||
|
||||
Domain-aware compositions of primitives. Each component owns its DOM
|
||||
subtree, manages its own state, and exposes a clean API for the surface
|
||||
to interact with.
|
||||
|
||||
### Catalog
|
||||
|
||||
| Component | Primitives Used | Domain |
|
||||
|-----------|----------------|--------|
|
||||
| `ChatPane` | Input, Button, Menu, Spinner, Toast | Channels, Messages, Streaming |
|
||||
| `NoteEditor` | Input (CM6), Menu, Tabs, Badge | Notes, Wikilinks, Folders |
|
||||
| `FileTree` | Menu (context), Button, Spinner | Workspaces, Files |
|
||||
| `ModelSelector` | Select, Badge, Spinner | Models, Capabilities, Health |
|
||||
| `PersonaPicker` | Select, Badge, Avatar | Personas, Scopes |
|
||||
| `KBPicker` | Toggle, Badge, Select | Knowledge Bases, Discoverability |
|
||||
| `ProjectSidebar` | Menu, Tabs, Badge, EmptyState | Projects, Channels, DnD |
|
||||
| `AuditLog` | Table, Select, Badge | Audit, Filtering, Pagination |
|
||||
| `CodeEditor` | Input (CM6), Select, Tabs | Workspaces, Languages |
|
||||
| `SettingsForm` | FormGroup, Toggle, Select, Button | User/Admin Settings |
|
||||
|
||||
### Instance Pattern
|
||||
|
||||
Components use the factory pattern established by ChatPane:
|
||||
|
||||
```js
|
||||
const pane = ChatPane.create({
|
||||
messagesEl: document.getElementById('editorChatMessages'),
|
||||
inputEl: document.getElementById('editorChatInput'),
|
||||
sendBtnEl: document.getElementById('editorSendBtn'),
|
||||
channelId: workspaceChatId,
|
||||
standalone: true,
|
||||
});
|
||||
|
||||
// Lifecycle
|
||||
pane.renderMessages(msgs);
|
||||
pane.streamResponse(resp, msgs);
|
||||
pane.destroy();
|
||||
```
|
||||
|
||||
Components can be instantiated multiple times on the same page (editor
|
||||
has a ChatPane, notes has a ChatPane — independent instances).
|
||||
|
||||
### Server-Rendered Shell + JS Hydration
|
||||
|
||||
Components have a Go template partial for the server-rendered scaffold:
|
||||
|
||||
```html
|
||||
{{template "chat-pane" dict "ID" "editor"}}
|
||||
```
|
||||
|
||||
This renders the DOM structure with predictable IDs. The JS component
|
||||
attaches to these IDs on DOMContentLoaded. No client-side DOM
|
||||
construction for the initial layout.
|
||||
|
||||
---
|
||||
|
||||
## Layer 3: Surfaces
|
||||
|
||||
A surface is a full-page layout that composes components. Each surface
|
||||
declares:
|
||||
|
||||
1. **What components it uses** (ChatPane, NoteEditor, FileTree, etc.)
|
||||
2. **How they're arranged** (CSS grid/flex layout)
|
||||
3. **What data it needs on load** (Go data loader)
|
||||
4. **What JS runs on boot** (script block or module)
|
||||
|
||||
### Current Surfaces
|
||||
|
||||
| Surface | Components | Data Loader |
|
||||
|---------|-----------|-------------|
|
||||
| Chat | ChatPane, ProjectSidebar, ModelSelector, PersonaPicker | channels, personas, models, projects |
|
||||
| Editor | FileTree, CodeEditor, ChatPane (assist) | workspace, files, models |
|
||||
| Notes | NoteEditor, NoteGraph, ChatPane (assist) | notes, folders, graph |
|
||||
| Admin | AuditLog, SettingsForm, Table (various) | users, configs, models, health |
|
||||
| Settings | SettingsForm, KBPicker, PersonaPicker | profile, preferences, policies |
|
||||
|
||||
### Surface Registration
|
||||
|
||||
Today: hardcoded in Go (`pageEngine.RenderSurface("chat")`).
|
||||
|
||||
Future: manifest-driven. An admin-installed extension declares a
|
||||
surface in its manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"surfaces": [{
|
||||
"id": "triage-intake",
|
||||
"route": "/s/triage",
|
||||
"title": "Triage Intake",
|
||||
"components": ["chat-pane", "settings-form"],
|
||||
"data_requires": ["personas", "models"],
|
||||
"script": "surfaces/triage.js",
|
||||
"auth": "authenticated"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
The page engine reads registered surfaces, generates routes, assembles
|
||||
data loaders from the declared requirements, and renders a shell
|
||||
template that loads the surface's script.
|
||||
|
||||
**Route namespace:** Admin-created surfaces live under `/s/:slug` to
|
||||
avoid collision with core routes.
|
||||
|
||||
### Data Loaders
|
||||
|
||||
Each surface declares what data it needs. The page engine has a
|
||||
registry of data providers:
|
||||
|
||||
```
|
||||
"personas" → loads personas for the current user
|
||||
"models" → loads enabled models with health status
|
||||
"channels" → loads user's channels
|
||||
"workspace" → loads workspace by :wsId param
|
||||
"notes" → loads notes list
|
||||
...
|
||||
```
|
||||
|
||||
The surface's `data_requires` field pulls from this registry. Data is
|
||||
injected into the page as `window.__PAGE_DATA__` (existing pattern).
|
||||
The surface's JS reads from there on boot — no waterfall of API calls
|
||||
on page load.
|
||||
|
||||
---
|
||||
|
||||
## Layer 4: Banners
|
||||
|
||||
The banner is global chrome outside the surface boundary. It exists
|
||||
at the top, bottom, or both — configured by the platform admin via
|
||||
`PUT /admin/settings/banner`.
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"text": "DEVELOPMENT",
|
||||
"position": "both|top|bottom",
|
||||
"bg": "#007a33",
|
||||
"fg": "#ffffff"
|
||||
}
|
||||
```
|
||||
|
||||
The Go template base layout renders banners before and after the
|
||||
surface content area. CSS custom properties (`--banner-top-height`,
|
||||
`--banner-bottom-height`, `--banner-bg`, `--banner-fg`) allow surfaces
|
||||
to account for banner space without knowing banner state.
|
||||
|
||||
Banners are not configurable by extensions or themes. They are a
|
||||
platform-level trust signal.
|
||||
|
||||
---
|
||||
|
||||
## Themes
|
||||
|
||||
A theme is a set of CSS custom property overrides applied to `<html>`.
|
||||
|
||||
```css
|
||||
[data-theme="corporate"] {
|
||||
--bg: #f5f5f5;
|
||||
--text: #1a1a1a;
|
||||
--accent: #0066cc;
|
||||
--border: #d1d5db;
|
||||
--font-ui: 'Inter', sans-serif;
|
||||
--font-code: 'Fira Code', monospace;
|
||||
--radius: 4px;
|
||||
/* ... full property set */
|
||||
}
|
||||
```
|
||||
|
||||
### What a Theme Can Do
|
||||
|
||||
- Override any CSS custom property in the theme contract
|
||||
- Change colors, fonts, border radii, spacing scale
|
||||
- Switch between light and dark base palettes
|
||||
- Apply to every primitive, component, and surface uniformly
|
||||
|
||||
### What a Theme Cannot Do
|
||||
|
||||
- Add or remove DOM elements
|
||||
- Execute JavaScript
|
||||
- Modify component behavior or layout
|
||||
- Override banner appearance (platform chrome)
|
||||
- Access APIs or user data
|
||||
|
||||
### Theme Contract
|
||||
|
||||
The set of CSS custom properties that all primitives read from. This
|
||||
is the stable API between themes and the UI. Properties are namespaced:
|
||||
|
||||
```
|
||||
--bg, --bg-secondary, --bg-tertiary (backgrounds)
|
||||
--text, --text-secondary, --text-muted (typography)
|
||||
--accent, --accent-hover, --accent-muted (interactive)
|
||||
--border, --border-strong (edges)
|
||||
--success, --warning, --danger (semantic)
|
||||
--font-ui, --font-code (typefaces)
|
||||
--radius, --radius-lg (shapes)
|
||||
--shadow, --shadow-lg (elevation)
|
||||
--banner-bg, --banner-fg (read-only, set by platform)
|
||||
```
|
||||
|
||||
Themes are admin-installed. An admin uploads a CSS file that declares
|
||||
a `[data-theme="name"]` rule set. Users can select from installed
|
||||
themes in Settings → Appearance.
|
||||
|
||||
Built-in themes: `dark` (default), `light`. The system preference
|
||||
auto-detection (`prefers-color-scheme`) continues to work.
|
||||
|
||||
---
|
||||
|
||||
## Extension Hooks
|
||||
|
||||
Extensions interact with the application through a defined set of
|
||||
hooks. Each hook has a specific trigger point in the lifecycle and
|
||||
a clear contract for what the extension receives and returns.
|
||||
|
||||
### Hook 1: Pre-Completion
|
||||
|
||||
**When:** After the user sends a message, before the API request fires.
|
||||
**Receives:** The completion request object (model, messages, tools, etc.)
|
||||
**Returns:** Modified request object (or unmodified to pass through).
|
||||
**Use case:** Inject additional tools, modify system prompt, add context.
|
||||
|
||||
```js
|
||||
ctx.hooks.preCompletion(request => {
|
||||
request.tools.push(myCustomTool);
|
||||
return request;
|
||||
});
|
||||
```
|
||||
|
||||
### Hook 2: Stream Processing
|
||||
|
||||
**When:** On each SSE chunk during streaming.
|
||||
**Receives:** The chunk (content delta, tool_use, tool_result, etc.)
|
||||
**Returns:** Modified chunk (or null to suppress).
|
||||
**Use case:** Transform content, intercept tool calls, accumulate data.
|
||||
|
||||
```js
|
||||
ctx.hooks.streamChunk((chunk, context) => {
|
||||
if (chunk.type === 'tool_result' && chunk.name === 'image_gen') {
|
||||
context.displayContent.push({ type: 'image', src: chunk.data.url });
|
||||
return null; // suppress from text content
|
||||
}
|
||||
return chunk;
|
||||
});
|
||||
```
|
||||
|
||||
### Hook 3: Post-Render
|
||||
|
||||
**When:** After a message is rendered into the DOM.
|
||||
**Receives:** The message container element, the message object.
|
||||
**Returns:** Nothing (mutates DOM in place).
|
||||
**Use case:** Add action buttons, wrap elements, enhance display.
|
||||
|
||||
This is how extensions compose with each other's output. Extension A
|
||||
produces an image via display content. Extension B's post-render hook
|
||||
finds `<img>` elements and wraps them with action buttons. They don't
|
||||
know about each other — they agree on the DOM contract.
|
||||
|
||||
```js
|
||||
ctx.hooks.postRender((containerEl, message) => {
|
||||
containerEl.querySelectorAll('img[data-display-content]').forEach(img => {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'image-actions';
|
||||
actions.innerHTML = '<button data-action="upscale">Upscale</button>';
|
||||
img.parentElement.appendChild(actions);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Hook 4: Display Content
|
||||
|
||||
**When:** After a completion finishes (all chunks received).
|
||||
**Receives:** The assistant message object, accumulated display content.
|
||||
**Returns:** Display content items to attach to the message.
|
||||
|
||||
Display content is **message-scoped, rendered inline, but excluded from
|
||||
the LLM context window**. It's not in `messages.content` and not sent
|
||||
back on the next turn.
|
||||
|
||||
```
|
||||
Message
|
||||
├── content (text — goes to LLM)
|
||||
├── attachments (files — go to LLM via multimodal assembly)
|
||||
└── display_content[] ← rendered inline, NOT sent to LLM
|
||||
├── { type: "image", src: "...", extension_id: "img-gen" }
|
||||
└── { type: "html", content: "<div>...", extension_id: "editor" }
|
||||
```
|
||||
|
||||
**Storage:** Display content is persisted in a `display_content` JSONB
|
||||
column on the message (or a junction table). It survives page reload
|
||||
and is included in `GET /channels/:id/path` responses.
|
||||
|
||||
### Hook 5: Tool Bridge
|
||||
|
||||
**When:** The LLM invokes a tool registered by the extension.
|
||||
**Receives:** Tool name and input parameters.
|
||||
**Returns:** Tool result (string or structured).
|
||||
|
||||
Existing mechanism — the WebSocket tool bridge from EXTENSIONS.md.
|
||||
Tool is registered via manifest, exposed to the LLM through the
|
||||
tools list, and executed client-side (browser tier) or server-side
|
||||
(starlark/sidecar tiers).
|
||||
|
||||
### Hook 6: Surface Injection
|
||||
|
||||
**When:** Surface boot (DOMContentLoaded).
|
||||
**Receives:** The surface ID and available mount points.
|
||||
**Returns:** Nothing (attaches to mount points).
|
||||
|
||||
Extensions declare which surfaces they target and which mount points
|
||||
they use:
|
||||
|
||||
```json
|
||||
{
|
||||
"surface_injections": [{
|
||||
"surface": "chat",
|
||||
"mount_point": "side-panel",
|
||||
"component": "my-panel.js"
|
||||
}, {
|
||||
"surface": "admin",
|
||||
"mount_point": "section",
|
||||
"section_id": "my-admin-section",
|
||||
"label": "Image Gen Settings"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Mount points are declared by each surface template:
|
||||
|
||||
```html
|
||||
<div data-mount="side-panel"></div>
|
||||
<div data-mount="section" data-section-id="..."></div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trust Model
|
||||
|
||||
Two tiers. The admin is the trust boundary.
|
||||
|
||||
### Privileged (Admin-Installed)
|
||||
|
||||
| Capability | Allowed |
|
||||
|-----------|---------|
|
||||
| Full DOM access | Yes |
|
||||
| Direct API calls (user's JWT) | Yes |
|
||||
| Modify other extensions' output (post-render) | Yes |
|
||||
| Create surfaces (own routes) | Yes |
|
||||
| Register tools | Yes |
|
||||
| Access all hook types | Yes |
|
||||
| Go template data loader | Yes |
|
||||
| Read `window.__PAGE_DATA__` | Yes |
|
||||
|
||||
The admin chose to install it. Same trust model as a VS Code extension
|
||||
or a WordPress plugin — you trust the publisher.
|
||||
|
||||
### Sandboxed (User-Installed, Future)
|
||||
|
||||
| Capability | Allowed |
|
||||
|-----------|---------|
|
||||
| Shadow DOM / iframe only | Yes |
|
||||
| API calls through message bridge | Yes (proxied, scoped) |
|
||||
| Modify other extensions' output | No |
|
||||
| Create surfaces | No |
|
||||
| Register tools | Limited (user-scoped) |
|
||||
| Access hooks | Post-render own output only |
|
||||
| Go template data loader | No |
|
||||
| Read `window.__PAGE_DATA__` | No |
|
||||
|
||||
Post-1.0 scope. Documented here to confirm the architecture
|
||||
accommodates it without redesign.
|
||||
|
||||
---
|
||||
|
||||
## Extension Lifecycle
|
||||
|
||||
### Boot Sequence
|
||||
|
||||
```
|
||||
1. Page loads (Go template renders surface shell + banner)
|
||||
2. Primitives initialize (CSS loaded, JS factories available)
|
||||
3. Components hydrate (attach to server-rendered DOM)
|
||||
4. Extensions.boot() — idempotent, runs on every surface
|
||||
a. Load extension manifests
|
||||
b. Register hooks (pre-completion, post-render, etc.)
|
||||
c. Register surface injections for current surface
|
||||
d. Initialize tool bridges
|
||||
5. Surface-specific JS runs (app.js for chat, editor-mode.js, etc.)
|
||||
```
|
||||
|
||||
`Extensions.boot()` replaces the current chat-specific
|
||||
`Extensions.loadAll()` / `Extensions.initAll()` pair and runs on
|
||||
every surface.
|
||||
|
||||
### Manifest Declaration
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "image-generator",
|
||||
"name": "AI Image Generator",
|
||||
"version": "1.0.0",
|
||||
"tier": "browser",
|
||||
"hooks": {
|
||||
"pre_completion": "hooks/pre-completion.js",
|
||||
"stream_chunk": "hooks/stream.js",
|
||||
"post_render": "hooks/post-render.js"
|
||||
},
|
||||
"tools": [{
|
||||
"name": "generate_image",
|
||||
"description": "Generate an image from a text description",
|
||||
"parameters": { ... }
|
||||
}],
|
||||
"surfaces": [{
|
||||
"id": "image-gallery",
|
||||
"route": "/s/gallery",
|
||||
"title": "Image Gallery",
|
||||
"components": ["chat-pane"],
|
||||
"data_requires": ["channels"],
|
||||
"script": "surfaces/gallery.js"
|
||||
}],
|
||||
"surface_injections": [{
|
||||
"surface": "chat",
|
||||
"mount_point": "message-actions",
|
||||
"script": "injections/image-actions.js"
|
||||
}],
|
||||
"theme": null,
|
||||
"settings_schema": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Display Content — Data Model
|
||||
|
||||
### Message Extension
|
||||
|
||||
```sql
|
||||
ALTER TABLE messages ADD COLUMN display_content JSONB DEFAULT '[]';
|
||||
```
|
||||
|
||||
Array of display items:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type": "image",
|
||||
"src": "data:image/png;base64,...",
|
||||
"alt": "A cat in a spacesuit",
|
||||
"extension_id": "image-generator",
|
||||
"metadata": { "model": "dall-e-3", "size": "1024x1024" }
|
||||
},
|
||||
{
|
||||
"type": "html",
|
||||
"content": "<div class='chart'>...</div>",
|
||||
"extension_id": "data-viz",
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### API Contract
|
||||
|
||||
Display content is included in message objects returned by
|
||||
`GET /channels/:id/path` and `GET /channels/:id/messages`.
|
||||
|
||||
It is **excluded** from the message array sent to the LLM provider
|
||||
in `POST /chat/completions`. The completion handler strips
|
||||
`display_content` when assembling the provider request.
|
||||
|
||||
### Rendering
|
||||
|
||||
During `renderMessages()`, after the message content is rendered,
|
||||
each `display_content` item is rendered below the text content:
|
||||
|
||||
```html
|
||||
<div class="message-content">
|
||||
<p>Here's the image you requested:</p>
|
||||
</div>
|
||||
<div class="message-display-content">
|
||||
<img src="..." alt="..." data-display-content data-extension="image-generator">
|
||||
</div>
|
||||
```
|
||||
|
||||
The `data-display-content` attribute is the DOM contract that
|
||||
post-render hooks use to discover display content from any extension.
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
| Current State | Target State |
|
||||
|---------------|-------------|
|
||||
| `ui-primitives.js` + `ui-primitives-additions.js` | Primitive catalog with stable factory API |
|
||||
| `ChatPane.create()` (v0.22.7) | Component catalog with shared instance pattern |
|
||||
| 5 Go template routes | Surface registry (hardcoded core, manifest-driven extensions) |
|
||||
| `ctx.ui.replace()` / `ctx.ui.inject()` | Hook 6: Surface Injection with declared mount points |
|
||||
| `ctx.surfaces.register()` | Manifest `surfaces` field → page engine route registration |
|
||||
| Block renderers in `Extensions.initAll()` | `Extensions.boot()` on every surface |
|
||||
| Message content only (text + attachments) | `display_content` column for render-only visual content |
|
||||
| Block renderer pipeline (mermaid, katex) | Post-render hook + DOM contract (`data-display-content`) |
|
||||
| `[data-theme]` with CSS variables | Theme contract: stable CSS custom property namespace |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
This does not prescribe version numbers. It describes dependency order.
|
||||
|
||||
**Phase 1: Primitive consolidation**
|
||||
- Audit `ui-primitives.js`, `ui-primitives-additions.js`, and Go
|
||||
template components
|
||||
- Define the primitive catalog and factory API
|
||||
- Define the CSS custom property contract (theme API)
|
||||
- Migrate existing callers to the consolidated API
|
||||
|
||||
**Phase 2: Component formalization**
|
||||
- ChatPane already exists (v0.22.7). Formalize the instance pattern.
|
||||
- Extract NoteEditor, FileTree, ModelSelector as components
|
||||
- Each component gets a Go template partial + JS hydration
|
||||
|
||||
**Phase 3: Extension hooks**
|
||||
- `Extensions.boot()` on every surface (v0.22.8)
|
||||
- Pre-completion and post-render hooks
|
||||
- Display content data model (message column + render pipeline)
|
||||
- Stream processing hook
|
||||
|
||||
**Phase 4: Surface registry**
|
||||
- Manifest-driven surface registration
|
||||
- Dynamic route generation in page engine
|
||||
- Data loader registry
|
||||
- Mount point declaration and injection
|
||||
|
||||
**Phase 5: Theme system**
|
||||
- Stable CSS custom property contract
|
||||
- Admin theme upload
|
||||
- User theme selection in Settings → Appearance
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Display content storage:** JSONB column on messages vs. separate
|
||||
`message_display_content` table. Column is simpler; table allows
|
||||
independent lifecycle (delete display content without touching
|
||||
message). Leaning column for KISS.
|
||||
|
||||
2. **Display content size limits:** Base64 images in JSONB could get
|
||||
large. May need blob storage for display content with URL references
|
||||
instead of inline data. Deferred until real usage patterns emerge.
|
||||
|
||||
3. **Extension ordering:** Post-render hooks from multiple extensions
|
||||
run in what order? Manifest priority field? Installation order?
|
||||
Alphabetical? Matters when Extension B adds buttons to Extension A's
|
||||
images — A must render first.
|
||||
|
||||
4. **Surface layout persistence:** If a surface supports resizable
|
||||
panes (editor: file tree width, chat pane width), where is that
|
||||
persisted? User settings? Per-workspace? Per-surface?
|
||||
|
||||
5. **Extension settings storage:** Extensions need per-user config
|
||||
(API keys for image gen, preferences for behavior). Current
|
||||
`UpdateUserExtensionSettings` endpoint exists. Is the schema
|
||||
sufficient or does it need structured validation from
|
||||
`settings_schema` in the manifest?
|
||||
2225
docs/ICD-API.md
Normal file
2225
docs/ICD-API.md
Normal file
File diff suppressed because it is too large
Load Diff
265
docs/ICD-AUDIT.md
Normal file
265
docs/ICD-AUDIT.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# ICD Audit: API Contract vs ROADMAP / ARCHITECTURE
|
||||
|
||||
**Date:** 2026-03-03 | **Baseline:** v0.22.7
|
||||
**Scope:** Cross-reference ICD-API.md against ROADMAP.md, ARCHITECTURE.md, and live codebase.
|
||||
**Status:** Items §2 (naming), §3 (cleanup) resolved in v0.22.8 naming cleanup pass. See ICD-API.md (domain-organized v2) and DESIGN-SURFACES.md.
|
||||
|
||||
---
|
||||
|
||||
## 1. ARCHITECTURE.md — Staleness Report
|
||||
|
||||
ARCHITECTURE.md is stamped **v0.20** — six minor versions behind. The following sections are materially stale:
|
||||
|
||||
### 1.1 Frontend File Structure (§ Frontend Architecture)
|
||||
|
||||
ARCHITECTURE lists 19 JS files. Codebase has **34**. Fifteen files are undocumented:
|
||||
|
||||
| Missing from ARCHITECTURE | Added In | Purpose |
|
||||
|---------------------------|----------|---------|
|
||||
| `admin-scaffold.js` | v0.22.7 | Admin section DOM scaffolding |
|
||||
| `channel-models.js` | v0.20.0 | Multi-model channel roster UI |
|
||||
| `chat-pane.js` | v0.22.7 | Reusable ChatPane component |
|
||||
| `editor-mode.js` | v0.21.5 | Editor surface (listed but not in file tree) |
|
||||
| `extensions.js` | v0.11.0 | Extension loader |
|
||||
| `notification-prefs.js` | v0.20.0 | Notification preference UI |
|
||||
| `notifications.js` | v0.20.0 | Bell dropdown + badge |
|
||||
| `pages.js` | v0.22.5 | Page-level auth + routing |
|
||||
| `pages-splash.js` | v0.22.7 | Login/register surface |
|
||||
| `panels.js` | v0.18.1 | Side panel architecture |
|
||||
| `persona-kb.js` | v0.17.0 | Persona-KB binding UI |
|
||||
| `repl.js` | v0.21.3 | REPL surface |
|
||||
| `surface-nav.js` | v0.22.8 | Surface navigation overrides |
|
||||
| `tools-toggle.js` | v0.13.1 | Per-tool enable/disable UI |
|
||||
| `ui-format.js` | v0.22.7 | Message formatting helpers |
|
||||
| `ui-primitives.js` | v0.10.5 | Shared render primitives |
|
||||
| `ui-primitives-additions.js` | v0.22.7 | Toast, badge, icon extensions |
|
||||
|
||||
ARCHITECTURE also lists `knowledge.js` — **renamed** to `knowledge-ui.js`.
|
||||
|
||||
### 1.2 SPA Shell References
|
||||
|
||||
ARCHITECTURE §Deployment Modes still says:
|
||||
|
||||
> "The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html`"
|
||||
|
||||
And the file tree lists:
|
||||
|
||||
> `├── index.html # Single-page app shell`
|
||||
|
||||
Both are wrong since v0.22.6. `index.html` is now a 16-line redirect stub. The real entry points are Go-rendered surface templates. The entrypoint now generates `proxy_pass` blocks to the backend for surface routes.
|
||||
|
||||
### 1.3 Deployment Diagram
|
||||
|
||||
The split-deployment description is functionally correct but the mechanism is wrong. Frontend no longer serves an SPA — it reverse-proxies page routes to the backend, which renders Go templates. The diagram should show the backend serving both API and HTML.
|
||||
|
||||
### 1.4 Backward Compatibility Section
|
||||
|
||||
The section says:
|
||||
|
||||
> "JSON field rename: `api_config_id` → `provider_config_id`... Frontend updated to match."
|
||||
|
||||
This is now ancient history (v0.9). The rename is complete everywhere. This section should either be removed or reduced to a single sentence noting historical context.
|
||||
|
||||
### 1.5 Missing Sections
|
||||
|
||||
ARCHITECTURE has **no documentation** for:
|
||||
|
||||
- Go template engine (`server/pages/`)
|
||||
- Surface architecture (5 surfaces: chat, editor, notes, admin, settings)
|
||||
- Template components (`model-select`, `team-select`, `file-upload`, `chat-pane`)
|
||||
- Page data loaders (`loaders.go`)
|
||||
- Provider Extensions / hook system (v0.22.1)
|
||||
- Routing policies (v0.22.2)
|
||||
- Export system (v0.22.4)
|
||||
- Git integration (v0.21.4)
|
||||
- The `treepath/` package (cursor, path, siblings)
|
||||
|
||||
**Recommendation:** Bump to v0.22 and rewrite §Frontend Architecture, §Deployment Modes, and §Backward Compatibility. Add §Template Engine, §Surface Architecture, §Provider Extensions sections.
|
||||
|
||||
---
|
||||
|
||||
## 2. ROADMAP.md — Housekeeping
|
||||
|
||||
### 2.1 Naming Inconsistency: Presets vs Personas
|
||||
|
||||
The ROADMAP uses "Persona" consistently. The API routes use `/personas`. The Go handler type is `PersonaHandler`. The JSON response sends **both** `"personas"` and `"presets"` keys (backward compat). The frontend reads the `presets` key exclusively.
|
||||
|
||||
This is a naming barnacle that bleeds into every layer:
|
||||
|
||||
| Layer | Name Used |
|
||||
|-------|-----------|
|
||||
| ROADMAP / ARCHITECTURE | Persona |
|
||||
| Go types | `Persona`, `PersonaPatch` |
|
||||
| Go handler | `PersonaHandler` |
|
||||
| API routes | `/personas`, `/admin/personas` |
|
||||
| JSON response keys | `"personas"` AND `"presets"` (dual) |
|
||||
| Frontend JS | `presets` (reads `data.presets`) |
|
||||
| UI labels | Mixed ("Preset" in some places, "Persona" in others) |
|
||||
|
||||
**Recommendation:** Pick one. The domain concept is "Persona" (it carries identity, system prompt, model, KB bindings — it's not just a preset). Two options:
|
||||
|
||||
- **Option A — Route rename**: `/personas` → `/personas`. Update FE. Remove dual JSON keys. This is a clean break and pre-1.0 is the time to do it.
|
||||
- **Option B — Accept "presets" everywhere**: Rename Go types to `Preset`, update docs. Simpler but loses semantic precision.
|
||||
|
||||
### 2.2 `chat_id` Backward Compat Alias
|
||||
|
||||
`completionRequest` has:
|
||||
|
||||
```go
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
```
|
||||
|
||||
The frontend doesn't send `chat_id` anymore. This is dead code in the handler that adds 3 lines of aliasing logic. Safe to remove.
|
||||
|
||||
### 2.3 Go Field Name Barnacle: `APIConfigID`
|
||||
|
||||
Throughout `channels.go` and `completion.go`, the Go struct field is named `APIConfigID` with json tag `"provider_config_id"`. The JSON tag is correct, so this is invisible to the API — but it's a source of confusion for anyone reading the Go code. Rename the Go field to `ProviderConfigID` for consistency.
|
||||
|
||||
### 2.4 Dual JSON Keys on Persona List
|
||||
|
||||
Three `PersonaHandler` methods return:
|
||||
|
||||
```go
|
||||
gin.H{"personas": personas, "presets": personas}
|
||||
```
|
||||
|
||||
The `"presets"` key exists solely for backward compat that was needed in v0.9. The FE only reads `"presets"`. If we go with Option A (§2.1), remove the `"presets"` key. If Option B, remove the `"personas"` key. Either way, stop sending both.
|
||||
|
||||
### 2.5 Route Alias: `/models` → `/models/enabled`
|
||||
|
||||
`main.go` has:
|
||||
|
||||
```go
|
||||
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
||||
protected.GET("/models", modelH.ListEnabledModels) // alias
|
||||
```
|
||||
|
||||
Plus the admin has its own `/admin/models`. Having `/models` as an alias for `/models/enabled` is confusing when `/admin/models` is a completely different thing. The ICD documents both, but one should go away. The FE uses `listEnabledModels()` → `/models/enabled`. The alias is unused.
|
||||
|
||||
### 2.6 ROADMAP v0.22.8 — Status vs Reality
|
||||
|
||||
The ROADMAP shows v0.22.8 as the next incomplete version with checkboxes for ChatPane Bridge, Theme Integration, Admin Surface, and Settings Surface. The last two sessions partially implemented some of these (admin scaffold, surface-nav, prototype overrides) but things broke and were rolled back/lost. The ROADMAP doesn't reflect the partial state — it still shows all items as unchecked.
|
||||
|
||||
**Recommendation:** After the ICD is finalized and the FE rebuild begins properly, update v0.22.8 checkboxes to reflect actual completion. Consider splitting v0.22.8 into smaller increments since it's become a catch-all.
|
||||
|
||||
---
|
||||
|
||||
## 3. ICD Gaps (things the backend implements but ICD doesn't fully cover)
|
||||
|
||||
### 3.1 Admin Models — Specific Endpoints
|
||||
|
||||
The ICD §24.5 lists admin model routes but doesn't document request/response shapes for:
|
||||
|
||||
- `POST /admin/models/fetch` — triggers provider sync, returns updated catalog
|
||||
- `PUT /admin/models/bulk` — bulk visibility updates (request shape)
|
||||
- `PUT /admin/models/:id` — single model update (what fields?)
|
||||
|
||||
**Fix:** Extract shapes from `admin.go` handler methods and add to ICD.
|
||||
|
||||
### 3.2 Admin Stats Response Shape
|
||||
|
||||
ICD §24.3 says "returns aggregate counts" but doesn't specify the shape. Should document:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": 42,
|
||||
"channels": 156,
|
||||
"messages": 12847,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Admin Global Config — configWithKey Shape
|
||||
|
||||
ICD §24.4 says admin configs include `has_key` flag but doesn't show the full shape. The actual `configWithKey` response differs from the user-facing `safeConfig` — it includes `config`, `headers`, `settings` objects.
|
||||
|
||||
### 3.4 Team Provider Models
|
||||
|
||||
`GET /teams/:teamId/providers/:id/models` is in `main.go` but not clearly called out in the ICD §18.2 team admin section.
|
||||
|
||||
### 3.5 Team Audit Endpoints
|
||||
|
||||
`GET /teams/:teamId/audit` and `GET /teams/:teamId/audit/actions` are registered but not in the ICD team section.
|
||||
|
||||
### 3.6 Knowledge Base — Missing Detail
|
||||
|
||||
ICD §14 doesn't document:
|
||||
|
||||
- `GET /channels/:id/knowledge-bases` response shape (array of `ChannelKB`)
|
||||
- `PUT /channels/:id/knowledge-bases` request shape
|
||||
- `GET /knowledge-bases-discoverable` vs `GET /knowledge-bases` distinction
|
||||
- KB document status progression: `pending → chunking → embedding → ready | error`
|
||||
|
||||
### 3.7 Export Endpoint Shape
|
||||
|
||||
ICD §21.2 has the right route but should note that `pandoc` must be available in the container. Also missing: supported format list beyond pdf/docx (the handler may accept more).
|
||||
|
||||
---
|
||||
|
||||
## 4. ICD Barnacles (things in the ICD that should be cleaned up)
|
||||
|
||||
### 4.1 `chat_id` Alias
|
||||
|
||||
The ICD §5.1 notes `chat_id` as a deprecated alias in the completion request. Once the dead code is removed from the handler, remove from ICD.
|
||||
|
||||
### 4.2 Legacy ListMessages
|
||||
|
||||
ICD §4.2 documents `GET /channels/:id/messages` as "Legacy/Debug." If it's truly not for frontend use, consider either removing the route or moving it to the admin namespace. Currently it's in the protected (user) namespace, which means any user can dump all messages (including branches) for their channels.
|
||||
|
||||
---
|
||||
|
||||
## 5. ROADMAP/ARCHITECTURE Reorganization Suggestions
|
||||
|
||||
### 5.1 ARCHITECTURE Should Reference the ICD
|
||||
|
||||
Instead of maintaining API route tables in multiple places, ARCHITECTURE should say "See ICD-API.md for the complete endpoint reference" and focus on:
|
||||
|
||||
- Design principles, scope model, resolution chains (what it does well today)
|
||||
- Package structure (update to v0.22)
|
||||
- Frontend architecture (rewrite for surfaces/templates)
|
||||
- Security model (mostly current)
|
||||
- New: Template engine, provider extensions, routing
|
||||
|
||||
### 5.2 ROADMAP Completed Versions — Collapse
|
||||
|
||||
The ROADMAP has 900+ lines of completed release details (v0.9 through v0.22.7). These are duplicated in CHANGELOG.md. The ROADMAP should collapse completed versions to one-liners (as it does for v0.9.x through v0.16.0) and push detailed history to CHANGELOG.
|
||||
|
||||
Currently v0.17.0 through v0.22.7 have full checkbox-level detail in the ROADMAP — that's useful while in-progress but noise once shipped. Collapse them.
|
||||
|
||||
### 5.3 Three-Document Model
|
||||
|
||||
Post-audit, the documentation should be:
|
||||
|
||||
| Document | Audience | Content |
|
||||
|----------|----------|---------|
|
||||
| `ARCHITECTURE.md` | Developers | Design principles, package structure, data model, resolution chains, security, deployment. **No API details.** |
|
||||
| `ICD-API.md` | FE developers, integrators | Complete API contract: routes, shapes, auth, streaming, WebSocket. |
|
||||
| `ROADMAP.md` | Stakeholders, contributors | Version plan, dependency graph, future features. Completed versions collapsed to one-liners with CHANGELOG refs. |
|
||||
|
||||
Future additions:
|
||||
|
||||
| Document | Audience | Content |
|
||||
|----------|----------|---------|
|
||||
| `ICD-SURFACE.md` | FE developers | Surface contract: templates, PageData, DOM IDs, JS globals, lifecycle. |
|
||||
| `ICD-EXTENSION.md` | Extension authors | Extension contract: manifest, tiers, hooks, tool bridge, asset serving. |
|
||||
|
||||
### 5.4 ROADMAP Dependency Graph — Update
|
||||
|
||||
The ASCII dependency graph stops at v0.22.7. The v0.22.8 → v0.23.0 → v0.24.0 path should be added.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cleanup Punch List
|
||||
|
||||
Priority-ordered actions that could all happen in a single commit:
|
||||
|
||||
| # | Action | Risk | Lines Changed |
|
||||
|---|--------|------|---------------|
|
||||
| 1 | Remove `chat_id` alias from `completionRequest` | None — FE doesn't use it | ~5 |
|
||||
| 2 | Rename Go field `APIConfigID` → `ProviderConfigID` in channels.go + completion.go | None — json tags unchanged | ~30 |
|
||||
| 3 | Pick persona vs preset naming, remove dual JSON keys | Low — FE reads one key | ~10 |
|
||||
| 4 | Remove `/models` alias (keep `/models/enabled` only) | Low — check FE uses | ~1 |
|
||||
| 5 | Bump ARCHITECTURE.md to v0.22, rewrite §Frontend | Medium — documentation | ~100 |
|
||||
| 6 | Collapse ROADMAP completed versions (v0.17–v0.22.7) to one-liners | None — CHANGELOG has detail | ~600 removed |
|
||||
| 7 | Add missing ICD shapes (§3 above) | None — documentation | ~80 added |
|
||||
| 8 | Update ICD to remove chat_id and document any cleanup from #1–#4 | None | ~10 |
|
||||
@@ -109,6 +109,11 @@ v0.22.5 Surfaces + Go Templates + Admin Bug Fixes ✅
|
||||
│
|
||||
v0.22.6 Split Deployment Fix + CI Timeout + Code Pruning ✅
|
||||
│
|
||||
|
|
||||
v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅
|
||||
|
|
||||
v0.22.8 Surface Integration (Wire into existing JS)
|
||||
|
|
||||
v0.23.0 Multi-Participant Channels + Presence
|
||||
│
|
||||
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
|
||||
@@ -1102,6 +1107,97 @@ replaced by server-rendered surfaces.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## v0.22.7 — Surface Prototypes (Theme + ChatPane + Splash) ✅
|
||||
|
||||
Foundation CSS/JS for the v0.22.5 surface architecture: theme system,
|
||||
reusable chat pane component, splash/login redesign, and UI primitive
|
||||
extensions. All files target the correct tree locations established in
|
||||
v0.22.5. Settings surface gains BYOK and User Personas feature gates.
|
||||
|
||||
Depends on: surfaces + Go templates (v0.22.5), code pruning (v0.22.6).
|
||||
|
||||
**Theme System:**
|
||||
- [x] `theme.css`: CSS custom properties for light/dark themes via `[data-theme]` attribute
|
||||
- [x] System preference auto-detection (`prefers-color-scheme` media query)
|
||||
- [x] Variable bridge: new vars (`--bg`, `--text`) alias old vars (`--bg-primary`, `--text-primary`)
|
||||
- [x] Shared component styles: toast stack, badges, buttons, icon buttons, form fields, confirm dialog, theme toggle, avatar upload, section headers
|
||||
- [x] Surface layout styles: splash, chat-pane, admin, settings, editor
|
||||
- [x] Google Fonts: DM Sans (UI) + JetBrains Mono (code)
|
||||
- [x] `base.html`: `data-theme` on `<html>`, `theme.css` before `styles.css`
|
||||
|
||||
**ChatPane Component:**
|
||||
- [x] `chat-pane.js`: factory pattern — `ChatPane.create(opts)` returns self-contained instance
|
||||
- [x] Instance API: renderMessages, appendChunk, finalizeStream, appendTyping, removeTyping, scrollToBottom, showWelcome, clear, getInputValue, setInputValue, focusInput, destroy
|
||||
- [x] Lookup: `ChatPane.get(id)`, `ChatPane.forChannel(channelId)`, `ChatPane.active()`
|
||||
- [x] `components/chat-pane.html`: Go template partial for server-rendered mount points
|
||||
- [x] Editor surface: `chat-pane` template in assist pane with `ChatPane.create()` boot
|
||||
|
||||
**Splash / Login:**
|
||||
- [x] `login.html` rewritten: split hero + auth card layout
|
||||
- [x] Animated grid canvas (40px grid, scrolling offset, `--border` color)
|
||||
- [x] Auth tabs (Login / Register) with server-gated registration
|
||||
- [x] Register form: username, display name, email, password, avatar upload
|
||||
- [x] `pages-splash.js`: `Pages.initSplash()`, grid animation, POST login, POST register + PUT avatar
|
||||
- [x] `PageData` gains `InstanceName`, `LogoURL`, `Tagline`, `RegistrationOpen`
|
||||
- [x] `loadBranding()` + `isRegistrationOpen()` from GlobalConfig
|
||||
|
||||
**UI Primitives Additions:**
|
||||
- [x] `Toast`: show/success/error/warning/info with auto-dismiss, stacking
|
||||
- [x] `renderBadge()`: 6 color variants (accent, success, danger, warning, purple, muted)
|
||||
- [x] `renderIcon()`: 20+ SVG icon set
|
||||
- [x] `renderIconBtn()`: icon button factory with active state
|
||||
- [x] `Theme`: init/set/get/resolved/renderToggle with localStorage
|
||||
- [x] `showConfirmDialog()`: enhanced with variant, onConfirm/onCancel
|
||||
- [x] `mountAvatarUpload()`: file input with preview, onUpload callback
|
||||
|
||||
**Settings Surface Gates:**
|
||||
- [x] BYOK gate: My Providers, Model Roles, Usage tabs shown when `user_providers.enabled`
|
||||
- [x] User Personas gate: My Personas tab shown when `user_presets.enabled`
|
||||
- [x] Models tab added to base navigation
|
||||
- [x] `SettingsPageData` gains `BYOKEnabled`, `UserPersonasEnabled` from GlobalConfig
|
||||
- [x] BYOK status indicator in nav footer
|
||||
|
||||
**Go Changes:**
|
||||
- [x] `PageData`: Theme, SurfaceSettings, InstanceName, LogoURL, Tagline, RegistrationOpen
|
||||
- [x] `Render()` defaults Theme to "dark"
|
||||
- [x] `RenderLogin()` populates splash fields from GlobalConfig
|
||||
- [x] `settingsLoader()` reads feature gates
|
||||
|
||||
---
|
||||
|
||||
## v0.22.8 — Surface Integration (Wire into Existing JS)
|
||||
|
||||
Connect v0.22.7 prototypes to the existing frontend JS modules.
|
||||
ChatPane.primary replaces singleton UI.* calls. Theme system wired
|
||||
into appearance settings. Admin surface hybrid loader completed.
|
||||
|
||||
Depends on: surface prototypes (v0.22.7).
|
||||
|
||||
**ChatPane Bridge:**
|
||||
- [ ] `ChatPane.primary` creation in `startApp()` from server-rendered mount points
|
||||
- [ ] `ui-core.js` delegation: `UI.renderMessages` → `ChatPane.primary.renderMessages`, etc.
|
||||
- [ ] `editor-mode.js` ChatPane creation for workspace chat
|
||||
- [ ] Branding init from `window.__SETTINGS__` (instanceName, logoURL)
|
||||
|
||||
**Theme Integration:**
|
||||
- [ ] Wire `Theme` into appearance settings save/load cycle
|
||||
- [ ] CM6 theme sync on theme change
|
||||
- [ ] Persist theme preference to user settings API
|
||||
|
||||
**Admin Surface:**
|
||||
- [ ] Complete hybrid section loaders for all JS-loaded sections
|
||||
- [ ] Admin surface stats/overview server-rendered content
|
||||
|
||||
**Settings Surface:**
|
||||
- [ ] Models visibility toggles (API.saveModelPreferences)
|
||||
- [ ] User Personas CRUD (add/edit/delete with confirm dialogs)
|
||||
- [ ] BYOK provider CRUD in settings context
|
||||
- [ ] Teams tab content
|
||||
|
||||
---
|
||||
|
||||
## v0.23.0 — Multi-Participant Channels + Presence
|
||||
|
||||
The channel foundation for workflows and real-time collaboration. Today
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
//
|
||||
// Tier | enabled | team | disabled
|
||||
// ----------+----------------+----------------------+---------
|
||||
// Global | All users | Team admin → presets | Hidden
|
||||
// Team | Team members | Team admin → presets | Hidden
|
||||
// Global | All users | Team admin → personas | Hidden
|
||||
// Team | Team members | Team admin → personas | Hidden
|
||||
// Personal | Owner direct | N/A | Hidden
|
||||
//
|
||||
// Sources aggregated:
|
||||
@@ -175,7 +175,7 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
}
|
||||
}
|
||||
// Fallback: look up any provider's catalog entry for this model
|
||||
// (covers auto-resolve presets where provider_config_id is NULL)
|
||||
// (covers auto-resolve personas where provider_config_id is NULL)
|
||||
if catalogCaps == nil {
|
||||
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
|
||||
catalogCaps = &entry.Capabilities
|
||||
@@ -209,12 +209,11 @@ func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]m
|
||||
ProviderName: provName,
|
||||
ProviderType: provType,
|
||||
Capabilities: caps,
|
||||
IsPreset: true,
|
||||
PresetID: p.ID,
|
||||
PresetScope: p.Scope,
|
||||
PresetAvatar: p.Avatar,
|
||||
PresetTeamName: teamName(p, stores, ctx),
|
||||
IsPersona: true,
|
||||
PersonaID: p.ID,
|
||||
PersonaScope: p.Scope,
|
||||
PersonaAvatar: p.Avatar,
|
||||
PersonaTeamName: teamName(p, stores, ctx),
|
||||
Description: p.Description,
|
||||
Icon: p.Icon,
|
||||
Avatar: p.Avatar,
|
||||
@@ -245,7 +244,7 @@ func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models
|
||||
catalogCaps = &entry.Capabilities
|
||||
}
|
||||
}
|
||||
// Fallback: any provider's catalog entry (auto-resolve presets)
|
||||
// Fallback: any provider's catalog entry (auto-resolve personas)
|
||||
if catalogCaps == nil {
|
||||
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
|
||||
catalogCaps = &entry.Capabilities
|
||||
|
||||
@@ -164,10 +164,10 @@ func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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")
|
||||
// ── Persona Avatar Upload ────────────────────
|
||||
// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
|
||||
func UploadPersonaAvatar(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
|
||||
var req uploadAvatarRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -208,7 +208,7 @@ func UploadPresetAvatar(c *gin.Context) {
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
database.Q(`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`),
|
||||
dataURI, presetID,
|
||||
dataURI, personaID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
|
||||
@@ -216,20 +216,20 @@ func UploadPresetAvatar(c *gin.Context) {
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "persona 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")
|
||||
// DeletePersonaAvatar clears a persona's avatar.
|
||||
func DeletePersonaAvatar(c *gin.Context) {
|
||||
personaID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
database.Q(`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`),
|
||||
presetID,
|
||||
personaID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
|
||||
@@ -237,7 +237,7 @@ func DeletePresetAvatar(c *gin.Context) {
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ type createChannelRequest struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
@@ -33,7 +33,7 @@ type updateChannelRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
@@ -49,7 +49,7 @@ type channelResponse struct {
|
||||
Type string `json:"type"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
APIConfigID *string `json:"provider_config_id"`
|
||||
ProviderConfigID *string `json:"provider_config_id"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
@@ -231,7 +231,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := rows.Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
@@ -288,7 +288,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
system_prompt, provider_config_id, folder, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, userID, req.Title, channelType, req.Description, req.Model,
|
||||
req.SystemPrompt, req.APIConfigID, req.Folder, writeTagsArg(req.Tags),
|
||||
req.SystemPrompt, req.ProviderConfigID, req.Folder, writeTagsArg(req.Tags),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
@@ -301,7 +301,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
tags, settings,
|
||||
created_at, updated_at
|
||||
FROM channels WHERE id = ?`, id).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
@@ -315,10 +315,10 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, project_id, workspace_id, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.ProviderConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
pq.Array(&tags), scanJSON(&ch.Settings), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
@@ -356,13 +356,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
INSERT INTO channel_models (id, channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, store.NewID(), ch.ID, req.Model, req.APIConfigID)
|
||||
`, store.NewID(), ch.ID, req.Model, req.ProviderConfigID)
|
||||
} else {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, req.Model, req.APIConfigID)
|
||||
`, ch.ID, req.Model, req.ProviderConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
) mc ON mc.channel_id = c.id
|
||||
WHERE c.id = $1 AND c.user_id = $2
|
||||
`), channelID, userID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.ProviderConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder, &ch.ProjectID, &ch.WorkspaceID,
|
||||
scanTags(&tags), scanJSON(&ch.Settings),
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
@@ -462,8 +462,8 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
if req.SystemPrompt != nil {
|
||||
addClause("system_prompt", *req.SystemPrompt)
|
||||
}
|
||||
if req.APIConfigID != nil {
|
||||
addClause("provider_config_id", *req.APIConfigID)
|
||||
if req.ProviderConfigID != nil {
|
||||
addClause("provider_config_id", *req.ProviderConfigID)
|
||||
}
|
||||
if req.IsArchived != nil {
|
||||
addClause("is_archived", *req.IsArchived)
|
||||
|
||||
@@ -32,12 +32,11 @@ import (
|
||||
// ── Request Types ───────────────────────────
|
||||
|
||||
type completionRequest struct {
|
||||
ChannelID string `json:"channel_id"` // preferred; validated manually below
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
ChannelID string `json:"channel_id"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
@@ -194,11 +193,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Support chat_id as alias during frontend transition
|
||||
channelID := req.ChannelID
|
||||
if channelID == "" {
|
||||
channelID = req.ChatID
|
||||
}
|
||||
if channelID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
|
||||
return
|
||||
@@ -211,60 +206,60 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── Preset unwrap: preset overrides defaults, explicit request fields win ──
|
||||
var presetSystemPrompt string
|
||||
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
|
||||
var personaSystemPrompt string
|
||||
var personaID string // tracks active persona for KB scoping
|
||||
var presetThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
|
||||
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
|
||||
if req.PersonaID != "" {
|
||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||
if persona == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found or not accessible"})
|
||||
return
|
||||
}
|
||||
personaID = preset.ID
|
||||
// Preset provides defaults; explicit request fields take priority
|
||||
personaID = persona.ID
|
||||
// Persona provides defaults; explicit request fields take priority
|
||||
if req.Model == "" {
|
||||
req.Model = preset.BaseModelID
|
||||
req.Model = persona.BaseModelID
|
||||
}
|
||||
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
|
||||
req.APIConfigID = *preset.ProviderConfigID
|
||||
if req.ProviderConfigID == "" && persona.ProviderConfigID != nil {
|
||||
req.ProviderConfigID = *persona.ProviderConfigID
|
||||
}
|
||||
if req.Temperature == nil && preset.Temperature != nil {
|
||||
req.Temperature = preset.Temperature
|
||||
if req.Temperature == nil && persona.Temperature != nil {
|
||||
req.Temperature = persona.Temperature
|
||||
}
|
||||
if req.MaxTokens == 0 && preset.MaxTokens != nil {
|
||||
req.MaxTokens = *preset.MaxTokens
|
||||
if req.MaxTokens == 0 && persona.MaxTokens != nil {
|
||||
req.MaxTokens = *persona.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
if persona.SystemPrompt != "" {
|
||||
personaSystemPrompt = persona.SystemPrompt
|
||||
}
|
||||
presetThinkingBudget = preset.ThinkingBudget
|
||||
personaThinkingBudget = persona.ThinkingBudget
|
||||
}
|
||||
|
||||
// ── Project persona fallback (v0.19.2): if no explicit preset, check project ──
|
||||
if req.PresetID == "" && h.stores.Projects != nil {
|
||||
// ── Project persona fallback (v0.19.2): if no explicit persona, check project ──
|
||||
if req.PersonaID == "" && h.stores.Projects != nil {
|
||||
projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID)
|
||||
if projID != "" {
|
||||
if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil {
|
||||
if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" {
|
||||
if preset := ResolvePreset(h.stores, pid, userID); preset != nil {
|
||||
personaID = preset.ID
|
||||
if persona := ResolvePersona(h.stores, pid, userID); persona != nil {
|
||||
personaID = persona.ID
|
||||
if req.Model == "" {
|
||||
req.Model = preset.BaseModelID
|
||||
req.Model = persona.BaseModelID
|
||||
}
|
||||
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
|
||||
req.APIConfigID = *preset.ProviderConfigID
|
||||
if req.ProviderConfigID == "" && persona.ProviderConfigID != nil {
|
||||
req.ProviderConfigID = *persona.ProviderConfigID
|
||||
}
|
||||
if req.Temperature == nil && preset.Temperature != nil {
|
||||
req.Temperature = preset.Temperature
|
||||
if req.Temperature == nil && persona.Temperature != nil {
|
||||
req.Temperature = persona.Temperature
|
||||
}
|
||||
if req.MaxTokens == 0 && preset.MaxTokens != nil {
|
||||
req.MaxTokens = *preset.MaxTokens
|
||||
if req.MaxTokens == 0 && persona.MaxTokens != nil {
|
||||
req.MaxTokens = *persona.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
if persona.SystemPrompt != "" {
|
||||
personaSystemPrompt = persona.SystemPrompt
|
||||
}
|
||||
presetThinkingBudget = preset.ThinkingBudget
|
||||
personaThinkingBudget = persona.ThinkingBudget
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +282,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
routingDecision = dec
|
||||
if winConfigID != configID {
|
||||
// Routing selected a different provider — reload its credentials
|
||||
req.APIConfigID = winConfigID
|
||||
req.ProviderConfigID = winConfigID
|
||||
providerCfg2, providerID2, model2, configID2, providerScope2, err := h.resolveConfig(userID, channelID, req)
|
||||
if err == nil {
|
||||
providerCfg = providerCfg2
|
||||
@@ -307,12 +302,12 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
// ── Inject persona-level thinking budget into provider settings (v0.22.1) ──
|
||||
// When a persona specifies a thinking budget, promote it into provider
|
||||
// settings so hooks can activate extended thinking automatically.
|
||||
if presetThinkingBudget != nil && *presetThinkingBudget > 0 {
|
||||
if personaThinkingBudget != nil && *personaThinkingBudget > 0 {
|
||||
if providerCfg.Settings == nil {
|
||||
providerCfg.Settings = make(map[string]interface{})
|
||||
}
|
||||
providerCfg.Settings["extended_thinking"] = true
|
||||
providerCfg.Settings["thinking_budget"] = *presetThinkingBudget
|
||||
providerCfg.Settings["thinking_budget"] = *personaThinkingBudget
|
||||
}
|
||||
|
||||
// ── Team policy: require_private_providers ──
|
||||
@@ -337,7 +332,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Load conversation history
|
||||
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt, personaID)
|
||||
messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||
return
|
||||
@@ -396,7 +391,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
parsed := mentions.Parse(req.Content, roster)
|
||||
targets := mentions.ResolvedModels(parsed)
|
||||
if len(targets) > 1 {
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, workspaceID, req)
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, personaSystemPrompt, workspaceID, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -449,7 +444,7 @@ func (h *CompletionHandler) multiModelStream(
|
||||
c *gin.Context,
|
||||
targets []models.ChannelModel,
|
||||
messages []providers.Message,
|
||||
channelID, userID, personaID, presetSystemPrompt, workspaceID string,
|
||||
channelID, userID, personaID, personaSystemPrompt, workspaceID string,
|
||||
req completionRequest,
|
||||
) {
|
||||
// Set SSE headers once for the entire multi-model stream
|
||||
@@ -485,7 +480,7 @@ func (h *CompletionHandler) multiModelStream(
|
||||
targetReq := req
|
||||
targetReq.Model = target.ModelID
|
||||
if target.ProviderConfigID != "" {
|
||||
targetReq.APIConfigID = target.ProviderConfigID
|
||||
targetReq.ProviderConfigID = target.ProviderConfigID
|
||||
}
|
||||
|
||||
providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq)
|
||||
@@ -998,8 +993,8 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
var configID string
|
||||
|
||||
// 1. Explicit config from request
|
||||
if req.APIConfigID != "" {
|
||||
configID = req.APIConfigID
|
||||
if req.ProviderConfigID != "" {
|
||||
configID = req.ProviderConfigID
|
||||
}
|
||||
|
||||
// 2. Config from channel
|
||||
@@ -1115,7 +1110,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
//
|
||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||
// messages before it are replaced by the summary content as a system message.
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||
messages := make([]providers.Message, 0)
|
||||
|
||||
// ── Admin system prompt (always injected first, no opt out) ──
|
||||
@@ -1129,17 +1124,17 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
||||
}
|
||||
}
|
||||
|
||||
// ── User/preset system prompt (appended after admin prompt) ──
|
||||
// ── User/persona system prompt (appended after admin prompt) ──
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(
|
||||
database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID,
|
||||
).Scan(&systemPrompt)
|
||||
|
||||
// Preset system prompt takes priority; channel system prompt is fallback
|
||||
if presetSystemPrompt != "" {
|
||||
// Persona system prompt takes priority; channel system prompt is fallback
|
||||
if personaSystemPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: presetSystemPrompt,
|
||||
Content: personaSystemPrompt,
|
||||
})
|
||||
} else if systemPrompt != nil && *systemPrompt != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
|
||||
@@ -200,12 +200,12 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
settings := NewSettingsHandler(nil)
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
|
||||
// Presets
|
||||
presets := NewPersonaHandler(stores)
|
||||
protected.GET("/presets", presets.ListUserPersonas)
|
||||
protected.POST("/presets", presets.CreateUserPersona)
|
||||
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
// Personas
|
||||
personas := NewPersonaHandler(stores)
|
||||
protected.GET("/personas", personas.ListUserPersonas)
|
||||
protected.POST("/personas", personas.CreateUserPersona)
|
||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := NewNoteHandler(stores)
|
||||
@@ -283,10 +283,10 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
admin.GET("/teams/:id", teams.GetTeam)
|
||||
admin.GET("/teams/:id/members", teams.ListMembers)
|
||||
admin.POST("/teams/:id/members", teams.AddMember)
|
||||
admin.GET("/presets", presets.ListAdminPersonas)
|
||||
admin.POST("/presets", presets.CreateAdminPersona)
|
||||
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||
admin.GET("/personas", personas.ListAdminPersonas)
|
||||
admin.POST("/personas", personas.CreateAdminPersona)
|
||||
admin.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Admin groups (v0.16.0)
|
||||
groupAdm := NewGroupHandler(stores)
|
||||
@@ -802,14 +802,14 @@ func TestIntegration_TeamMemberManagement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Presets (Policy Gated) ────────────────
|
||||
// ── 7. Personas (Policy Gated) ───────────────
|
||||
|
||||
func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
||||
func TestIntegration_PersonaCreation_PolicyGated(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
_ = adminID
|
||||
|
||||
// Create a provider config (presets need a valid model reference)
|
||||
// Create a provider config (personas need a valid model reference)
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "TestProvider", "provider": "openai",
|
||||
"endpoint": "https://api.openai.com/v1", "api_key": "sk-test",
|
||||
@@ -821,13 +821,13 @@ func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
||||
// Ensure allow_user_personas = false
|
||||
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('allow_user_personas', 'false') ON CONFLICT (key) DO UPDATE SET value = 'false'")
|
||||
|
||||
// Try to create a preset as admin (role=admin but policy says no)
|
||||
w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
|
||||
"name": "My Preset", "base_model_id": "gpt-4o",
|
||||
// Try to create a persona as admin (role=admin but policy says no)
|
||||
w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
|
||||
"name": "My Persona", "base_model_id": "gpt-4o",
|
||||
"provider_config_id": configID, "system_prompt": "You are helpful",
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("preset create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("persona create with policy=false: want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Enable the policy
|
||||
@@ -835,18 +835,18 @@ func TestIntegration_PresetCreation_PolicyGated(t *testing.T) {
|
||||
map[string]interface{}{"value": "true"})
|
||||
|
||||
// Now creation should succeed
|
||||
w = h.request("POST", "/api/v1/presets", adminToken, map[string]interface{}{
|
||||
"name": "My Preset", "base_model_id": "gpt-4o",
|
||||
w = h.request("POST", "/api/v1/personas", adminToken, map[string]interface{}{
|
||||
"name": "My Persona", "base_model_id": "gpt-4o",
|
||||
"provider_config_id": configID, "system_prompt": "You are helpful",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("preset create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("persona create with policy=true: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// List presets
|
||||
w = h.request("GET", "/api/v1/presets", adminToken, nil)
|
||||
// List personas
|
||||
w = h.request("GET", "/api/v1/personas", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,7 +1105,7 @@ func TestIntegration_AdminModelFetchEnableUserSees(t *testing.T) {
|
||||
t.Error("MISSING: model_id — required for composite ID")
|
||||
}
|
||||
|
||||
// source required for preset detection
|
||||
// source required for persona detection
|
||||
if m["source"] == nil || m["source"] == "" {
|
||||
t.Error("MISSING: source — frontend uses this to distinguish catalog vs persona")
|
||||
}
|
||||
@@ -2748,7 +2748,7 @@ func TestResourceGrants(t *testing.T) {
|
||||
decode(w, &group)
|
||||
|
||||
// Create a persona (admin)
|
||||
w = h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||
w = h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||
"name": "Test Bot",
|
||||
"base_model_id": "test-model",
|
||||
"scope": "global",
|
||||
@@ -2840,11 +2840,11 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── User should NOT see team persona ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
var presetsResp map[string]interface{}
|
||||
decode(w, &presetsResp)
|
||||
presets, _ := presetsResp["presets"].([]interface{})
|
||||
for _, p := range presets {
|
||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||
var personasResp map[string]interface{}
|
||||
decode(w, &personasResp)
|
||||
personas, _ := personasResp["personas"].([]interface{})
|
||||
for _, p := range personas {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
t.Fatalf("user should NOT see team persona without group access")
|
||||
@@ -2872,14 +2872,14 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── User should NOW see team persona via group grant ──
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list presets: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("list personas: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
decode(w, &presetsResp)
|
||||
presets, _ = presetsResp["presets"].([]interface{})
|
||||
decode(w, &personasResp)
|
||||
personas, _ = personasResp["personas"].([]interface{})
|
||||
found := false
|
||||
for _, p := range presets {
|
||||
for _, p := range personas {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
found = true
|
||||
@@ -2887,16 +2887,16 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("user should see team persona after group grant, but didn't find it in %d presets (response: %s)", len(presets), w.Body.String())
|
||||
t.Fatalf("user should see team persona after group grant, but didn't find it in %d personas (response: %s)", len(personas), w.Body.String())
|
||||
}
|
||||
|
||||
// ── Remove user from group → should lose access ──
|
||||
w = h.request("DELETE", fmt.Sprintf("/api/v1/admin/groups/%s/members/%s", group.ID, userID), adminToken, nil)
|
||||
|
||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||
decode(w, &presetsResp)
|
||||
presets, _ = presetsResp["presets"].([]interface{})
|
||||
for _, p := range presets {
|
||||
w = h.request("GET", "/api/v1/personas", userToken, nil)
|
||||
decode(w, &personasResp)
|
||||
personas, _ = personasResp["personas"].([]interface{})
|
||||
for _, p := range personas {
|
||||
pm := p.(map[string]interface{})
|
||||
if pm["id"] == personaID {
|
||||
t.Fatalf("user should NOT see persona after group removal")
|
||||
@@ -2915,7 +2915,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// 1. Create a global persona via admin API
|
||||
w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||
w := h.request("POST", "/api/v1/admin/personas", adminToken, map[string]interface{}{
|
||||
"name": "KB Persona",
|
||||
"base_model_id": "test-model",
|
||||
"system_prompt": "You are a test persona.",
|
||||
@@ -2941,7 +2941,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 3. Bind KB to persona
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, map[string]interface{}{
|
||||
"kb_ids": []string{kbID},
|
||||
"auto_search": map[string]bool{kbID: true},
|
||||
@@ -2952,7 +2952,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 4. Read back bindings
|
||||
w = h.request("GET",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
@@ -2973,7 +2973,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 5. Unbind — send empty list
|
||||
w = h.request("PUT",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, map[string]interface{}{
|
||||
"kb_ids": []string{},
|
||||
})
|
||||
@@ -2983,7 +2983,7 @@ func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||
|
||||
// 6. Verify empty
|
||||
w = h.request("GET",
|
||||
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||
fmt.Sprintf("/api/v1/admin/personas/%s/knowledge-bases", personaID),
|
||||
adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
|
||||
@@ -47,8 +47,8 @@ type editRequest struct {
|
||||
|
||||
type regenerateRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
ProviderConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
DisabledTools []string `json:"disabled_tools,omitempty"`
|
||||
@@ -417,34 +417,34 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore, nil)
|
||||
|
||||
var presetSystemPrompt string
|
||||
var personaSystemPrompt string
|
||||
var personaID string
|
||||
model := req.Model
|
||||
apiConfigID := req.APIConfigID
|
||||
providerConfigID := req.ProviderConfigID
|
||||
temperature := req.Temperature
|
||||
maxTokens := req.MaxTokens
|
||||
|
||||
if req.PresetID != "" {
|
||||
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||
if preset == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
||||
if req.PersonaID != "" {
|
||||
persona := ResolvePersona(h.stores, req.PersonaID, userID)
|
||||
if persona == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
personaID = preset.ID
|
||||
personaID = persona.ID
|
||||
if model == "" {
|
||||
model = preset.BaseModelID
|
||||
model = persona.BaseModelID
|
||||
}
|
||||
if apiConfigID == "" && preset.ProviderConfigID != nil {
|
||||
apiConfigID = *preset.ProviderConfigID
|
||||
if providerConfigID == "" && persona.ProviderConfigID != nil {
|
||||
providerConfigID = *persona.ProviderConfigID
|
||||
}
|
||||
if temperature == nil && preset.Temperature != nil {
|
||||
temperature = preset.Temperature
|
||||
if temperature == nil && persona.Temperature != nil {
|
||||
temperature = persona.Temperature
|
||||
}
|
||||
if maxTokens == 0 && preset.MaxTokens != nil {
|
||||
maxTokens = *preset.MaxTokens
|
||||
if maxTokens == 0 && persona.MaxTokens != nil {
|
||||
maxTokens = *persona.MaxTokens
|
||||
}
|
||||
if preset.SystemPrompt != "" {
|
||||
presetSystemPrompt = preset.SystemPrompt
|
||||
if persona.SystemPrompt != "" {
|
||||
personaSystemPrompt = persona.SystemPrompt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
providerCfg, providerID, model, configID, providerScope, err := comp.resolveConfig(userID, channelID, completionRequest{
|
||||
Model: model,
|
||||
APIConfigID: apiConfigID,
|
||||
ProviderConfigID: providerConfigID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -475,8 +475,8 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
// Build LLM message array
|
||||
llmMessages := make([]providers.Message, 0, len(contextPath)+1)
|
||||
|
||||
if presetSystemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: presetSystemPrompt})
|
||||
if personaSystemPrompt != "" {
|
||||
llmMessages = append(llmMessages, providers.Message{Role: "system", Content: personaSystemPrompt})
|
||||
} else {
|
||||
var systemPrompt *string
|
||||
_ = database.DB.QueryRow(database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID).Scan(&systemPrompt)
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
|
||||
@@ -121,7 +121,7 @@ func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
|
||||
@@ -167,7 +167,7 @@ func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
|
||||
@@ -253,18 +253,17 @@ func (r *personaRequest) toPersona() *models.Persona {
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolvePreset loads a persona by ID and returns it if the user has access.
|
||||
// ResolvePersona loads a persona by ID and returns it if the user has access.
|
||||
// Returns nil if not found, inactive, or not accessible.
|
||||
// Used by completion.go and messages.go for preset unwrapping.
|
||||
func ResolvePreset(stores store.Stores, presetID, userID string) *models.Persona {
|
||||
func ResolvePersona(stores store.Stores, personaID, userID string) *models.Persona {
|
||||
ctx := context.Background()
|
||||
|
||||
p, err := stores.Personas.GetByID(ctx, presetID)
|
||||
p, err := stores.Personas.GetByID(ctx, personaID)
|
||||
if err != nil || !p.IsActive {
|
||||
return nil
|
||||
}
|
||||
|
||||
ok, err := stores.Personas.UserCanAccess(ctx, userID, presetID)
|
||||
ok, err := stores.Personas.UserCanAccess(ctx, userID, personaID)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
@@ -470,10 +470,10 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": teams})
|
||||
}
|
||||
|
||||
// ── Team Models: Available for Presets ──────
|
||||
// ── Team Models: Available for Personas ──────
|
||||
|
||||
// ListAvailableModels returns models with visibility 'enabled' or 'team'
|
||||
// for team admins building presets. Requires RequireTeamAdmin middleware.
|
||||
// for team admins building personas. Requires RequireTeamAdmin middleware.
|
||||
// GET /api/v1/teams/:teamId/models
|
||||
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
@@ -427,7 +427,6 @@ func main() {
|
||||
modelH.SetHealthStore(healthStore)
|
||||
}
|
||||
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
||||
protected.GET("/models", modelH.ListEnabledModels) // alias
|
||||
|
||||
// Model Preferences
|
||||
modelPrefs := handlers.NewModelPrefsHandler(stores)
|
||||
@@ -449,16 +448,16 @@ func main() {
|
||||
usage := handlers.NewUsageHandler(stores)
|
||||
protected.GET("/usage", usage.PersonalUsage)
|
||||
|
||||
// Personas (replaces /presets)
|
||||
// Personas
|
||||
personas := handlers.NewPersonaHandler(stores)
|
||||
protected.GET("/presets", personas.ListUserPersonas) // backward compat
|
||||
protected.POST("/presets", personas.CreateUserPersona)
|
||||
protected.PUT("/presets/:id", personas.UpdateUserPersona)
|
||||
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
|
||||
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||
protected.GET("/presets/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/presets/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
protected.GET("/personas", personas.ListUserPersonas)
|
||||
protected.POST("/personas", personas.CreateUserPersona)
|
||||
protected.PUT("/personas/:id", personas.UpdateUserPersona)
|
||||
protected.DELETE("/personas/:id", personas.DeleteUserPersona)
|
||||
protected.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
|
||||
protected.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
||||
protected.GET("/personas/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||
protected.PUT("/personas/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Notes
|
||||
notes := handlers.NewNoteHandler(stores)
|
||||
@@ -634,11 +633,11 @@ func main() {
|
||||
|
||||
// Team personas
|
||||
teamPersonas := handlers.NewPersonaHandler(stores)
|
||||
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
||||
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
|
||||
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
|
||||
teamScoped.GET("/presets/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
|
||||
teamScoped.PUT("/presets/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
|
||||
teamScoped.GET("/personas", teamPersonas.ListTeamPersonas)
|
||||
teamScoped.POST("/personas", teamPersonas.CreateTeamPersona)
|
||||
teamScoped.DELETE("/personas/:id", teamPersonas.DeleteTeamPersona)
|
||||
teamScoped.GET("/personas/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
|
||||
teamScoped.PUT("/personas/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Team role overrides
|
||||
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
||||
@@ -697,14 +696,14 @@ func main() {
|
||||
|
||||
// Personas (admin global)
|
||||
personaAdm := handlers.NewPersonaHandler(stores)
|
||||
admin.GET("/presets", personaAdm.ListAdminPersonas)
|
||||
admin.POST("/presets", personaAdm.CreateAdminPersona)
|
||||
admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
|
||||
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
|
||||
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||
admin.GET("/presets/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/presets/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
|
||||
admin.GET("/personas", personaAdm.ListAdminPersonas)
|
||||
admin.POST("/personas", personaAdm.CreateAdminPersona)
|
||||
admin.PUT("/personas/:id", personaAdm.UpdateAdminPersona)
|
||||
admin.DELETE("/personas/:id", personaAdm.DeleteAdminPersona)
|
||||
admin.POST("/personas/:id/avatar", handlers.UploadPersonaAvatar)
|
||||
admin.DELETE("/personas/:id/avatar", handlers.DeletePersonaAvatar)
|
||||
admin.GET("/personas/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
|
||||
admin.PUT("/personas/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
|
||||
|
||||
// Admin memory review (v0.18.0)
|
||||
adminMemH := handlers.NewMemoryHandler(stores)
|
||||
|
||||
@@ -609,14 +609,13 @@ type UserModel struct {
|
||||
|
||||
Capabilities ModelCapabilities `json:"capabilities"`
|
||||
|
||||
// Preset fields — always emitted so frontend can branch on is_preset.
|
||||
IsPreset bool `json:"is_preset"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
PresetScope string `json:"preset_scope,omitempty"`
|
||||
PresetAvatar string `json:"preset_avatar,omitempty"`
|
||||
PresetTeamName string `json:"preset_team_name,omitempty"`
|
||||
|
||||
// Persona fields — always emitted so frontend can branch on is_persona.
|
||||
IsPersona bool `json:"is_persona"`
|
||||
PersonaID string `json:"persona_id,omitempty"`
|
||||
PersonaScope string `json:"persona_scope,omitempty"`
|
||||
PersonaAvatar string `json:"persona_avatar,omitempty"`
|
||||
PersonaTeamName string `json:"persona_team_name,omitempty"`
|
||||
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
|
||||
@@ -112,6 +112,11 @@ type NotesPageData struct {
|
||||
// SettingsPageData is what the settings surface templates receive.
|
||||
type SettingsPageData struct {
|
||||
Section string `json:"section"`
|
||||
|
||||
// v0.22.7: Feature gates from admin config.
|
||||
// These control which nav links/tabs are visible in the settings surface.
|
||||
BYOKEnabled bool `json:"BYOKEnabled"`
|
||||
UserPersonasEnabled bool `json:"UserPersonasEnabled"`
|
||||
}
|
||||
|
||||
// ── Loader registration ──────────────────────
|
||||
@@ -290,7 +295,7 @@ func sectionCategory(section string) string {
|
||||
switch section {
|
||||
case "users", "teams", "groups":
|
||||
return "people"
|
||||
case "providers", "models", "presets", "roles", "knowledgeBases", "memory":
|
||||
case "providers", "models", "personas", "roles", "knowledgeBases", "memory":
|
||||
return "ai"
|
||||
case "health", "routing", "capabilities":
|
||||
return "routing"
|
||||
@@ -373,7 +378,6 @@ func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
// ── Editor loader ────────────────────────────
|
||||
// Resolves workspace from URL param. The heavy lifting (file tree, CodeMirror)
|
||||
// is done client-side by editor-mode.js.
|
||||
@@ -400,13 +404,35 @@ func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
}
|
||||
|
||||
// ── Settings loader ──────────────────────────
|
||||
// Most settings sections are populated client-side by existing JS.
|
||||
// Server just passes the section name for navigation highlighting.
|
||||
// v0.22.7: Reads feature gates from GlobalConfig to control
|
||||
// which nav links/tabs are visible (BYOK, User Personas).
|
||||
|
||||
func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
|
||||
section := c.Param("section")
|
||||
if section == "" {
|
||||
section = "general"
|
||||
}
|
||||
return &SettingsPageData{Section: section}, nil
|
||||
|
||||
data := &SettingsPageData{Section: section}
|
||||
|
||||
// Read feature gates from admin config
|
||||
if s.GlobalConfig != nil {
|
||||
ctx := context.Background()
|
||||
|
||||
// BYOK: check if user providers are enabled
|
||||
if raw, err := s.GlobalConfig.Get(ctx, "user_providers"); err == nil && raw != nil {
|
||||
if v, ok := raw["enabled"].(bool); ok {
|
||||
data.BYOKEnabled = v
|
||||
}
|
||||
}
|
||||
|
||||
// User Personas: check if user-created personas are enabled
|
||||
if raw, err := s.GlobalConfig.Get(ctx, "user_personas"); err == nil && raw != nil {
|
||||
if v, ok := raw["enabled"].(bool); ok {
|
||||
data.UserPersonasEnabled = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// templates/base.html — outer shell (banner + surface block + scripts)
|
||||
// templates/login.html — standalone login page
|
||||
// templates/components/*.html — reusable partials (model-select, team-select, etc.)
|
||||
// templates/components/*.html — reusable partials (model-select, team-select, chat-pane, etc.)
|
||||
// templates/surfaces/*.html — one per surface (chat, editor, notes, admin, etc.)
|
||||
// templates/admin/*.html — admin sub-pages (roles, routing, providers, etc.)
|
||||
package pages
|
||||
@@ -61,6 +61,16 @@ type PageData struct {
|
||||
Environment string
|
||||
User *UserContext
|
||||
Data any // surface-specific data from loader
|
||||
|
||||
// v0.22.7: Theme + settings injection
|
||||
Theme string // "dark", "light", or "" (= use default)
|
||||
SurfaceSettings any // JSON-serialized to window.__SETTINGS__
|
||||
|
||||
// v0.22.7: Login/splash page fields
|
||||
InstanceName string // branding: instance display name
|
||||
LogoURL string // branding: custom logo URL
|
||||
Tagline string // branding: tagline under instance name
|
||||
RegistrationOpen bool // whether self-registration is enabled
|
||||
}
|
||||
|
||||
// UserContext is the authenticated user's info available to templates.
|
||||
@@ -138,6 +148,11 @@ func (e *Engine) Render(c *gin.Context, name string, data PageData) {
|
||||
data.CSPNonce = generateNonce()
|
||||
data.Banner = e.loadBanner()
|
||||
|
||||
// v0.22.7: Default theme if not set
|
||||
if data.Theme == "" {
|
||||
data.Theme = "dark"
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
tmpl := e.templates
|
||||
e.mu.RUnlock()
|
||||
@@ -186,8 +201,16 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
// RenderLogin serves the standalone login page.
|
||||
func (e *Engine) RenderLogin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// v0.22.7: Populate splash/login fields from global config
|
||||
instanceName, logoURL, tagline := e.loadBranding()
|
||||
regOpen := e.isRegistrationOpen()
|
||||
|
||||
e.Render(c, "login.html", PageData{
|
||||
Surface: "login",
|
||||
InstanceName: instanceName,
|
||||
LogoURL: logoURL,
|
||||
Tagline: tagline,
|
||||
RegistrationOpen: regOpen,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -229,6 +252,43 @@ func (e *Engine) loadBanner() BannerConfig {
|
||||
return b
|
||||
}
|
||||
|
||||
// loadBranding reads instance branding from global settings.
|
||||
func (e *Engine) loadBranding() (name, logoURL, tagline string) {
|
||||
name = "Chat Switchboard" // default
|
||||
if e.stores.GlobalConfig == nil {
|
||||
return
|
||||
}
|
||||
raw, err := e.stores.GlobalConfig.Get(context.Background(), "branding")
|
||||
if err != nil || raw == nil {
|
||||
return
|
||||
}
|
||||
if v, ok := raw["instance_name"].(string); ok && v != "" {
|
||||
name = v
|
||||
}
|
||||
if v, ok := raw["logo_url"].(string); ok {
|
||||
logoURL = v
|
||||
}
|
||||
if v, ok := raw["tagline"].(string); ok {
|
||||
tagline = v
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// isRegistrationOpen checks if self-registration is enabled.
|
||||
func (e *Engine) isRegistrationOpen() bool {
|
||||
if e.stores.GlobalConfig == nil {
|
||||
return false
|
||||
}
|
||||
raw, err := e.stores.GlobalConfig.Get(context.Background(), "registration")
|
||||
if err != nil || raw == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := raw["enabled"].(bool); ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Version is injected at build time via ldflags.
|
||||
var Version = "dev"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{define "admin-teams"}}
|
||||
<div class="admin-page">
|
||||
<h2>Teams</h2>
|
||||
<p class="section-hint">Organize users into teams for scoped access to providers, presets, and policies.</p>
|
||||
<p class="section-hint">Organize users into teams for scoped access to providers, personas, and policies.</p>
|
||||
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" onclick="Pages.showTeamForm()">+ Create Team</button>
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/styles.css?v={{.Version}}">
|
||||
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
|
||||
{{if eq .Surface "admin"}}{{template "css-admin" .}}{{end}}
|
||||
{{if eq .Surface "editor"}}{{template "css-editor" .}}{{end}}
|
||||
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
|
||||
{{if eq .Surface "settings"}}{{template "css-settings" .}}{{end}}
|
||||
<style>
|
||||
:root {
|
||||
--banner-h: 28px;
|
||||
@@ -20,13 +17,13 @@
|
||||
--banner-bot-h: {{if .Banner.Visible}}var(--banner-h){{else}}0px{{end}};
|
||||
--surface-h: calc(100vh - var(--banner-top-h) - var(--banner-bot-h));
|
||||
}
|
||||
body { margin: 0; background: var(--bg-primary, #0e0e10); color: var(--text-primary, #e0e0e0); }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); }
|
||||
.surface { height: var(--surface-h); overflow: hidden; }
|
||||
.banner { flex-shrink: 0; }
|
||||
</style>
|
||||
<meta name="theme-color" content="#0e0e10">
|
||||
</head>
|
||||
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
|
||||
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}" data-theme="{{or .Theme "dark"}}">
|
||||
{{if .Banner.Visible}}
|
||||
<div class="banner banner-top" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
|
||||
{{.Banner.Text}}
|
||||
@@ -59,9 +56,16 @@
|
||||
window.__USER__ = {{.User | toJSON}};
|
||||
</script>
|
||||
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app-state.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/api.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
// Hydrate auth tokens for all surfaces (chat surface also calls this in init)
|
||||
API.loadTokens();
|
||||
</script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/events.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-primitives-additions.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
|
||||
|
||||
{{if eq .Surface "chat"}}{{template "scripts-chat" .}}{{end}}
|
||||
|
||||
23
server/pages/templates/components/chat-pane.html
Normal file
23
server/pages/templates/components/chat-pane.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{{/*
|
||||
Chat Pane Component - reusable chat scaffold.
|
||||
Usage: {{template "chat-pane" dict "ID" "main"}}
|
||||
Creates mount points: {ID}ChatMessages, {ID}ChatInput, {ID}SendBtn, {ID}ModelSel
|
||||
ChatPane.create() in chat-pane.js binds to these IDs.
|
||||
*/}}
|
||||
{{define "chat-pane"}}
|
||||
<div class="chat-pane" id="{{.ID}}ChatPane">
|
||||
<div class="chat-pane-messages" id="{{.ID}}ChatMessages"></div>
|
||||
<div class="chat-pane-input-bar">
|
||||
<div class="chat-pane-model-sel" id="{{.ID}}ModelSel"></div>
|
||||
<div class="chat-pane-input-wrap">
|
||||
<div class="chat-pane-input" id="{{.ID}}ChatInput"></div>
|
||||
<button class="chat-pane-send" id="{{.ID}}SendBtn" title="Send">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="chat-pane-toolbar" id="{{.ID}}Toolbar"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -4,68 +4,342 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — Chat Switchboard</title>
|
||||
<title>Chat Switchboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/styles.css?v={{.Version}}">
|
||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e0e10; --bg-surface: #18181b; --bg-raised: #222227;
|
||||
--border: #2e2e35; --border-light: #3a3a42;
|
||||
--text: #e8e8ed; --text-2: #9898a8; --text-3: #6b6b7b;
|
||||
--accent: #6c9fff; --accent-hover: #84b0ff; --accent-dim: rgba(108,159,255,0.12);
|
||||
--purple: #a78bfa; --purple-dim: rgba(167,139,250,0.10);
|
||||
--danger: #ef4444; --success: #22c55e;
|
||||
--input-bg: #1a1a1f;
|
||||
--grid-line: rgba(108,159,255,0.04);
|
||||
--glow1: rgba(108,159,255,0.06); --glow2: rgba(167,139,250,0.05);
|
||||
--orb: rgba(167,139,250,0.08);
|
||||
--font: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--mono: 'JetBrains Mono', monospace;
|
||||
--banner-h: 28px;
|
||||
--banner-top-h: {{if .Banner.Visible}}var(--banner-h){{else}}0px{{end}};
|
||||
--banner-bot-h: {{if .Banner.Visible}}var(--banner-h){{else}}0px{{end}};
|
||||
}
|
||||
body { margin: 0; background: var(--bg-primary, #0e0e10); color: var(--text-primary, #e0e0e0); }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; }
|
||||
|
||||
.login-shell { display: flex; flex-direction: column; height: 100vh; }
|
||||
.login-main { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Hero Panel (left) ─────────────────── */
|
||||
.hero {
|
||||
flex: 1; display: flex; flex-direction: column; justify-content: center;
|
||||
padding: 4rem 3.5rem; position: relative; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 30% 40%, var(--glow1), transparent),
|
||||
radial-gradient(ellipse 60% 50% at 70% 70%, var(--glow2), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.hero-grid {
|
||||
position: absolute; inset: -50%;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
animation: gridDrift 30s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-orb {
|
||||
position: absolute; width: 320px; height: 320px; bottom: -80px; right: -60px;
|
||||
background: radial-gradient(circle, var(--orb), transparent 70%);
|
||||
border-radius: 50%; pointer-events: none;
|
||||
}
|
||||
.hero-content { position: relative; z-index: 1; max-width: 520px; }
|
||||
|
||||
.hero-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 1.5rem; }
|
||||
.hero-brand-icon { font-size: 40px; line-height: 1; }
|
||||
.hero-brand-text { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
||||
.hero-brand-text .hl { color: var(--accent); }
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
||||
letter-spacing: -0.03em; margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--text) 0%, var(--text-2) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 1.05rem; color: var(--text-2); line-height: 1.6;
|
||||
margin-bottom: 2.5rem; max-width: 440px;
|
||||
}
|
||||
.hero-pills { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.hero-pill {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 7px 14px; background: var(--bg-surface);
|
||||
border-radius: 100px; font-size: 0.8rem; font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.hero-pill.ac { border: 1px solid rgba(108,159,255,0.2); color: var(--accent); }
|
||||
.hero-pill.pu { border: 1px solid rgba(167,139,250,0.2); color: var(--purple); }
|
||||
.hero-pill-icon { font-size: 0.95rem; line-height: 1; }
|
||||
|
||||
.hero-version {
|
||||
margin-top: 3rem; font-size: 0.72rem; font-family: var(--mono);
|
||||
color: var(--text-3); letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Auth Panel (right) ────────────────── */
|
||||
.auth-panel {
|
||||
width: 440px; flex-shrink: 0; display: flex; align-items: center;
|
||||
justify-content: center; padding: 2rem; background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border); overflow-y: auto;
|
||||
}
|
||||
.auth-inner { width: 100%; max-width: 340px; }
|
||||
|
||||
.auth-heading { margin-bottom: 2rem; }
|
||||
.auth-heading h2 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||
.auth-heading p { font-size: 0.85rem; color: var(--text-3); }
|
||||
|
||||
.auth-tabs { display: flex; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); }
|
||||
.auth-tab {
|
||||
flex: 1; background: none; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--text-3); padding: 10px 0; cursor: pointer; font-family: var(--font);
|
||||
font-size: 0.85rem; font-weight: 500; transition: color 0.15s;
|
||||
}
|
||||
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.auth-tab:hover { color: var(--text-2); }
|
||||
|
||||
.form-field { margin-bottom: 16px; }
|
||||
.form-field label { display: block; font-size: 13px; font-weight: 500; color: var(--text-2); margin-bottom: 6px; }
|
||||
.form-field input {
|
||||
width: 100%; box-sizing: border-box; background: var(--input-bg);
|
||||
border: 1px solid var(--border); color: var(--text);
|
||||
padding: 10px 14px; border-radius: 10px; font-size: 14px; font-family: var(--font);
|
||||
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.form-field input:focus {
|
||||
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
.auth-error { color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem; }
|
||||
|
||||
.btn-login {
|
||||
width: 100%; background: var(--accent); color: #fff; border: none;
|
||||
padding: 10px 18px; font-size: 14px; border-radius: 10px; cursor: pointer;
|
||||
font-family: var(--font); font-weight: 600; letter-spacing: -0.01em;
|
||||
transition: all 0.18s; margin-top: 1.5rem;
|
||||
}
|
||||
.btn-login:hover { background: var(--accent-hover); }
|
||||
.btn-login:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.auth-footer {
|
||||
margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
.auth-footer p { font-size: 0.75rem; color: var(--text-3); line-height: 1.6; }
|
||||
|
||||
/* ── Banner ─────────────────────────────── */
|
||||
.login-banner {
|
||||
text-align: center; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 1.2px; padding: 3px 0; text-transform: uppercase; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Animations ─────────────────────────── */
|
||||
@keyframes gridDrift { to { transform: translate(48px, 48px); } }
|
||||
@keyframes fadeUp { to { opacity: 1; transform: translateY(0); } }
|
||||
.anim { opacity: 0; transform: translateY(12px); animation: fadeUp 0.5s ease forwards; }
|
||||
.anim-1 { animation-delay: .05s; } .anim-2 { animation-delay: .12s; }
|
||||
.anim-3 { animation-delay: .19s; } .anim-4 { animation-delay: .26s; }
|
||||
.anim-5 { animation-delay: .33s; }
|
||||
.anim-a1 { animation-delay: .15s; } .anim-a2 { animation-delay: .22s; }
|
||||
.anim-a3 { animation-delay: .29s; }
|
||||
|
||||
/* ── Responsive ─────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.hero { display: none; }
|
||||
.auth-panel { width: 100%; border-left: none; }
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-shell">
|
||||
|
||||
{{if .Banner.Visible}}
|
||||
<div class="banner banner-top" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;">
|
||||
<div class="login-banner" style="background:{{.Banner.Background}};color:{{.Banner.Color}};">
|
||||
{{.Banner.Text}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:calc(100vh - var(--banner-top-h) - var(--banner-bot-h));">
|
||||
<div style="width:360px;padding:32px;">
|
||||
<div style="text-align:center;margin-bottom:24px;">
|
||||
<img src="{{.BasePath}}/favicon-32.png" alt="" style="width:48px;height:48px;margin-bottom:12px;">
|
||||
<h1 style="font-size:20px;margin:0;">Chat Switchboard</h1>
|
||||
<div class="login-main">
|
||||
|
||||
<!-- ═══ HERO (left) ═══ -->
|
||||
<div class="hero">
|
||||
<div class="hero-grid"></div>
|
||||
<div class="hero-orb"></div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-brand anim anim-1">
|
||||
<span class="hero-brand-icon">🔀</span>
|
||||
<span class="hero-brand-text">Chat <span class="hl">Switchboard</span></span>
|
||||
</div>
|
||||
<h1 class="anim anim-2">Your AI conversations,<br>your infrastructure</h1>
|
||||
<p class="hero-sub anim anim-3">
|
||||
Self-hosted multi-provider chat with team collaboration, BYOK privacy, and an extensible plugin system.
|
||||
</p>
|
||||
<div class="hero-pills anim anim-4">
|
||||
<span class="hero-pill ac"><span class="hero-pill-icon">🤖</span> Multi-Provider AI</span>
|
||||
<span class="hero-pill pu"><span class="hero-pill-icon">🔑</span> Bring Your Own Key</span>
|
||||
<span class="hero-pill ac"><span class="hero-pill-icon">👥</span> Teams & Projects</span>
|
||||
<span class="hero-pill pu"><span class="hero-pill-icon">🧩</span> Extensions</span>
|
||||
<span class="hero-pill ac"><span class="hero-pill-icon">🔍</span> Web Search Tools</span>
|
||||
<span class="hero-pill pu"><span class="hero-pill-icon">📝</span> Notes & Memory</span>
|
||||
</div>
|
||||
<p class="hero-version anim anim-5">v{{.Version}} · gobha.ai</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loginError" class="form-error" style="display:none;margin-bottom:12px;color:#ef4444;font-size:13px;"></div>
|
||||
<!-- ═══ AUTH (right) ═══ -->
|
||||
<div class="auth-panel">
|
||||
<div class="auth-inner">
|
||||
<div class="auth-heading anim anim-a1">
|
||||
<h2 id="authTitle">Welcome back</h2>
|
||||
<p id="authSubtitle">Sign in to your Switchboard instance</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:12px;">
|
||||
<label for="loginUsername" style="display:block;font-size:12px;color:var(--text-secondary,#888);margin-bottom:4px;">Username</label>
|
||||
<input type="text" id="loginUsername" autocomplete="username" autofocus
|
||||
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--border,#2a2a2a);background:var(--bg-secondary,#1a1a1e);color:var(--text-primary,#e0e0e0);font-size:14px;box-sizing:border-box;">
|
||||
<div class="auth-tabs anim anim-a2">
|
||||
<button class="auth-tab active" id="tabLogin" onclick="_switchTab('login')">Log In</button>
|
||||
<button class="auth-tab" id="tabRegister" onclick="_switchTab('register')">Register</button>
|
||||
</div>
|
||||
|
||||
<!-- LOGIN FORM -->
|
||||
<div id="loginForm" class="anim anim-a3">
|
||||
<div class="form-field">
|
||||
<label for="loginUsername">Username</label>
|
||||
<input type="text" id="loginUsername" placeholder="Enter username" autocomplete="username" autofocus>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="loginPassword">Password</label>
|
||||
<input type="password" id="loginPassword" placeholder="Enter password" autocomplete="current-password">
|
||||
</div>
|
||||
<p class="auth-error" id="loginError" style="display:none;"></p>
|
||||
<button class="btn-login" id="loginBtn" onclick="Pages.doLogin()">Log In</button>
|
||||
</div>
|
||||
|
||||
<!-- REGISTER FORM -->
|
||||
<div id="registerForm" style="display:none;">
|
||||
<div class="form-field">
|
||||
<label for="regUsername">Username</label>
|
||||
<input type="text" id="regUsername" placeholder="Choose a username" autocomplete="username">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="regEmail">Email</label>
|
||||
<input type="email" id="regEmail" placeholder="you@example.com" autocomplete="email">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="regPassword">Password</label>
|
||||
<input type="password" id="regPassword" placeholder="Min 8 characters" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="regConfirm">Confirm Password</label>
|
||||
<input type="password" id="regConfirm" placeholder="Re-enter password" autocomplete="new-password">
|
||||
</div>
|
||||
<p class="auth-error" id="regError" style="display:none;"></p>
|
||||
<button class="btn-login" id="regBtn" onclick="_doRegister()">Create Account</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p>Self-hosted instance · Your data stays on your infrastructure</p>
|
||||
</div>
|
||||
<div style="margin-bottom:16px;">
|
||||
<label for="loginPassword" style="display:block;font-size:12px;color:var(--text-secondary,#888);margin-bottom:4px;">Password</label>
|
||||
<input type="password" id="loginPassword" autocomplete="current-password"
|
||||
style="width:100%;padding:8px;border-radius:6px;border:1px solid var(--border,#2a2a2a);background:var(--bg-secondary,#1a1a1e);color:var(--text-primary,#e0e0e0);font-size:14px;box-sizing:border-box;">
|
||||
</div>
|
||||
<button id="loginBtn" onclick="Pages.doLogin()"
|
||||
style="width:100%;padding:10px;border:none;border-radius:6px;background:var(--accent,#5865f2);color:white;font-size:14px;cursor:pointer;">
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Banner.Visible}}
|
||||
<div class="banner banner-bottom" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;">
|
||||
<div class="login-banner" style="background:{{.Banner.Background}};color:{{.Banner.Color}};">
|
||||
{{.Banner.Text}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__BASE__ = '{{.BasePath}}';
|
||||
</script>
|
||||
<script src="{{.BasePath}}/js/pages.js?v={{.Version}}"></script>
|
||||
<script>
|
||||
document.getElementById('loginPassword').addEventListener('keydown', (e) => {
|
||||
function _switchTab(tab) {
|
||||
document.getElementById('loginForm').style.display = tab === 'login' ? '' : 'none';
|
||||
document.getElementById('registerForm').style.display = tab === 'register' ? '' : 'none';
|
||||
document.getElementById('tabLogin').classList.toggle('active', tab === 'login');
|
||||
document.getElementById('tabRegister').classList.toggle('active', tab === 'register');
|
||||
document.getElementById('authTitle').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||||
document.getElementById('authSubtitle').textContent = tab === 'login'
|
||||
? 'Sign in to your Switchboard instance'
|
||||
: 'Join your team on Switchboard';
|
||||
document.getElementById('loginError').textContent = '';
|
||||
document.getElementById('regError').textContent = '';
|
||||
}
|
||||
|
||||
async function _doRegister() {
|
||||
const username = document.getElementById('regUsername').value.trim();
|
||||
const email = document.getElementById('regEmail').value.trim();
|
||||
const password = document.getElementById('regPassword').value;
|
||||
const confirm = document.getElementById('regConfirm').value;
|
||||
const errEl = document.getElementById('regError');
|
||||
const btn = document.getElementById('regBtn');
|
||||
|
||||
if (!username || !password) { errEl.textContent = 'Username and password are required'; return; }
|
||||
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; return; }
|
||||
if (password !== confirm) { errEl.textContent = 'Passwords do not match'; return; }
|
||||
|
||||
errEl.textContent = '';
|
||||
btn.disabled = true; btn.textContent = 'Creating account\u2026';
|
||||
|
||||
const base = window.__BASE__ || '';
|
||||
try {
|
||||
const resp = await fetch(base + '/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Registration failed');
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.pending) {
|
||||
errEl.textContent = 'Account created — pending admin approval.';
|
||||
errEl.style.color = 'var(--success)';
|
||||
setTimeout(() => { errEl.style.color = ''; _switchTab('login'); }, 3000);
|
||||
} else {
|
||||
// Auto-login
|
||||
const storageKey = base ? 'sb_auth_' + base.replace(/\//g, '') : 'sb_auth';
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
accessToken: data.access_token, refreshToken: data.refresh_token, user: data.user,
|
||||
}));
|
||||
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
|
||||
window.location.href = base + '/';
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Create Account';
|
||||
}
|
||||
}
|
||||
|
||||
// Enter key handlers
|
||||
document.getElementById('loginPassword').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') Pages.doLogin();
|
||||
});
|
||||
document.getElementById('loginUsername').addEventListener('keydown', (e) => {
|
||||
document.getElementById('loginUsername').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('loginPassword').focus();
|
||||
});
|
||||
document.getElementById('regConfirm').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') _doRegister();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,193 +1,117 @@
|
||||
{{/* Admin surface — full admin panel with category/section navigation.
|
||||
Categories and sections mirror the SPA's ADMIN_SECTIONS structure.
|
||||
Server-rendered sections: roles, routing, providers, models, teams, users, settings.
|
||||
Hybrid sections: container div + JS loader fills content. */}}
|
||||
{{/*
|
||||
Admin surface — matches switchboard-prototype-admin.jsx.
|
||||
Full-page layout: topbar (with category tabs) + left nav + content area.
|
||||
JS (admin-handlers.js, ui-admin.js) populates dynamic sections.
|
||||
*/}}
|
||||
|
||||
{{define "surface-admin"}}
|
||||
<div class="admin-surface">
|
||||
{{/* ── Category bar (top) ───────────────── */}}
|
||||
<div class="admin-cat-bar">
|
||||
{{$cat := .Data.Category}}
|
||||
<button class="admin-cat{{if eq $cat "people"}} active{{end}}" onclick="window.location='{{.BasePath}}/admin/users'">People</button>
|
||||
<button class="admin-cat{{if eq $cat "ai"}} active{{end}}" onclick="window.location='{{.BasePath}}/admin/providers'">AI</button>
|
||||
<button class="admin-cat{{if eq $cat "routing"}} active{{end}}" onclick="window.location='{{.BasePath}}/admin/health'">Routing</button>
|
||||
<button class="admin-cat{{if eq $cat "system"}} active{{end}}" onclick="window.location='{{.BasePath}}/admin/settings'">System</button>
|
||||
<button class="admin-cat{{if eq $cat "monitoring"}} active{{end}}" onclick="window.location='{{.BasePath}}/admin/usage'">Monitoring</button>
|
||||
<div class="admin-cat-spacer"></div>
|
||||
<a href="{{.BasePath}}/" class="admin-back-link">← Chat</a>
|
||||
<div class="surface-admin">
|
||||
|
||||
{{/* Top Bar */}}
|
||||
<div class="admin-topbar">
|
||||
<a href="{{.BasePath}}/" class="admin-topbar-back">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to Chat
|
||||
</a>
|
||||
<div class="admin-topbar-sep"></div>
|
||||
<span class="admin-topbar-title">🔀 Administration</span>
|
||||
|
||||
{{/* Category tabs */}}
|
||||
<div class="admin-category-tabs" id="adminCategoryTabs">
|
||||
{{$section := .Section}}
|
||||
{{$base := .BasePath}}
|
||||
<a href="{{$base}}/admin/users" class="admin-cat-btn{{if eq $section "users"}} active{{else if eq $section "teams"}} active{{else if eq $section "groups"}} active{{end}}" data-cat="people">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
|
||||
People
|
||||
</a>
|
||||
<a href="{{$base}}/admin/providers" class="admin-cat-btn{{if eq $section "providers"}} active{{else if eq $section "models"}} active{{else if eq $section "personas"}} active{{else if eq $section "roles"}} active{{else if eq $section "knowledgeBases"}} active{{else if eq $section "memory"}} active{{end}}" data-cat="ai">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/></svg>
|
||||
AI
|
||||
</a>
|
||||
<a href="{{$base}}/admin/health" class="admin-cat-btn{{if eq $section "health"}} active{{else if eq $section "routing"}} active{{else if eq $section "capabilities"}} active{{end}}" data-cat="routing">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Routing
|
||||
</a>
|
||||
<a href="{{$base}}/admin/settings" class="admin-cat-btn{{if eq $section "settings"}} active{{else if eq $section "storage"}} active{{else if eq $section "extensions"}} active{{end}}" data-cat="system">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09"/></svg>
|
||||
System
|
||||
</a>
|
||||
<a href="{{$base}}/admin/usage" class="admin-cat-btn{{if eq $section "usage"}} active{{else if eq $section "audit"}} active{{else if eq $section "stats"}} active{{end}}" data-cat="monitoring">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Monitoring
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-body">
|
||||
{{/* ── Section sidebar ──────────────── */}}
|
||||
<nav class="admin-sidebar">
|
||||
{{$section := .Section}}
|
||||
{{$bp := .BasePath}}
|
||||
{{if eq $cat "people"}}
|
||||
<a href="{{$bp}}/admin/users" class="admin-nav-link{{if eq $section "users"}} active{{end}}">Users</a>
|
||||
<a href="{{$bp}}/admin/teams" class="admin-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
|
||||
<a href="{{$bp}}/admin/groups" class="admin-nav-link{{if eq $section "groups"}} active{{end}}">Groups</a>
|
||||
{{else if eq $cat "ai"}}
|
||||
<a href="{{$bp}}/admin/providers" class="admin-nav-link{{if eq $section "providers"}} active{{end}}">Providers</a>
|
||||
<a href="{{$bp}}/admin/models" class="admin-nav-link{{if eq $section "models"}} active{{end}}">Models</a>
|
||||
<a href="{{$bp}}/admin/presets" class="admin-nav-link{{if eq $section "presets"}} active{{end}}">Personas</a>
|
||||
<a href="{{$bp}}/admin/roles" class="admin-nav-link{{if eq $section "roles"}} active{{end}}">Roles</a>
|
||||
<a href="{{$bp}}/admin/knowledgeBases" class="admin-nav-link{{if eq $section "knowledgeBases"}} active{{end}}">Knowledge</a>
|
||||
<a href="{{$bp}}/admin/memory" class="admin-nav-link{{if eq $section "memory"}} active{{end}}">Memory</a>
|
||||
{{else if eq $cat "routing"}}
|
||||
<a href="{{$bp}}/admin/health" class="admin-nav-link{{if eq $section "health"}} active{{end}}">Health</a>
|
||||
<a href="{{$bp}}/admin/routing" class="admin-nav-link{{if eq $section "routing"}} active{{end}}">Routing</a>
|
||||
<a href="{{$bp}}/admin/capabilities" class="admin-nav-link{{if eq $section "capabilities"}} active{{end}}">Capabilities</a>
|
||||
{{else if eq $cat "system"}}
|
||||
<a href="{{$bp}}/admin/settings" class="admin-nav-link{{if eq $section "settings"}} active{{end}}">Settings</a>
|
||||
<a href="{{$bp}}/admin/storage" class="admin-nav-link{{if eq $section "storage"}} active{{end}}">Storage</a>
|
||||
<a href="{{$bp}}/admin/extensions" class="admin-nav-link{{if eq $section "extensions"}} active{{end}}">Extensions</a>
|
||||
{{else if eq $cat "monitoring"}}
|
||||
<a href="{{$bp}}/admin/usage" class="admin-nav-link{{if eq $section "usage"}} active{{end}}">Usage</a>
|
||||
<a href="{{$bp}}/admin/audit" class="admin-nav-link{{if eq $section "audit"}} active{{end}}">Audit</a>
|
||||
<a href="{{$bp}}/admin/stats" class="admin-nav-link{{if eq $section "stats"}} active{{end}}">Stats</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
|
||||
{{/* ── Content area ─────────────────── */}}
|
||||
<div class="admin-content">
|
||||
{{if eq .Section "roles"}}{{template "admin-roles" .}}
|
||||
{{else if eq .Section "routing"}}{{template "admin-routing" .}}
|
||||
{{else if eq .Section "providers"}}{{template "admin-providers" .}}
|
||||
{{else if eq .Section "models"}}{{template "admin-models" .}}
|
||||
{{else if eq .Section "teams"}}{{template "admin-teams" .}}
|
||||
{{else if eq .Section "users"}}{{template "admin-users" .}}
|
||||
{{else if eq .Section "settings"}}{{template "admin-settings" .}}
|
||||
{{else}}
|
||||
{{/* Hybrid: container for JS-loaded sections */}}
|
||||
<div class="admin-page" id="adminHybridContainer" data-section="{{.Section}}">
|
||||
<div class="admin-loading">Loading {{.Section}}…</div>
|
||||
{{/* Left Nav */}}
|
||||
<div class="admin-nav" id="adminNav">
|
||||
{{/* People sections */}}
|
||||
{{if eq $section "users"}}<a href="{{$base}}/admin/users" class="admin-nav-link active">Users</a>
|
||||
{{else}}<a href="{{$base}}/admin/users" class="admin-nav-link">Users</a>{{end}}
|
||||
{{if eq $section "teams"}}<a href="{{$base}}/admin/teams" class="admin-nav-link active">Teams</a>
|
||||
{{else}}<a href="{{$base}}/admin/teams" class="admin-nav-link">Teams</a>{{end}}
|
||||
{{if eq $section "groups"}}<a href="{{$base}}/admin/groups" class="admin-nav-link active">Groups</a>
|
||||
{{else}}<a href="{{$base}}/admin/groups" class="admin-nav-link">Groups</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Content Area */}}
|
||||
<div class="admin-content">
|
||||
<div class="admin-content-header">
|
||||
<h2 id="adminSectionTitle">{{$section}}</h2>
|
||||
<div style="flex:1;"></div>
|
||||
<div class="admin-search">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-3);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="adminSearch" placeholder="Search…" autocomplete="off">
|
||||
</div>
|
||||
<button id="adminAddBtn" class="btn-md btn-primary" style="display:none;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-content-body" id="adminContentBody" data-section="{{$section}}">
|
||||
{{/* Populated by admin-handlers.js */}}
|
||||
<div class="settings-placeholder" id="adminDynamic">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
{{/* Confirm dialog */}}
|
||||
<div id="confirmModal" class="modal-overlay">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<div class="modal-header">
|
||||
<h2 id="confirmTitle">Confirm</h2>
|
||||
<button class="modal-close" onclick="closeModal('confirmModal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirmMessage" style="color:var(--text-2);font-size:14px;line-height:1.65;"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-md btn-ghost" onclick="closeModal('confirmModal')">Cancel</button>
|
||||
<button id="confirmOkBtn" class="btn-md btn-danger">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "css-admin"}}
|
||||
<style>
|
||||
/* ── Admin surface layout ───────────────── */
|
||||
.admin-surface { display: flex; flex-direction: column; height: 100%; font-family: inherit; }
|
||||
.admin-cat-bar {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border, #2a2a2a);
|
||||
flex-shrink: 0; background: var(--bg-secondary, #1a1a1e);
|
||||
}
|
||||
.admin-cat {
|
||||
background: none; border: none; color: var(--text-secondary, #888);
|
||||
padding: 5px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.admin-cat:hover { background: var(--hover, #252528); color: var(--text-primary, #e0e0e0); }
|
||||
.admin-cat.active { background: var(--bg-tertiary, #252528); color: var(--text-primary, #e0e0e0); font-weight: 600; }
|
||||
.admin-cat-spacer { flex: 1; }
|
||||
.admin-back-link { color: var(--text-secondary, #888); text-decoration: none; font-size: 13px; padding: 5px 8px; }
|
||||
.admin-back-link:hover { color: var(--text-primary, #e0e0e0); }
|
||||
|
||||
.admin-body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.admin-sidebar {
|
||||
width: 160px; border-right: 1px solid var(--border, #2a2a2a);
|
||||
padding: 8px; overflow-y: auto; flex-shrink: 0;
|
||||
}
|
||||
.admin-nav-link {
|
||||
display: block; padding: 6px 10px; border-radius: 6px;
|
||||
color: var(--text-primary, #e0e0e0); text-decoration: none;
|
||||
font-size: 13px; margin-bottom: 2px;
|
||||
}
|
||||
.admin-nav-link.active { background: var(--bg-tertiary, #252528); }
|
||||
.admin-nav-link:hover { background: var(--bg-secondary, #1a1a1e); }
|
||||
.admin-content { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
|
||||
/* ── Shared admin styles ────────────────── */
|
||||
.admin-page h2 { font-size: 18px; margin: 0 0 8px; }
|
||||
.section-hint { font-size: 13px; color: var(--text-secondary, #888); margin: 0 0 16px; }
|
||||
.settings-section { padding: 16px; background: var(--bg-secondary, #1a1a1e); border-radius: 8px; border: 1px solid var(--border, #2a2a2a); margin-bottom: 16px; }
|
||||
.form-row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.form-group { margin-bottom: 8px; }
|
||||
.form-group label { display: block; font-size: 12px; color: var(--text-secondary, #888); margin-bottom: 4px; }
|
||||
.form-group select, .form-group input, .form-group textarea {
|
||||
padding: 6px 8px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a);
|
||||
background: var(--bg-primary, #0e0e10); color: var(--text-primary, #e0e0e0); font-size: 13px; width: 100%;
|
||||
}
|
||||
.btn-small { padding: 6px 14px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.btn-primary { background: var(--accent, #5865f2); color: white; }
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
.btn-danger { background: var(--error, #ef4444); color: white; }
|
||||
.btn-danger:hover { opacity: 0.9; }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
|
||||
.admin-inline-form { padding: 16px; background: var(--bg-secondary, #1a1a1e); border-radius: 8px; border: 1px solid var(--border, #2a2a2a); margin-bottom: 16px; }
|
||||
.empty-hint { font-size: 13px; color: var(--text-secondary, #888); padding: 12px 0; }
|
||||
.admin-loading { font-size: 13px; color: var(--text-tertiary, #666); padding: 20px; text-align: center; }
|
||||
|
||||
/* ── Tables ──────────────────────────────── */
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th {
|
||||
text-align: left; padding: 8px; font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--text-tertiary, #666); border-bottom: 1px solid var(--border, #2a2a2a);
|
||||
}
|
||||
.admin-table td { padding: 8px; border-bottom: 1px solid var(--border, #2a2a2a); vertical-align: top; }
|
||||
.admin-table tr:hover td { background: var(--hover, rgba(255,255,255,0.02)); }
|
||||
.admin-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.admin-badge-admin { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||
.admin-badge-user { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.admin-badge-active { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.admin-badge-inactive { background: rgba(239,68,68,0.15); color: #ef4444; }
|
||||
.admin-badge-global { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||||
.admin-badge-team { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.admin-badge-personal { background: rgba(234,179,8,0.15); color: #eab308; }
|
||||
|
||||
/* ── Toolbar ─────────────────────────────── */
|
||||
.admin-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.admin-search {
|
||||
padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a);
|
||||
background: var(--bg-primary, #0e0e10); color: var(--text-primary, #e0e0e0);
|
||||
font-size: 13px; min-width: 200px;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{/* CSS: consolidated into styles.css */}}
|
||||
{{define "css-admin"}}{{end}}
|
||||
|
||||
{{/* Scripts */}}
|
||||
{{define "scripts-admin"}}
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
// Hybrid section loader — for admin sections not yet server-rendered.
|
||||
// Detects the data-section on the hybrid container and calls the
|
||||
// appropriate legacy loader if ui-admin.js is loaded.
|
||||
(function() {
|
||||
const container = document.getElementById('adminHybridContainer');
|
||||
if (!container) return;
|
||||
const section = container.dataset.section;
|
||||
if (!section) return;
|
||||
|
||||
// Wait for UI and admin loaders to be available
|
||||
function tryLoad() {
|
||||
if (typeof UI === 'undefined' || typeof UI.loadAdminUsers === 'undefined') {
|
||||
setTimeout(tryLoad, 100);
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
const LOADERS = {
|
||||
groups: () => UI.loadAdminGroups(),
|
||||
presets: () => UI.loadAdminPresets(),
|
||||
knowledgeBases: () => typeof KnowledgeUI !== 'undefined' ? KnowledgeUI.openAdminPanel() : null,
|
||||
memory: () => typeof MemoryUI !== 'undefined' ? MemoryUI.openAdminPanel() : null,
|
||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||
extensions: () => UI.loadAdminExtensions(),
|
||||
health: () => UI.loadAdminHealth(),
|
||||
capabilities: () => UI.loadAdminCapabilities(),
|
||||
usage: () => UI.loadAdminUsage(),
|
||||
audit: () => UI.loadAuditLog(),
|
||||
stats: () => UI.loadAdminStats(),
|
||||
};
|
||||
const loader = LOADERS[section];
|
||||
if (loader) loader();
|
||||
else container.innerHTML = '<div class="empty-hint">Section not found</div>';
|
||||
}
|
||||
tryLoad();
|
||||
})();
|
||||
</script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-admin.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-handlers.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/admin-scaffold.js?v={{.Version}}"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -55,12 +55,12 @@
|
||||
{{/* ── Sidebar ─────────────────────── */}}
|
||||
<div id="sidebar" class="sidebar">
|
||||
<div class="sidebar-top">
|
||||
{{/* Brand */}}
|
||||
<div class="sb-brand">
|
||||
{{/* Brand — click toggles sidebar */}}
|
||||
<div class="sb-brand" onclick="UI.toggleSidebar()" role="button" tabindex="0">
|
||||
<div id="brandSidebarLogo" class="sb-logo">
|
||||
<img src="{{.BasePath}}/favicon.svg" alt="" style="width:28px;height:28px;">
|
||||
<img src="{{.BasePath}}/favicon-32.png" alt="" class="brand-logo-img">
|
||||
</div>
|
||||
<span id="brandSidebarText" class="brand-text">Chat Switchboard</span>
|
||||
<span id="brandSidebarText" class="brand-text">Switchboard</span>
|
||||
<span id="brandWordmark" class="brand-text" style="display:none;"></span>
|
||||
</div>
|
||||
|
||||
@@ -73,23 +73,40 @@
|
||||
<button id="newChatDropBtn" class="split-btn-drop" title="More options">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<div id="newChatDropdown" class="split-btn-dropdown">
|
||||
{{/* Populated by projects-ui.js */}}
|
||||
<div id="newChatDropdown" class="split-dropdown">
|
||||
<button class="split-dropdown-item" onclick="document.getElementById('newChatDropdown').classList.remove('open'); newChat();">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
<span>New Chat</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item" onclick="document.getElementById('newChatDropdown').classList.remove('open'); if(typeof createProject==='function') createProject();">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>New Project</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span>Group Chat</span>
|
||||
<span class="dd-hint">soon</span>
|
||||
</button>
|
||||
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>New Channel</span>
|
||||
<span class="dd-hint">soon</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Search */}}
|
||||
{{/* Search (outside sidebar-top, below brand section) */}}
|
||||
<div id="sidebarSearch" class="sidebar-search">
|
||||
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
|
||||
<button id="chatSearchClear" class="search-clear" title="Clear search">✕</button>
|
||||
<button id="chatSearchClear" class="sidebar-search-clear" title="Clear search">✕</button>
|
||||
</div>
|
||||
|
||||
{{/* Sidebar toggle (hamburger) */}}
|
||||
{{/* Sidebar toggle (hidden on desktop, used by JS for mobile) */}}
|
||||
<button id="sidebarToggle" class="sb-btn sidebar-toggle" title="Toggle sidebar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{/* Sidebar Tabs (Chats / Files) */}}
|
||||
<div id="sidebarTabs" class="sidebar-tabs" style="display:none;">
|
||||
@@ -107,6 +124,12 @@
|
||||
|
||||
{{/* User / bottom */}}
|
||||
<div class="sidebar-bottom">
|
||||
{{/* Notes shortcut */}}
|
||||
<button id="notesBtn" class="sb-btn sidebar-notes-btn">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<span class="sb-label">Notes</span>
|
||||
</button>
|
||||
<div class="sidebar-bottom-divider"></div>
|
||||
<button id="userMenuBtn" class="user-btn">
|
||||
<div id="userAvatar" class="user-avatar">
|
||||
<span id="avatarLetter">?</span>
|
||||
@@ -151,7 +174,7 @@
|
||||
<div class="workspace">
|
||||
<div class="workspace-primary workspace-pane">
|
||||
|
||||
{{/* Chat header: model selector + token count */}}
|
||||
{{/* Chat header: model selector + caps + tokens + panel + notif */}}
|
||||
<div class="chat-header">
|
||||
<div id="modelDropdown" class="model-dropdown">
|
||||
<button id="modelDropdownBtn" class="model-dropdown-btn">
|
||||
@@ -169,6 +192,21 @@
|
||||
<button id="summarizeBtn" class="action-btn" style="display:none;" title="Summarize older messages">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
</button>
|
||||
{{/* Panel toggle */}}
|
||||
<button id="panelToggleBtn" class="action-btn" title="Toggle side panel" onclick="if(typeof PanelRegistry!=='undefined')PanelRegistry.toggle('preview')">
|
||||
<svg width="16" height="16" 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="15" y1="3" x2="15" y2="21"/></svg>
|
||||
</button>
|
||||
{{/* Notification bell (inline, not fixed) */}}
|
||||
<div id="notifWrap" class="notif-wrap">
|
||||
<button class="notif-bell" onclick="Notifications.toggleDropdown()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span id="notifBadge" class="notif-badge" style="display:none;">0</span>
|
||||
</button>
|
||||
<div id="notifDropdown" class="notif-dropdown" style="display:none;">
|
||||
<div id="notifPanelList"></div>
|
||||
<div id="notifPanelMore" style="text-align:center;padding:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,12 +226,28 @@
|
||||
{{/* Streaming tools display */}}
|
||||
<div id="streamTools" class="stream-tools" style="display:none;"></div>
|
||||
|
||||
{{/* Input area (centered, padded bottom) */}}
|
||||
<div class="chat-input-area">
|
||||
{{/* Input toolbar (attach, tools toggle, KB) */}}
|
||||
<div class="input-toolbar">
|
||||
<button id="attachBtn" class="action-btn attach-btn" title="Attach file">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
{{/* tools-toggle.js adds toggle buttons here */}}
|
||||
{{/* Tools toggle */}}
|
||||
<div class="tools-toggle-wrap" style="display:none;">
|
||||
<button id="toolsToggleBtn" class="action-btn tools-toggle-btn" title="Tools">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</button>
|
||||
<div id="toolsPopup" class="popup tools-popup"></div>
|
||||
</div>
|
||||
{{/* Knowledge Bases toggle */}}
|
||||
<div class="kb-toggle-wrap">
|
||||
<button id="kbToggleBtn" class="action-btn kb-toggle-btn" title="Knowledge Bases">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||||
</button>
|
||||
<div id="kbPopup" class="popup kb-popup"></div>
|
||||
</div>
|
||||
{{/* tools-toggle.js + knowledge-ui.js show/populate these at init */}}
|
||||
</div>
|
||||
|
||||
{{/* Input wrap: CM6 editor + send/stop */}}
|
||||
@@ -212,6 +266,11 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-footer">
|
||||
<span class="chat-version">Switchboard v{{.Version}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Resize handle */}}
|
||||
@@ -239,7 +298,7 @@
|
||||
<p>Open a code block preview or extension output here.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{/* Notes and project panels are registered dynamically */}}
|
||||
{{/* Notes and project panels are registered dynamically by their JS modules */}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,13 +332,13 @@
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button class="modal-close" onclick="closeModal('settingsModal')">✕</button>
|
||||
<button id="settingsCloseBtn" class="modal-close" onclick="closeModal('settingsModal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-tabs">
|
||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||
<button id="settingsProvidersTabBtn" class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">My Providers</button>
|
||||
<button id="settingsPersonasTabBtn" class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')">My Presets</button>
|
||||
<button id="settingsPersonasTabBtn" class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')">My Personas</button>
|
||||
<button id="settingsUsageTabBtn" class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')">Usage</button>
|
||||
<button class="settings-tab" data-stab="profile" onclick="UI.switchSettingsTab('profile')">Profile</button>
|
||||
<button id="settingsRolesTabBtn" class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" style="display:none;">Roles</button>
|
||||
@@ -333,11 +392,11 @@
|
||||
<div id="providerAddForm" style="display:none;"></div>
|
||||
<div id="providerList"></div>
|
||||
</div>
|
||||
{{/* Presets tab */}}
|
||||
{{/* Personas tab */}}
|
||||
<div id="settingsPersonasTab" class="settings-tab-content" style="display:none;">
|
||||
<button id="userAddPresetBtn" class="btn-small" style="display:none;">+ New Preset</button>
|
||||
<div id="userAddPresetForm" style="display:none;"></div>
|
||||
<div id="userPresetList"></div>
|
||||
<button id="userAddPersonaBtn" class="btn-small" style="display:none;">+ New Persona</button>
|
||||
<div id="userAddPersonaForm" style="display:none;"></div>
|
||||
<div id="userPersonaList"></div>
|
||||
</div>
|
||||
{{/* Usage tab */}}
|
||||
<div id="settingsUsageTab" class="settings-tab-content" style="display:none;">
|
||||
@@ -345,6 +404,19 @@
|
||||
</div>
|
||||
{{/* Profile tab */}}
|
||||
<div id="settingsProfileTab" class="settings-tab-content" style="display:none;">
|
||||
<div class="form-group">
|
||||
<label>Avatar</label>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px;">
|
||||
<div id="avatarPreview" class="user-avatar" style="width:48px;height:48px;font-size:20px;">
|
||||
<span id="avatarPreviewLetter">?</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button id="avatarUploadBtn" class="btn-small">Upload</button>
|
||||
<button id="avatarRemoveBtn" class="btn-small btn-danger" style="display:none;">Remove</button>
|
||||
</div>
|
||||
<input type="file" id="avatarFileInput" accept="image/*" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" id="profileDisplayName">
|
||||
@@ -354,12 +426,12 @@
|
||||
<input type="email" id="profileEmail" disabled>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn-small" onclick="document.getElementById('profileChangePwForm').style.display=''">Change Password</button>
|
||||
<button id="profileChangePwBtn" class="btn-small">Change Password</button>
|
||||
<div id="profileChangePwForm" style="display:none;margin-top:8px;">
|
||||
<input type="password" id="settingsCurrentPw" placeholder="Current password" autocomplete="current-password" style="margin-bottom:6px;">
|
||||
<input type="password" id="settingsNewPw" placeholder="New password" autocomplete="new-password" style="margin-bottom:6px;">
|
||||
<input type="password" id="settingsConfirmPw" placeholder="Confirm new password" autocomplete="new-password" style="margin-bottom:6px;">
|
||||
<button class="btn-small" onclick="Pages.changePassword()">Update Password</button>
|
||||
<input type="password" id="profileCurrentPw" placeholder="Current password" autocomplete="current-password" style="margin-bottom:6px;">
|
||||
<input type="password" id="profileNewPw" placeholder="New password" autocomplete="new-password" style="margin-bottom:6px;">
|
||||
<input type="password" id="profileConfirmPw" placeholder="Confirm new password" autocomplete="new-password" style="margin-bottom:6px;">
|
||||
<button id="profileSavePwBtn" class="btn-small">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,8 +449,8 @@
|
||||
<div id="settingsTeamsTab" class="settings-tab-content" style="display:none;">
|
||||
<div id="settingsTeamsList"></div>
|
||||
<div id="settingsTeamMembers" style="display:none;"></div>
|
||||
<div id="settingsTeamPresets" style="display:none;">
|
||||
<div id="adminPresetList"></div>
|
||||
<div id="settingsTeamPersonas" style="display:none;">
|
||||
<div id="adminPersonaList"></div>
|
||||
</div>
|
||||
<div id="settingsTeamProviders" style="display:none;">
|
||||
<button id="settingsTeamAddProviderBtn" class="btn-small" style="display:none;">+ Add Provider</button>
|
||||
@@ -394,7 +466,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" onclick="UI.saveSettingsAndClose()">Save</button>
|
||||
<button id="settingsSaveBtn" class="btn-primary" onclick="UI.saveSettingsAndClose()">Save</button>
|
||||
<button class="btn-secondary" onclick="closeModal('settingsModal')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,7 +529,7 @@
|
||||
<div class="form-group"><label><input type="checkbox" id="adminRegToggle"> Registration Enabled</label></div>
|
||||
<div class="form-group"><label>Default User State</label><select id="adminRegDefaultState"><option value="active">Active</option><option value="pending">Pending Approval</option></select></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="adminUserProvidersToggle"> Allow User BYOK</label></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="adminUserPresetsToggle"> Allow User Presets</label></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="adminUserPersonasToggle"> Allow User Personas</label></div>
|
||||
<div class="form-group"><label><input type="checkbox" id="adminKBDirectAccessToggle"> KB Direct Access</label></div>
|
||||
<div class="form-group"><label>Default Model</label><select id="adminDefaultModel"></select></div>
|
||||
<div class="form-group"><label>System Prompt</label><textarea id="adminSystemPrompt" rows="4"></textarea></div>
|
||||
@@ -544,8 +616,8 @@
|
||||
<button id="teamAuditNextBtn" class="btn-small">→</button>
|
||||
</div>
|
||||
</div>
|
||||
{{/* Team preset model select */}}
|
||||
<div style="display:none;"><select id="teamPreset_model"></select></div>
|
||||
{{/* Team persona model select */}}
|
||||
<div style="display:none;"><select id="teamPersona_model"></select></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -590,17 +662,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Notification Bell ───────────────────── */}}
|
||||
<div id="notifWrap" class="notif-wrap" style="position:fixed;top:8px;right:12px;z-index:100;">
|
||||
<button class="notif-btn" onclick="Notifications.toggleDropdown()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span id="notifBadge" class="notif-badge" style="display:none;">0</span>
|
||||
</button>
|
||||
<div id="notifDropdown" class="notif-dropdown" style="display:none;">
|
||||
<div id="notifPanelList"></div>
|
||||
<div id="notifPanelMore" style="text-align:center;padding:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{/* ── Notification bell is now inside .chat-header ── */}}
|
||||
|
||||
{{/* ── Routing Policy Form (shared) ────────── */}}
|
||||
<div id="routingPolicyForm" style="display:none;">
|
||||
@@ -649,7 +711,6 @@
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/extensions.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/panels.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-format.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-core.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-settings.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/ui-admin.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/tokens.js?v={{$.Version}}"></script>
|
||||
@@ -667,6 +728,7 @@
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/chat.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/settings-handlers.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/surface-nav.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/editor-mode.js?v={{$.Version}}"></script>
|
||||
<script nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>
|
||||
{{end}}
|
||||
|
||||
@@ -1,91 +1,127 @@
|
||||
{{/*
|
||||
Editor surface (Phase 2b).
|
||||
Server renders the layout shell with proper CSS sizing.
|
||||
editor-mode.js mounts CodeMirror, file tree, etc. into these containers.
|
||||
Fixes bugs #4 and #5: layout collapse when switching surfaces.
|
||||
Editor surface — matches switchboard-prototype-editor.jsx.
|
||||
IDE-like layout: topbar + file tree + tabs + code + chat pane.
|
||||
editor-mode.js builds CodeMirror, file tree, etc.
|
||||
*/}}
|
||||
|
||||
{{define "surface-editor"}}
|
||||
<div class="surface-editor" id="surfaceEditor" data-ws-id="{{.Data.WorkspaceID}}" data-ws-name="{{.Data.WorkspaceName}}">
|
||||
{{/* Header — editor-mode.js populates this */}}
|
||||
<div class="surface-editor-header" id="editorHeaderMount"></div>
|
||||
<div class="surface-editor">
|
||||
|
||||
<div class="surface-editor-body">
|
||||
{{/* Editor main area — left pane (tabs + code) + split handle + right pane (chat) */}}
|
||||
<div class="surface-editor-main" id="editorMainMount"></div>
|
||||
{{/* Top Bar */}}
|
||||
<div class="editor-topbar">
|
||||
<a href="{{.BasePath}}/" class="editor-topbar-back">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back
|
||||
</a>
|
||||
<div style="width:1px;height:18px;background:var(--border);"></div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
<span style="font-size:13px;font-weight:600;" id="editorWorkspaceName">
|
||||
{{if .Data}}{{.Data.WorkspaceName}}{{else}}Editor{{end}}
|
||||
</span>
|
||||
<div style="display:flex;align-items:center;gap:4px;background:var(--purple-dim);padding:2px 8px;border-radius:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--purple);font-family:var(--mono);">main</span>
|
||||
</div>
|
||||
<div style="flex:1;"></div>
|
||||
<button class="icon-btn" id="editorNewFileBtn" title="New File">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorRefreshBtn" title="Refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorSearchBtn" title="Search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
</button>
|
||||
<div style="width:1px;height:18px;background:var(--border);"></div>
|
||||
<button class="icon-btn" id="editorToggleTree" title="Toggle file tree">
|
||||
<svg width="14" height="14" 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>
|
||||
</button>
|
||||
<button class="icon-btn" id="editorToggleChat" title="Toggle chat">
|
||||
<svg width="14" height="14" 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="15" y1="3" x2="15" y2="21"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{/* Body: tree + editor + chat */}}
|
||||
<div class="editor-body" id="editorBody">
|
||||
{{/* File Tree */}}
|
||||
<div class="editor-tree" id="editorFileTree">
|
||||
<div class="editor-tree-header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:0.4px;flex:1;">Files</span>
|
||||
<button class="icon-btn" id="treeNewFileBtn" title="New file">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-tree-files" id="editorTreeFiles">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Main Editor Area */}}
|
||||
<div class="editor-main" id="editorMain">
|
||||
{{/* Tabs */}}
|
||||
<div class="editor-tabs" id="editorTabs">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
|
||||
{{/* Code Content */}}
|
||||
<div class="editor-content" id="editorContent">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-3);">
|
||||
<div style="text-align:center;">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:0.12;display:inline-block;margin-bottom:10px;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||
<p style="margin:0;font-size:14px;">Open a file to start editing</p>
|
||||
<p style="margin:6px 0 0;font-size:12px;">Select from the file tree or Ctrl+P</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Status Bar */}}
|
||||
<div class="editor-statusbar" id="editorStatusbar">
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Chat Pane (resizable) */}}
|
||||
<div id="editorSplitHandle" style="width:5px;background:var(--border);cursor:col-resize;flex-shrink:0;display:none;"></div>
|
||||
<div class="editor-chat" id="editorChatPane" style="display:none;">
|
||||
<div class="editor-chat-header">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span style="font-size:12px;font-weight:600;flex:1;">AI Chat</span>
|
||||
<span class="badge badge-accent" id="editorChatModel">claude-sonnet-4-5</span>
|
||||
</div>
|
||||
<div class="editor-chat-messages" id="editorChatMessages">
|
||||
{{/* Populated by editor-mode.js */}}
|
||||
</div>
|
||||
<div class="editor-chat-input">
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;">
|
||||
<textarea id="editorChatInput" placeholder="Ask about this code…" rows="2"
|
||||
style="flex:1;background:var(--input-bg,var(--bg));border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:12px;font-family:inherit;outline:none;resize:none;"></textarea>
|
||||
<button id="editorChatSendBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Send</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:6px;font-size:10px;color:var(--text-3);">
|
||||
<span id="editorChatContext">Context: none</span>
|
||||
<span>·</span>
|
||||
<span>Cmd+L to focus</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
{{end}}
|
||||
|
||||
{{/* CSS: consolidated into styles.css */}}
|
||||
{{define "css-editor"}}
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/editor-mode.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/persona-kb.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/memory.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/notifications.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/channel-models.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/branding/custom.css" onerror="this.remove()">
|
||||
<style>
|
||||
/*
|
||||
* Editor surface layout — owns the full viewport below banners.
|
||||
* This is the bug #5 fix: height is set by the server, not fought over
|
||||
* with other surfaces' CSS.
|
||||
*/
|
||||
.surface-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.surface-editor-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.surface-editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.surface-editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{/* Scripts */}}
|
||||
{{define "scripts-editor"}}
|
||||
{{/* Chat rendering for the assist pane */}}
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/codemirror/codemirror.bundle.js?v={{.Version}}" onerror="console.warn('[CM6] Bundle not available')"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/attachments.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tokens.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/extensions.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notes.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/knowledge-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/persona-kb.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/panels.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/editor-mode.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/projects-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/tools-toggle.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/app.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
// Editor surface boot: initialize and mount into template containers
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof EditorMode !== 'undefined' && window.__SURFACE__ === 'editor') {
|
||||
const data = window.__PAGE_DATA__ || {};
|
||||
if (data.WorkspaceID) {
|
||||
EditorMode.mountServerRendered(data.WorkspaceID, data.WorkspaceName || 'Workspace');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
{{define "scripts-notes"}}
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/chat.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/attachments.js?v={{.Version}}"></script>
|
||||
|
||||
@@ -1,236 +1,162 @@
|
||||
{{/*
|
||||
Settings surface (Phase 2b).
|
||||
Server renders a full-page settings layout (replaces the modal).
|
||||
Reuses existing settings-handlers.js + ui-settings.js for interactions.
|
||||
Settings surface — matches switchboard-prototype-settings.jsx.
|
||||
Full-page layout: topbar + left nav + content area.
|
||||
JS (settings-handlers.js, ui-settings.js) populates dynamic sections.
|
||||
*/}}
|
||||
|
||||
{{define "surface-settings"}}
|
||||
<div class="surface-settings">
|
||||
<div class="settings-nav">
|
||||
<div class="settings-nav-title">Settings</div>
|
||||
{{$section := .Section}}
|
||||
<a href="{{.BasePath}}/settings/general" class="settings-nav-link{{if eq $section "general"}} active{{end}}">General</a>
|
||||
<a href="{{.BasePath}}/settings/appearance" class="settings-nav-link{{if eq $section "appearance"}} active{{end}}">Appearance</a>
|
||||
<a href="{{.BasePath}}/settings/providers" class="settings-nav-link{{if eq $section "providers"}} active{{end}}">My Providers</a>
|
||||
<a href="{{.BasePath}}/settings/models" class="settings-nav-link{{if eq $section "models"}} active{{end}}">My Models</a>
|
||||
<a href="{{.BasePath}}/settings/personas" class="settings-nav-link{{if eq $section "personas"}} active{{end}}">My Presets</a>
|
||||
<a href="{{.BasePath}}/settings/roles" class="settings-nav-link{{if eq $section "roles"}} active{{end}}">Model Roles</a>
|
||||
<a href="{{.BasePath}}/settings/knowledge" class="settings-nav-link{{if eq $section "knowledge"}} active{{end}}">Knowledge Bases</a>
|
||||
<a href="{{.BasePath}}/settings/memory" class="settings-nav-link{{if eq $section "memory"}} active{{end}}">Memory</a>
|
||||
<a href="{{.BasePath}}/settings/notifications" class="settings-nav-link{{if eq $section "notifications"}} active{{end}}">Notifications</a>
|
||||
<a href="{{.BasePath}}/settings/usage" class="settings-nav-link{{if eq $section "usage"}} active{{end}}">Usage</a>
|
||||
<div class="settings-nav-sep"></div>
|
||||
<a href="{{.BasePath}}/" class="settings-nav-link settings-nav-back">← Back to Chat</a>
|
||||
<div class="surface-settings" style="flex-direction:column;">
|
||||
|
||||
{{/* Top Bar */}}
|
||||
<div class="settings-topbar">
|
||||
<a href="{{.BasePath}}/" class="settings-topbar-back">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to Chat
|
||||
</a>
|
||||
<div class="settings-topbar-sep"></div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--text-2)"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
|
||||
<span class="settings-topbar-title">Settings</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex:1;min-height:0;">
|
||||
{{/* Left Nav */}}
|
||||
<div class="settings-nav">
|
||||
{{$section := .Section}}
|
||||
{{$base := .BasePath}}
|
||||
|
||||
<a href="{{$base}}/settings/general" class="settings-nav-link{{if eq $section "general"}} active{{end}}">General</a>
|
||||
<a href="{{$base}}/settings/appearance" class="settings-nav-link{{if eq $section "appearance"}} active{{end}}">Appearance</a>
|
||||
<a href="{{$base}}/settings/models" class="settings-nav-link{{if eq $section "models"}} active{{end}}">Models</a>
|
||||
<a href="{{$base}}/settings/personas" class="settings-nav-link{{if eq $section "personas"}} active{{end}}">Personas</a>
|
||||
<a href="{{$base}}/settings/profile" class="settings-nav-link{{if eq $section "profile"}} active{{end}}">Profile</a>
|
||||
<a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
|
||||
|
||||
{{/* BYOK-gated nav items */}}
|
||||
<div id="settingsByokNav" style="display:none;">
|
||||
<div class="settings-nav-sep"></div>
|
||||
<a href="{{$base}}/settings/providers" class="settings-nav-link{{if eq $section "providers"}} active{{end}}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
My Providers
|
||||
</a>
|
||||
<a href="{{$base}}/settings/roles" class="settings-nav-link{{if eq $section "roles"}} active{{end}}">Model Roles</a>
|
||||
<a href="{{$base}}/settings/usage" class="settings-nav-link{{if eq $section "usage"}} active{{end}}">My Usage</a>
|
||||
</div>
|
||||
|
||||
{{/* BYOK indicator */}}
|
||||
<div id="settingsByokIndicator" class="settings-nav-footer" style="display:none;">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
|
||||
<span style="font-size:11px;font-weight:600;color:var(--success);">BYOK Enabled</span>
|
||||
</div>
|
||||
<p style="font-size:10px;color:var(--text-3);margin:0;line-height:1.5;">Admin has enabled Bring Your Own Key</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* Content */}}
|
||||
<div class="settings-content" id="settingsContentMount">
|
||||
{{/* Content rendered by section-specific JS or server template */}}
|
||||
<div class="settings-content-inner" id="settingsSection" data-section="{{.Section}}">
|
||||
{{if eq .Section "general"}}{{template "settings-general" .}}
|
||||
{{else if eq .Section "appearance"}}{{template "settings-appearance" .}}
|
||||
{{else if eq .Section "providers"}}{{template "settings-providers" .}}
|
||||
{{else if eq .Section "personas"}}{{template "settings-personas" .}}
|
||||
<div id="settingsSection" data-section="{{.Section}}">
|
||||
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
|
||||
|
||||
{{if eq .Section "general"}}
|
||||
<div class="settings-section">
|
||||
<h3>Chat Defaults</h3>
|
||||
<div class="form-group">
|
||||
<label>Default Model</label>
|
||||
<select id="settingsModel"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>System Prompt</label>
|
||||
<textarea id="settingsSystemPrompt" rows="3" placeholder="Optional system prompt…" style="max-width:100%;"></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Max Tokens <span id="settingsMaxHint" style="font-weight:400;color:var(--text-3);"></span></label>
|
||||
<input type="number" id="settingsMaxTokens" placeholder="default">
|
||||
</div>
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Temperature</label>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="range" id="settingsTemp" min="0" max="2" step="0.1" value="0.7" style="flex:1;">
|
||||
<span id="settingsTempValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);">0.7</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-2);cursor:pointer;margin-top:4px;">
|
||||
<input type="checkbox" id="settingsThinking"> Show thinking blocks
|
||||
</label>
|
||||
</div>
|
||||
{{else if eq .Section "appearance"}}
|
||||
<div class="settings-section">
|
||||
<h3>Theme</h3>
|
||||
<div id="themeToggle" class="toggle-group">
|
||||
<button class="toggle-btn" data-theme="light">☀ Light</button>
|
||||
<button class="toggle-btn" data-theme="dark">☾ Dark</button>
|
||||
<button class="toggle-btn" data-theme="system">⊞ System</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h3>UI Scale</h3>
|
||||
<div class="form-group">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="range" id="settingsScale" min="80" max="120" step="5" value="100" style="flex:1;">
|
||||
<span id="scaleValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 style="margin-top:16px;">Message Font Size</h3>
|
||||
<div class="form-group">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14" style="flex:1;">
|
||||
<span id="msgFontValue" style="font-size:12px;color:var(--text-2);font-family:var(--mono);min-width:32px;">14px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-md btn-primary" onclick="if(typeof UI!=='undefined')UI.saveAppearance?.()">Save</button>
|
||||
{{else if eq .Section "profile"}}
|
||||
<div class="settings-section">
|
||||
<h3>Profile</h3>
|
||||
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName" placeholder="Your display name"></div>
|
||||
<div class="form-group"><label>Email</label><input type="email" id="profileEmail" disabled></div>
|
||||
<button class="btn-md btn-primary" onclick="if(typeof Pages!=='undefined')Pages.saveProfile?.()">Save Profile</button>
|
||||
</div>
|
||||
<div class="settings-section" style="margin-top:20px;">
|
||||
<h3>Change Password</h3>
|
||||
<div class="form-group"><label>Current Password</label><input type="password" id="settingsCurrentPw" autocomplete="current-password"></div>
|
||||
<div class="form-group"><label>New Password</label><input type="password" id="settingsNewPw" autocomplete="new-password"></div>
|
||||
<div class="form-group"><label>Confirm New Password</label><input type="password" id="settingsConfirmPw" autocomplete="new-password"></div>
|
||||
<button class="btn-md btn-primary" onclick="if(typeof Pages!=='undefined')Pages.changePassword?.()">Update Password</button>
|
||||
</div>
|
||||
{{else if eq .Section "providers"}}
|
||||
<div id="userProvidersDisabled" style="display:none;">
|
||||
<div class="settings-section"><p style="color:var(--text-2);font-size:13px;">Your admin has disabled user-managed API keys (BYOK).</p></div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" id="providerShowAddBtn" style="display:none;">+ Add Provider</button>
|
||||
</div>
|
||||
<div id="providerAddForm" style="display:none;"></div>
|
||||
<div id="providerList"><div class="settings-placeholder">Loading providers…</div></div>
|
||||
{{else if eq .Section "personas"}}
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-md btn-primary" id="userAddPersonaBtn" style="display:none;">+ New Persona</button>
|
||||
</div>
|
||||
<div id="userAddPersonaForm" style="display:none;"></div>
|
||||
<div id="userPersonaList"><div class="settings-placeholder">Loading personas…</div></div>
|
||||
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
{{end}}
|
||||
|
||||
{{define "settings-general"}}
|
||||
<div class="settings-section">
|
||||
<h2>Profile</h2>
|
||||
<div class="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" id="settingsDisplayName" value="{{if .User}}{{.User.display_name}}{{end}}" placeholder="Your name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="settingsEmail" value="{{if .User}}{{.User.email}}{{end}}" disabled>
|
||||
</div>
|
||||
<button class="btn-small btn-primary" onclick="Pages.saveProfile()">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="margin-top:16px">
|
||||
<h2>Password</h2>
|
||||
<div class="form-group">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="settingsCurrentPw">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="settingsNewPw">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm Password</label>
|
||||
<input type="password" id="settingsConfirmPw">
|
||||
</div>
|
||||
<button class="btn-small btn-primary" onclick="Pages.changePassword()">Change Password</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "settings-appearance"}}
|
||||
<div class="settings-section">
|
||||
<h2>Theme</h2>
|
||||
<div id="themeToggle" class="settings-toggle-group">
|
||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||
<button class="theme-btn" data-theme="light">Light</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section" style="margin-top:16px">
|
||||
<h2>Editor Keymap</h2>
|
||||
<div id="keymapToggle" class="settings-toggle-group">
|
||||
<button class="theme-btn" data-keymap="standard">Standard</button>
|
||||
<button class="theme-btn" data-keymap="vim">Vim</button>
|
||||
<button class="theme-btn" data-keymap="emacs">Emacs</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section" style="margin-top:16px">
|
||||
<h2>UI Scale</h2>
|
||||
<div class="form-group">
|
||||
<input type="range" id="settingsScale" min="80" max="120" step="5" value="100">
|
||||
<span id="scaleValue">100%</span>
|
||||
</div>
|
||||
<h2>Message Font Size</h2>
|
||||
<div class="form-group">
|
||||
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14">
|
||||
<span id="msgFontValue">14px</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "settings-providers"}}
|
||||
<div id="userProvidersDisabled" class="settings-section" style="display:none;">
|
||||
<p style="color:var(--text-secondary);font-size:13px;">Your admin has disabled user-managed API keys (BYOK). Models are available through team presets.</p>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-small btn-primary" id="providerShowAddBtn">+ Add Provider</button>
|
||||
</div>
|
||||
<div id="providerAddForm" style="display:none;"></div>
|
||||
<div id="providerList"><div class="settings-placeholder">Loading providers…</div></div>
|
||||
{{end}}
|
||||
|
||||
{{define "settings-personas"}}
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn-small btn-primary" id="userAddPresetBtn">+ New Preset</button>
|
||||
</div>
|
||||
<div id="userAddPresetForm" style="display:none;"></div>
|
||||
<div id="userPresetList"><div class="settings-placeholder">Loading presets…</div></div>
|
||||
{{end}}
|
||||
|
||||
{{define "css-settings"}}
|
||||
<style>
|
||||
.surface-settings {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.settings-nav {
|
||||
width: 200px;
|
||||
border-right: 1px solid var(--border, #2a2a2a);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-nav-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.settings-nav-link {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.settings-nav-link.active { background: var(--bg-tertiary, #252528); }
|
||||
.settings-nav-link:hover { background: var(--bg-secondary, #1a1a1e); }
|
||||
.settings-nav-sep {
|
||||
margin: 12px 0;
|
||||
border-top: 1px solid var(--border, #2a2a2a);
|
||||
}
|
||||
.settings-nav-back { color: var(--text-secondary, #888); }
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.settings-section {
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #1a1a1e);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border, #2a2a2a);
|
||||
max-width: 600px;
|
||||
}
|
||||
.settings-section h2 {
|
||||
font-size: 15px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.settings-toggle-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.settings-toggle-group .theme-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border, #2a2a2a);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary, #0e0e10);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-toggle-group .theme-btn.active {
|
||||
background: var(--accent, #5865f2);
|
||||
border-color: var(--accent, #5865f2);
|
||||
color: white;
|
||||
}
|
||||
.settings-placeholder {
|
||||
padding: 20px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
.form-group { margin-bottom: 8px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #2a2a2a);
|
||||
background: var(--bg-primary, #0e0e10);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent, #5865f2);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-nav { width: 160px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{/* CSS: consolidated into styles.css */}}
|
||||
{{define "css-settings"}}{{end}}
|
||||
|
||||
{{/* Scripts */}}
|
||||
{{define "scripts-settings"}}
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/marked.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/vendor/purify.min.js"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-core.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-format.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/ui-settings.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/settings-handlers.js?v={{.Version}}"></script>
|
||||
@@ -239,32 +165,50 @@
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/memory-ui.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notifications.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/notification-prefs.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/channel-models.js?v={{.Version}}"></script>
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
// Settings surface boot
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const section = document.getElementById('settingsSection')?.dataset.section;
|
||||
if (!section) return;
|
||||
|
||||
// Appearance: wire up theme/keymap buttons and sliders
|
||||
if (section === 'appearance' && typeof UI !== 'undefined') {
|
||||
UI.loadAppearanceSettings();
|
||||
UI.initAppearance();
|
||||
// Show BYOK nav if enabled
|
||||
const pageData = window.__PAGE_DATA__;
|
||||
if (pageData && pageData.BYOKEnabled) {
|
||||
const byokNav = document.getElementById('settingsByokNav');
|
||||
const byokInd = document.getElementById('settingsByokIndicator');
|
||||
if (byokNav) byokNav.style.display = '';
|
||||
if (byokInd) byokInd.style.display = '';
|
||||
}
|
||||
|
||||
// Dynamic sections: let existing JS populate the container
|
||||
const dynamicSections = {
|
||||
providers: () => typeof UI !== 'undefined' && UI.loadProviderList(),
|
||||
models: () => typeof UI !== 'undefined' && UI.loadUserModels(),
|
||||
personas: () => typeof UI !== 'undefined' && UI.loadUserPresets(),
|
||||
usage: () => typeof UI !== 'undefined' && UI.loadMyUsage(),
|
||||
roles: () => typeof UI !== 'undefined' && UI.loadUserRoles(),
|
||||
knowledge: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel(),
|
||||
memory: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel(),
|
||||
notifications: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load(),
|
||||
};
|
||||
if (dynamicSections[section]) {
|
||||
dynamicSections[section]();
|
||||
if (section === 'appearance' && typeof UI !== 'undefined') {
|
||||
UI.loadAppearanceSettings?.();
|
||||
UI.initAppearance?.();
|
||||
}
|
||||
if (section === 'general' && typeof UI !== 'undefined') {
|
||||
UI.loadGeneralSettings?.();
|
||||
}
|
||||
if (section === 'profile') {
|
||||
const user = window.__USER__;
|
||||
if (user) {
|
||||
const dn = document.getElementById('profileDisplayName');
|
||||
const em = document.getElementById('profileEmail');
|
||||
if (dn) dn.value = user.display_name || user.username || '';
|
||||
if (em) em.value = user.email || '';
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicSections = {
|
||||
providers: () => typeof UI !== 'undefined' && UI.loadProviderList?.(),
|
||||
models: () => typeof UI !== 'undefined' && UI.loadUserModels?.(),
|
||||
personas: () => typeof UI !== 'undefined' && UI.loadUserPersonas?.(),
|
||||
usage: () => typeof UI !== 'undefined' && UI.loadMyUsage?.(),
|
||||
roles: () => typeof UI !== 'undefined' && UI.loadUserRoles?.(),
|
||||
knowledge: () => typeof KnowledgeUI !== 'undefined' && KnowledgeUI.openManagePanel?.(),
|
||||
memory: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.(),
|
||||
notifications: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.(),
|
||||
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
|
||||
};
|
||||
if (dynamicSections[section]) dynamicSections[section]();
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -265,13 +265,13 @@ func (h *OpenRouterHooks) PostStreamEvent(_ ProviderConfig, _ *StreamEvent) {
|
||||
// No post-processing needed. OpenRouter returns standard OAI format.
|
||||
}
|
||||
|
||||
// ── Preset Override Merge ───────────────────
|
||||
// ── Persona Override Merge ───────────────────
|
||||
|
||||
// MergePresetSettings merges persona-level setting overrides onto
|
||||
// provider-level settings. Preset values take priority except for
|
||||
// MergePersonaSettings merges persona-level setting overrides onto
|
||||
// provider-level settings. Persona values take priority except for
|
||||
// fields marked ProviderOnly in the schema.
|
||||
func MergePresetSettings(providerType string, providerSettings, presetOverrides map[string]interface{}) map[string]interface{} {
|
||||
if len(presetOverrides) == 0 {
|
||||
func MergePersonaSettings(providerType string, providerSettings, personaOverrides map[string]interface{}) map[string]interface{} {
|
||||
if len(personaOverrides) == 0 {
|
||||
return providerSettings
|
||||
}
|
||||
|
||||
@@ -283,11 +283,11 @@ func MergePresetSettings(providerType string, providerSettings, presetOverrides
|
||||
}
|
||||
}
|
||||
|
||||
merged := make(map[string]interface{}, len(providerSettings)+len(presetOverrides))
|
||||
merged := make(map[string]interface{}, len(providerSettings)+len(personaOverrides))
|
||||
for k, v := range providerSettings {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range presetOverrides {
|
||||
for k, v := range personaOverrides {
|
||||
if !providerOnly[k] {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
@@ -270,45 +270,45 @@ func TestGetHooks_Unknown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── MergePresetSettings Tests ───────────────
|
||||
// ── MergePersonaSettings Tests ──────────────
|
||||
|
||||
func TestMergePresetSettings_Basic(t *testing.T) {
|
||||
func TestMergePersonaSettings_Basic(t *testing.T) {
|
||||
provider := map[string]interface{}{
|
||||
"system_prompt_prefix": "Provider prompt",
|
||||
"frequency_penalty": 0.3,
|
||||
}
|
||||
preset := map[string]interface{}{
|
||||
"system_prompt_prefix": "Preset prompt",
|
||||
persona := map[string]interface{}{
|
||||
"system_prompt_prefix": "Persona prompt",
|
||||
}
|
||||
|
||||
merged := MergePresetSettings("openai", provider, preset)
|
||||
merged := MergePersonaSettings("openai", provider, persona)
|
||||
|
||||
if merged["system_prompt_prefix"] != "Preset prompt" {
|
||||
t.Errorf("preset should override provider, got %v", merged["system_prompt_prefix"])
|
||||
if merged["system_prompt_prefix"] != "Persona prompt" {
|
||||
t.Errorf("persona should override provider, got %v", merged["system_prompt_prefix"])
|
||||
}
|
||||
if merged["frequency_penalty"] != 0.3 {
|
||||
t.Errorf("provider value should be preserved, got %v", merged["frequency_penalty"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePresetSettings_ProviderOnlyBlocked(t *testing.T) {
|
||||
func TestMergePersonaSettings_ProviderOnlyBlocked(t *testing.T) {
|
||||
provider := map[string]interface{}{
|
||||
"route": "auto",
|
||||
}
|
||||
preset := map[string]interface{}{
|
||||
persona := map[string]interface{}{
|
||||
"route": "fallback", // route is ProviderOnly for openrouter
|
||||
}
|
||||
|
||||
merged := MergePresetSettings("openrouter", provider, preset)
|
||||
merged := MergePersonaSettings("openrouter", provider, persona)
|
||||
|
||||
if merged["route"] != "auto" {
|
||||
t.Errorf("ProviderOnly field should not be overridden by preset, got %v", merged["route"])
|
||||
t.Errorf("ProviderOnly field should not be overridden by persona, got %v", merged["route"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePresetSettings_EmptyOverrides(t *testing.T) {
|
||||
func TestMergePersonaSettings_EmptyOverrides(t *testing.T) {
|
||||
provider := map[string]interface{}{"key": "value"}
|
||||
merged := MergePresetSettings("openai", provider, nil)
|
||||
merged := MergePersonaSettings("openai", provider, nil)
|
||||
if merged["key"] != "value" {
|
||||
t.Error("empty overrides should return provider settings unchanged")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type ProfileField struct {
|
||||
Min *int `json:"min,omitempty"` // min for int/float
|
||||
Max *int `json:"max,omitempty"` // max for int/float
|
||||
DependsOn string `json:"depends_on,omitempty"` // only show when this key is truthy
|
||||
ProviderOnly bool `json:"provider_only,omitempty"` // not overridable at preset level
|
||||
ProviderOnly bool `json:"provider_only,omitempty"` // not overridable at persona level
|
||||
}
|
||||
|
||||
// ── Built-in Profile Schemas ────────────────
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, model
|
||||
}
|
||||
|
||||
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
|
||||
// across any provider. Used to resolve capabilities for presets with auto-resolve
|
||||
// across any provider. Used to resolve capabilities for personas with auto-resolve
|
||||
// (no specific provider_config_id).
|
||||
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
|
||||
@@ -20,7 +20,7 @@ func SetDB(db *sql.DB) {
|
||||
|
||||
// ── Dynamic SQL Builder ─────────────────────
|
||||
// Replaces the copy-pasted addClause/addField pattern
|
||||
// found in admin.go, presets.go, team_providers.go, apiconfigs.go.
|
||||
// found in admin.go, personas.go, team_providers.go, apiconfigs.go.
|
||||
|
||||
// UpdateBuilder constructs a dynamic UPDATE statement.
|
||||
type UpdateBuilder struct {
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, model
|
||||
}
|
||||
|
||||
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
|
||||
// across any provider. Used to resolve capabilities for presets with auto-resolve
|
||||
// across any provider. Used to resolve capabilities for personas with auto-resolve
|
||||
// (no specific provider_config_id).
|
||||
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
|
||||
row := DB.QueryRowContext(ctx,
|
||||
|
||||
@@ -8,26 +8,24 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.notif-bell {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary, #888);
|
||||
padding: 5px;
|
||||
border-radius: 7px;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.notif-bell:hover {
|
||||
background: var(--hover-bg, rgba(128, 128, 128, 0.1));
|
||||
color: var(--text-primary, #ddd);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notif-badge {
|
||||
@@ -41,7 +39,7 @@
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-red, #e55);
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 0 4px;
|
||||
pointer-events: none;
|
||||
@@ -56,8 +54,8 @@
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-height: 480px;
|
||||
background: var(--bg-surface, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
@@ -69,7 +67,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -77,7 +75,7 @@
|
||||
.notif-mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent, #5b9);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
@@ -85,7 +83,7 @@
|
||||
}
|
||||
|
||||
.notif-mark-all:hover {
|
||||
background: var(--hover-bg, rgba(128, 128, 128, 0.1));
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-dd-list {
|
||||
@@ -95,14 +93,14 @@
|
||||
|
||||
.notif-dd-footer {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-view-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent, #5b9);
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -120,17 +118,17 @@
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid var(--border-color, #222);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notif-item:hover,
|
||||
.notif-panel-item:hover {
|
||||
background: var(--hover-bg, rgba(128, 128, 128, 0.08));
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-item.notif-unread,
|
||||
.notif-panel-item.notif-unread {
|
||||
background: var(--notif-unread-bg, rgba(91, 187, 153, 0.06));
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.notif-dot {
|
||||
@@ -138,7 +136,7 @@
|
||||
width: 18px;
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
color: var(--accent, #5b9);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notif-item:not(.notif-unread) .notif-dot,
|
||||
@@ -154,12 +152,12 @@
|
||||
.notif-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary, #ddd);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notif-detail {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-2);
|
||||
margin-top: 2px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
@@ -176,7 +174,7 @@
|
||||
.notif-empty {
|
||||
padding: 32px 14px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -192,7 +190,7 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notif-panel-list {
|
||||
@@ -225,14 +223,14 @@
|
||||
}
|
||||
|
||||
.notif-delete:hover {
|
||||
color: var(--accent-red, #e55);
|
||||
background: var(--hover-bg, rgba(128, 128, 128, 0.1));
|
||||
color: var(--danger);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.notif-panel-more {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Mobile ────────────────────────────────── */
|
||||
|
||||
@@ -41,8 +41,11 @@
|
||||
--warning-dim: rgba(234,179,8,0.2);
|
||||
|
||||
/* ── Surfaces ────────────────────────────── */
|
||||
--overlay: rgba(0,0,0,0.6);
|
||||
--overlay: rgba(0,0,0,0.55);
|
||||
--glass: rgba(255,255,255,0.03);
|
||||
--input-bg: #1a1a1f;
|
||||
--user-bubble: rgba(108,159,255,0.08);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||
|
||||
/* ── Layout ──────────────────────────────── */
|
||||
--radius: 8px;
|
||||
@@ -62,18 +65,18 @@
|
||||
|
||||
/* ── Light Theme ─────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--bg-surface: #f8f9fa;
|
||||
--bg-raised: #f0f1f3;
|
||||
--bg-hover: #e8e9ec;
|
||||
--border: #d5d7db;
|
||||
--border-light: #c0c3c9;
|
||||
--bg: #f7f7fa;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-raised: #eff0f3;
|
||||
--bg-hover: #e5e6eb;
|
||||
--border: #d5d6dc;
|
||||
--border-light: #c2c3cb;
|
||||
--text: #1a1a2e;
|
||||
--text-2: #555770;
|
||||
--text-3: #8b8da3;
|
||||
--accent: #4a7cdb;
|
||||
--accent-hover: #3968c4;
|
||||
--accent-dim: rgba(74,124,219,0.10);
|
||||
--accent-dim: rgba(74,124,219,0.09);
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warning: #ca8a04;
|
||||
@@ -88,12 +91,15 @@
|
||||
--success-light: #16a34a;
|
||||
--warning-light: #ca8a04;
|
||||
|
||||
--danger-dim: rgba(220,38,38,0.10);
|
||||
--success-dim: rgba(22,163,74,0.10);
|
||||
--warning-dim: rgba(202,138,4,0.10);
|
||||
--danger-dim: rgba(220,38,38,0.09);
|
||||
--success-dim: rgba(22,163,74,0.09);
|
||||
--warning-dim: rgba(202,138,4,0.09);
|
||||
|
||||
--overlay: rgba(0,0,0,0.35);
|
||||
--overlay: rgba(0,0,0,0.25);
|
||||
--glass: rgba(0,0,0,0.02);
|
||||
--input-bg: #f2f3f6;
|
||||
--user-bubble: rgba(74,124,219,0.06);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.1);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -114,7 +120,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── App Shell ───────────────────────────── */
|
||||
|
||||
.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; }
|
||||
.app { display: flex; height: 100%; flex-direction: column; }
|
||||
.app-body { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Workspace (everything right of sidebar) ── */
|
||||
@@ -180,7 +186,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.sidebar-top {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 10px 10px 8px;
|
||||
padding: 10px 10px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -194,8 +200,8 @@ a:hover { text-decoration: underline; }
|
||||
position: relative;
|
||||
}
|
||||
.sb-brand:hover { background: var(--bg-hover); }
|
||||
.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; }
|
||||
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; }
|
||||
.sb-logo { flex-shrink: 0; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; }
|
||||
.sb-logo img, .sb-logo .brand-logo-img { width: 22px; height: 22px; object-fit: contain; border-radius: 3px; }
|
||||
.brand-collapse { display: none; }
|
||||
.brand-text { font-weight: 600; font-size: 14px; }
|
||||
|
||||
@@ -209,6 +215,8 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.sb-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sb-btn svg { flex-shrink: 0; }
|
||||
/* Sidebar toggle: hidden on desktop (brand click handles collapse) */
|
||||
.sidebar-toggle { display: none; }
|
||||
.sb-label { overflow: hidden; transition: opacity var(--transition); }
|
||||
.sidebar.collapsed .sb-label { opacity: 0; width: 0; }
|
||||
.sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
||||
@@ -231,7 +239,7 @@ a:hover { text-decoration: underline; }
|
||||
.sidebar.collapsed .avatar-bug { display: none; }
|
||||
|
||||
/* Split button (New Chat + dropdown) */
|
||||
.split-btn { display: flex; width: 100%; gap: 0; }
|
||||
.split-btn { display: flex; width: 100%; gap: 0; position: relative; }
|
||||
.split-btn-main {
|
||||
flex: 1; display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: var(--radius) 0 0 var(--radius);
|
||||
@@ -356,6 +364,17 @@ a:hover { text-decoration: underline; }
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 10px; position: relative;
|
||||
}
|
||||
.sidebar-bottom-divider {
|
||||
height: 1px; background: var(--border);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
.sidebar-notes-btn {
|
||||
color: var(--text-2); font-size: 13px;
|
||||
}
|
||||
.sidebar-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sidebar.collapsed .sidebar-notes-btn .sb-label { opacity: 0; width: 0; }
|
||||
.sidebar.collapsed .sidebar-notes-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||
.sidebar.collapsed .sidebar-bottom-divider { margin: 4px 12px; }
|
||||
|
||||
.user-btn {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
@@ -368,7 +387,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.user-avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: var(--bg-raised); display: flex; align-items: center;
|
||||
background: var(--accent-dim); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 13px; font-weight: 600;
|
||||
color: var(--accent); flex-shrink: 0; position: relative;
|
||||
overflow: hidden;
|
||||
@@ -416,6 +435,47 @@ a:hover { text-decoration: underline; }
|
||||
/* .chat-area is now .workspace-primary — alias kept for compat */
|
||||
.chat-area, .workspace-primary { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
|
||||
|
||||
/* Role fallback banner */
|
||||
.role-fallback-banner {
|
||||
background: var(--warning-dim); color: var(--warning);
|
||||
text-align: center; font-size: 12px; font-weight: 500;
|
||||
padding: 6px 12px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat header (model selector + caps + token count + panel toggle) */
|
||||
.chat-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 16px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chat-header-right {
|
||||
margin-left: auto; display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.token-count {
|
||||
font-size: 11px; color: var(--text-3);
|
||||
font-family: var(--mono); white-space: nowrap;
|
||||
}
|
||||
.summarized-badge {
|
||||
font-size: 11px; color: var(--text-3); white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
|
||||
/* Message container (scrollable) */
|
||||
.msg-container {
|
||||
flex: 1; overflow-y: auto; scroll-behavior: smooth;
|
||||
padding-top: 12px; padding-bottom: 8px;
|
||||
}
|
||||
.msg-container::-webkit-scrollbar { width: 5px; }
|
||||
.msg-container::-webkit-scrollbar-track { background: transparent; }
|
||||
.msg-container::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border); }
|
||||
|
||||
/* Streaming tools display */
|
||||
.stream-tools {
|
||||
padding: 8px 24px; flex-shrink: 0;
|
||||
font-size: 12px; color: var(--text-3);
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
|
||||
.model-bar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; flex-shrink: 0;
|
||||
@@ -497,13 +557,33 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.icon-btn:hover { color: var(--text); }
|
||||
|
||||
/* Messages */
|
||||
/* Messages — Bubble Layout (matching prototype) */
|
||||
.messages { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
|
||||
|
||||
.message { padding: 1.25rem 0; }
|
||||
.message:not(:last-child) { border-bottom: 1px solid var(--glass); }
|
||||
.message { padding: 10px 24px; }
|
||||
|
||||
.msg-inner { max-width: 768px; margin: 0 auto; padding: 0 1.5rem; display: flex; gap: 1rem; }
|
||||
.msg-inner { max-width: 768px; margin: 0 auto; display: flex; gap: 10px; }
|
||||
|
||||
/* User messages: right-aligned bubble */
|
||||
.message.user { display: flex; justify-content: flex-end; }
|
||||
.message.user .msg-inner { flex-direction: row-reverse; }
|
||||
.message.user .msg-body {
|
||||
background: var(--user-bubble);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.message.user .msg-head { flex-direction: row-reverse; }
|
||||
.message.user .msg-role { color: var(--accent); }
|
||||
.message.user .msg-avatar { color: var(--accent); }
|
||||
|
||||
/* Assistant messages: left-aligned bubble */
|
||||
.message.assistant .msg-body {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
@@ -967,9 +1047,9 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.message-summary-divider .summary-toggle:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||
.input-wrap {
|
||||
max-width: 768px; margin: 0 auto;
|
||||
max-width: 768px; margin: 0 auto; width: 100%; box-sizing: border-box;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 0;
|
||||
border-radius: 12px; padding: 0;
|
||||
display: flex; align-items: flex-end;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
@@ -977,7 +1057,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.input-wrap textarea {
|
||||
flex: 1; resize: none; background: none; border: none;
|
||||
color: var(--text); padding: 12px 0 12px 8px;
|
||||
color: var(--text); padding: 12px 0 12px 14px;
|
||||
font-family: var(--font); font-size: 14px;
|
||||
max-height: 200px; line-height: 1.5;
|
||||
}
|
||||
@@ -1007,10 +1087,27 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; }
|
||||
|
||||
/* Chat input area wrapper (bottom pinned, padded) */
|
||||
.chat-input-area {
|
||||
padding: 0 16px 16px; flex-shrink: 0;
|
||||
}
|
||||
.chat-footer {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 4px 4px 0; max-width: 768px; margin: 0 auto;
|
||||
min-height: 18px;
|
||||
}
|
||||
.chat-version { font-size: 10px; color: var(--text-3); }
|
||||
.input-token-count {
|
||||
font-size: 10px; color: var(--text-3);
|
||||
font-family: var(--mono); margin-right: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Left-side toolbar: attach, tools, KB — single container handles alignment */
|
||||
.input-toolbar {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
padding: 0 2px 6px 8px; flex-shrink: 0;
|
||||
padding: 0 4px 6px; flex-shrink: 0;
|
||||
max-width: 768px; margin: 0 auto; width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -1609,15 +1706,15 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.admin-provider-row .btn-delete:hover { color: var(--danger); }
|
||||
|
||||
.admin-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.admin-preset-row .preset-info { flex: 1; min-width: 0; }
|
||||
.preset-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
|
||||
.preset-row-icon { margin-right: 4px; }
|
||||
.admin-preset-row .preset-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.admin-preset-row .preset-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
.admin-preset-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.admin-preset-row .btn-delete:hover { color: var(--danger); }
|
||||
.admin-persona-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.admin-persona-row .persona-info { flex: 1; min-width: 0; }
|
||||
.persona-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
|
||||
.persona-row-icon { margin-right: 4px; }
|
||||
.admin-persona-row .persona-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.admin-persona-row .persona-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-persona-row .persona-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
.admin-persona-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.admin-persona-row .btn-delete:hover { color: var(--danger); }
|
||||
.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
/* Stats cards */
|
||||
@@ -1878,7 +1975,32 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
display: flex; gap: 8px; padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.notes-search-row {
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
.notes-search-input {
|
||||
width: 100%; background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
font-size: 13px; font-family: var(--font); padding: 6px 10px;
|
||||
outline: none; transition: border-color var(--transition);
|
||||
}
|
||||
.notes-search-input:focus { border-color: var(--accent); }
|
||||
.notes-filter-row {
|
||||
display: flex; gap: 8px; padding: 0 12px 8px;
|
||||
}
|
||||
.notes-editor-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
.notes-meta-row {
|
||||
display: flex; gap: 8px; margin-bottom: 8px;
|
||||
}
|
||||
.notes-meta-row input { flex: 1; }
|
||||
.notes-content-container { min-height: 200px; }
|
||||
.notes-read-view { padding: 12px; }
|
||||
.notes-search-wrap {
|
||||
display: flex; align-items: center; gap: 6px; flex: 1;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
@@ -2147,9 +2269,9 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
/* ── Sidebar Search ──────────────────────── */
|
||||
|
||||
.sidebar-search {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; margin: 4px 6px;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 6px 10px; margin: 8px 10px 4px;
|
||||
background: var(--input-bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); transition: border-color var(--transition);
|
||||
}
|
||||
.sidebar-search:focus-within { border-color: var(--accent); }
|
||||
@@ -2314,6 +2436,15 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
|
||||
/* ── Tools Toggle ────────────────────────── */
|
||||
|
||||
/* ── Popup (shared base for tools/KB popups) ── */
|
||||
.popup {
|
||||
display: none; position: absolute;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg); padding: 4px;
|
||||
box-shadow: var(--shadow-lg); z-index: 300;
|
||||
}
|
||||
.popup.open { display: block; }
|
||||
|
||||
.tools-toggle-wrap { position: relative; display: flex; align-items: center; }
|
||||
.tools-toggle-btn.has-disabled { color: var(--text-3); }
|
||||
.tools-toggle-btn.has-disabled::after {
|
||||
@@ -2656,3 +2787,299 @@ select option { background: var(--bg-surface); color: var(--text); }
|
||||
.router-picker-actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ── Settings Surface ────────────────────── */
|
||||
|
||||
.surface-settings {
|
||||
display: flex; height: 100%; overflow: hidden;
|
||||
}
|
||||
.settings-topbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface); flex-shrink: 0;
|
||||
}
|
||||
.settings-topbar-back {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||
padding: 4px 8px; border-radius: 6px; transition: color 0.12s;
|
||||
}
|
||||
.settings-topbar-back:hover { color: var(--text); }
|
||||
.settings-topbar-sep { width: 1px; height: 20px; background: var(--border); }
|
||||
.settings-topbar-title { font-size: 15px; font-weight: 600; }
|
||||
.settings-nav {
|
||||
width: 220px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
padding: 12px 8px; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.settings-nav-link {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
color: var(--text-2); text-decoration: none;
|
||||
font-size: 13px; font-weight: 500; transition: all 0.12s;
|
||||
border: none; background: none; cursor: pointer;
|
||||
font-family: inherit; width: 100%; text-align: left;
|
||||
}
|
||||
.settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||
.settings-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
||||
.settings-nav-sep { margin: 8px 0; border-top: 1px solid var(--border); }
|
||||
.settings-nav-footer {
|
||||
margin-top: auto; padding: 10px 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
|
||||
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
|
||||
.settings-section {
|
||||
margin-bottom: 24px; padding: 20px;
|
||||
background: var(--bg-surface); border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.settings-section h3 {
|
||||
font-size: 15px; font-weight: 600; margin: 0 0 16px;
|
||||
padding-bottom: 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-placeholder {
|
||||
padding: 40px 20px; color: var(--text-3);
|
||||
text-align: center; font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Admin Surface ───────────────────────── */
|
||||
|
||||
.surface-admin {
|
||||
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
||||
}
|
||||
.admin-topbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface); flex-shrink: 0;
|
||||
}
|
||||
.admin-topbar-back {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||
padding: 4px 8px; border-radius: 6px; transition: color 0.12s;
|
||||
}
|
||||
.admin-topbar-back:hover { color: var(--text); }
|
||||
.admin-topbar-sep { width: 1px; height: 20px; background: var(--border); }
|
||||
.admin-topbar-title { font-size: 15px; font-weight: 600; }
|
||||
.admin-category-tabs {
|
||||
display: flex; gap: 1px; padding: 3px;
|
||||
background: var(--bg); border-radius: 10px;
|
||||
border: 1px solid var(--border); margin-left: auto;
|
||||
}
|
||||
.admin-cat-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border-radius: 7px; border: none;
|
||||
cursor: pointer; font-size: 13px; font-weight: 500;
|
||||
font-family: inherit; background: transparent; color: var(--text-3);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.admin-cat-btn.active { background: var(--bg-raised); color: var(--text); }
|
||||
.admin-cat-btn:hover { color: var(--text-2); }
|
||||
.admin-body { display: flex; flex: 1; min-height: 0; }
|
||||
.admin-nav {
|
||||
width: 200px; background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; flex-shrink: 0;
|
||||
}
|
||||
.admin-nav-link {
|
||||
display: flex; padding: 8px 12px; border-radius: 8px;
|
||||
border: none; cursor: pointer; font-family: inherit;
|
||||
font-size: 13px; font-weight: 500; width: 100%;
|
||||
text-align: left; transition: all 0.12s;
|
||||
background: transparent; color: var(--text-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.admin-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||
.admin-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
||||
.admin-content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.admin-content-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.admin-content-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||
.admin-search {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
background: var(--input-bg, var(--bg)); border-radius: 8px;
|
||||
padding: 6px 10px; border: 1px solid var(--border); width: 220px;
|
||||
}
|
||||
.admin-search input {
|
||||
background: none; border: none; color: var(--text);
|
||||
font-size: 12px; font-family: inherit; flex: 1; outline: none;
|
||||
}
|
||||
.admin-content-body { flex: 1; overflow-y: auto; padding: 24px; }
|
||||
|
||||
/* ── Stat Cards ──────────────────────────── */
|
||||
.stat-cards { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
padding: 16px 20px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border); border-radius: 10px; flex: 1;
|
||||
}
|
||||
.stat-card-value { font-size: 24px; font-weight: 700; }
|
||||
.stat-card-label { font-size: 12px; color: var(--text-2); margin-top: 2px; }
|
||||
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
|
||||
|
||||
/* ── Data Tables ─────────────────────────── */
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th {
|
||||
padding: 10px 14px; text-align: left; font-weight: 600;
|
||||
font-size: 11px; color: var(--text-3); text-transform: uppercase;
|
||||
letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.data-table td {
|
||||
padding: 10px 14px; font-size: 13px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.data-table tr:hover td { background: var(--bg-hover); }
|
||||
|
||||
/* ── Editor Surface ──────────────────────── */
|
||||
|
||||
.surface-editor {
|
||||
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
||||
}
|
||||
.editor-topbar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface); flex-shrink: 0;
|
||||
}
|
||||
.editor-topbar-back {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||
padding: 4px 8px; border-radius: 6px;
|
||||
}
|
||||
.editor-topbar-back:hover { color: var(--text); }
|
||||
.editor-body { display: flex; flex: 1; min-height: 0; }
|
||||
.editor-tree {
|
||||
width: 220px; background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; flex-shrink: 0;
|
||||
}
|
||||
.editor-tree-header {
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.editor-tree-files { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
.editor-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.editor-tabs {
|
||||
display: flex; align-items: center;
|
||||
background: var(--bg-raised); border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0; overflow-x: auto; min-height: 33px;
|
||||
}
|
||||
.editor-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; cursor: pointer; font-size: 12px;
|
||||
white-space: nowrap; border-right: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
border-top: none; border-left: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.editor-tab.active {
|
||||
background: var(--bg); color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.editor-content { flex: 1; overflow: auto; }
|
||||
.editor-statusbar {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 3px 12px; background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border); flex-shrink: 0;
|
||||
font-size: 11px; color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.editor-chat {
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 200px; background: var(--bg-surface);
|
||||
}
|
||||
.editor-chat-header {
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.editor-chat-messages { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.editor-chat-input {
|
||||
padding: 8px 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Shared Form Controls ────────────────── */
|
||||
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label {
|
||||
display: block; font-size: 12px; font-weight: 500;
|
||||
color: var(--text-2); margin-bottom: 5px;
|
||||
}
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--input-bg, var(--bg)); color: var(--text);
|
||||
font-size: 13px; font-family: inherit;
|
||||
width: 100%; max-width: 360px; outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.form-group input:disabled { opacity: 0.6; }
|
||||
.form-group input[type="range"] { max-width: 100%; border: none; background: none; }
|
||||
.form-group input[type="range"]:focus { box-shadow: none; }
|
||||
.form-group input[type="checkbox"] { width: auto; max-width: none; accent-color: var(--accent); }
|
||||
|
||||
.toggle-group {
|
||||
display: flex; gap: 1px; padding: 3px;
|
||||
background: var(--bg); border-radius: 10px;
|
||||
border: 1px solid var(--border); width: fit-content;
|
||||
}
|
||||
.toggle-btn {
|
||||
padding: 6px 14px; border: none; border-radius: 7px;
|
||||
background: transparent; color: var(--text-3);
|
||||
cursor: pointer; font-size: 12px; font-weight: 500;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active { background: var(--bg-raised); color: var(--text); }
|
||||
.toggle-btn:hover { color: var(--text-2); }
|
||||
|
||||
.btn-primary { background: var(--accent); color: #fff; border: none; }
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-danger { background: var(--danger); color: #fff; border: none; }
|
||||
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border); }
|
||||
.btn-ghost:hover { background: var(--bg-hover); }
|
||||
.btn-subtle { background: var(--accent-dim); color: var(--accent); border: none; }
|
||||
.btn-sm { padding: 5px 11px; font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: 500; font-family: inherit; }
|
||||
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: 8px; cursor: pointer; font-weight: 500; font-family: inherit; }
|
||||
|
||||
/* ── Badge ───────────────────────────────── */
|
||||
.badge {
|
||||
font-size: 10px; padding: 2px 7px; border-radius: 4px;
|
||||
font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;
|
||||
display: inline-flex; align-items: center;
|
||||
}
|
||||
.badge-accent { background: var(--accent-dim); color: var(--accent); }
|
||||
.badge-success { background: var(--success-dim); color: var(--success); }
|
||||
.badge-warning { background: var(--warning-dim); color: var(--warning); }
|
||||
.badge-danger { background: var(--danger-dim); color: var(--danger); }
|
||||
.badge-muted { background: var(--bg-raised); color: var(--text-3); }
|
||||
|
||||
/* ── Status Dot ──────────────────────────── */
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.healthy { background: var(--success); }
|
||||
.status-dot.degraded { background: var(--warning); }
|
||||
.status-dot.down { background: var(--danger); }
|
||||
.status-dot.disabled { background: var(--text-3); }
|
||||
|
||||
/* ── Icon Button ─────────────────────────── */
|
||||
.icon-btn {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; padding: 5px; border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text-2); }
|
||||
.icon-btn.active { color: var(--accent); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; }
|
||||
.settings-content { padding: 16px; }
|
||||
.admin-nav { width: 160px; }
|
||||
.admin-content-body { padding: 16px; }
|
||||
.editor-tree { width: 180px; }
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -116,71 +116,61 @@ describe('GET /models/enabled response contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── /models/enabled with preset response ─────
|
||||
// ── /models/enabled with persona response ────
|
||||
|
||||
describe('GET /models/enabled preset contract', () => {
|
||||
// ACTUAL backend UserModel for a persona includes both canonical and alias fields.
|
||||
const presetModel = {
|
||||
id: 'preset-uuid-123',
|
||||
describe('GET /models/enabled persona contract', () => {
|
||||
// ACTUAL backend UserModel for a persona (v0.22.8+: unified naming, no aliases).
|
||||
const personaModel = {
|
||||
id: 'persona-uuid-123',
|
||||
model_id: 'gpt-4o',
|
||||
display_name: 'Code Helper',
|
||||
source: 'persona',
|
||||
// Backend canonical fields
|
||||
// Backend canonical fields (v0.22.8: all use persona_ prefix)
|
||||
provider_config_id: 'cfg1',
|
||||
persona_id: 'preset-uuid-123',
|
||||
scope: 'global',
|
||||
avatar: null,
|
||||
// Frontend alias fields (populated by resolver)
|
||||
is_preset: true,
|
||||
preset_id: 'preset-uuid-123',
|
||||
preset_scope: 'global',
|
||||
preset_avatar: null,
|
||||
is_persona: true,
|
||||
persona_id: 'persona-uuid-123',
|
||||
persona_scope: 'global',
|
||||
persona_avatar: null,
|
||||
persona_team_name: null,
|
||||
config_id: 'cfg1',
|
||||
provider_name: 'OpenAI',
|
||||
capabilities: { streaming: true },
|
||||
};
|
||||
|
||||
it('preset has is_preset = true', () => {
|
||||
assert.equal(presetModel.is_preset, true);
|
||||
it('persona has is_persona = true', () => {
|
||||
assert.equal(personaModel.is_persona, true);
|
||||
});
|
||||
|
||||
it('preset has preset_id matching persona_id', () => {
|
||||
assert.ok(presetModel.preset_id);
|
||||
assert.equal(presetModel.preset_id, presetModel.persona_id,
|
||||
'preset_id alias must match persona_id');
|
||||
it('persona has persona_id', () => {
|
||||
assert.ok(personaModel.persona_id);
|
||||
});
|
||||
|
||||
it('preset has preset_scope matching scope', () => {
|
||||
assert.ok(['global', 'team', 'personal'].includes(presetModel.preset_scope));
|
||||
assert.equal(presetModel.preset_scope, presetModel.scope,
|
||||
'preset_scope alias must match scope');
|
||||
it('persona has persona_scope', () => {
|
||||
assert.ok(['global', 'team', 'personal'].includes(personaModel.persona_scope));
|
||||
});
|
||||
|
||||
it('preset ID uses preset_id not composite', () => {
|
||||
// Frontend: id = isPreset ? (m.preset_id || m.persona_id || m.id) : composite
|
||||
const id = presetModel.is_preset
|
||||
? (presetModel.preset_id || presetModel.persona_id || presetModel.id)
|
||||
: `${presetModel.config_id || presetModel.provider_config_id}:${presetModel.model_id}`;
|
||||
assert.equal(id, 'preset-uuid-123');
|
||||
it('persona ID uses persona_id not composite', () => {
|
||||
// Frontend: id = isPersona ? (m.persona_id || m.id) : composite
|
||||
const id = personaModel.is_persona
|
||||
? (personaModel.persona_id || personaModel.id)
|
||||
: `${personaModel.config_id || personaModel.provider_config_id}:${personaModel.model_id}`;
|
||||
assert.equal(id, 'persona-uuid-123');
|
||||
});
|
||||
|
||||
it('frontend works with ONLY canonical fields (no aliases)', () => {
|
||||
// If someone breaks the alias population in resolver.go,
|
||||
// the frontend fallback chain must still produce correct IDs.
|
||||
// v0.22.8: no more dual-key aliases — backend sends is_persona directly.
|
||||
const rawBackend = {
|
||||
id: 'preset-uuid-123',
|
||||
id: 'persona-uuid-123',
|
||||
model_id: 'gpt-4o',
|
||||
source: 'persona',
|
||||
provider_config_id: 'cfg1',
|
||||
persona_id: 'preset-uuid-123',
|
||||
scope: 'global',
|
||||
// NO is_preset, NO preset_id, NO config_id aliases
|
||||
is_persona: true,
|
||||
persona_id: 'persona-uuid-123',
|
||||
persona_scope: 'global',
|
||||
};
|
||||
// is_preset would be undefined/falsy → treated as catalog model
|
||||
// This test documents the FAILURE MODE if aliases break
|
||||
const isPreset = !!rawBackend.is_preset; // false!
|
||||
assert.equal(isPreset, false,
|
||||
'Without is_preset alias, persona is misidentified as catalog model');
|
||||
const isPersona = !!rawBackend.is_persona;
|
||||
assert.equal(isPersona, true,
|
||||
'is_persona field must be present for correct identification');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -441,8 +431,8 @@ describe('Response shape consistency', () => {
|
||||
assert.ok('models' in shape, 'admin/models must use "models" key');
|
||||
});
|
||||
|
||||
it('presets uses {presets:[]}', () => {
|
||||
const shape = { presets: [] };
|
||||
assert.ok('presets' in shape);
|
||||
it('personas uses {personas:[]}', () => {
|
||||
const shape = { personas: [] };
|
||||
assert.ok('personas' in shape);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,22 +116,24 @@ function loadSource(sandbox, filename) {
|
||||
*/
|
||||
function loadAppModules(overrides = {}) {
|
||||
const sandbox = createBrowserContext(overrides);
|
||||
loadSource(sandbox, 'app-state.js');
|
||||
loadSource(sandbox, 'api.js');
|
||||
loadSource(sandbox, 'app.js');
|
||||
return { API: sandbox.API, App: sandbox.App, sandbox };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the model-processing transform from fetchModels.
|
||||
* Extract the model-processing transform from fetchModels (app-state.js).
|
||||
* This is the core mapping logic that converts API response → App.models.
|
||||
* v0.22.8: unified persona naming — no more preset aliases.
|
||||
*/
|
||||
function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
return (data.models || []).map(m => {
|
||||
const isPreset = !!m.is_preset;
|
||||
const isPersona = !!m.is_persona;
|
||||
const baseModelId = m.model_id || m.id;
|
||||
const cfgId = m.config_id || m.provider_config_id;
|
||||
const id = isPreset
|
||||
? (m.preset_id || m.persona_id || m.id)
|
||||
const id = isPersona
|
||||
? (m.persona_id || m.id)
|
||||
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
|
||||
return {
|
||||
id,
|
||||
@@ -139,14 +141,14 @@ function processModelsResponse(data, hiddenModels = new Set()) {
|
||||
name: m.display_name || baseModelId,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: cfgId || null,
|
||||
isPreset,
|
||||
presetId: m.preset_id || m.persona_id || null,
|
||||
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
|
||||
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
|
||||
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||||
source: m.source || (isPreset ? 'preset' : 'global'),
|
||||
teamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||||
hidden: !isPreset && hiddenModels.has(baseModelId),
|
||||
isPersona,
|
||||
personaId: m.persona_id || null,
|
||||
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
||||
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
||||
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
||||
source: m.source || (isPersona ? 'persona' : 'global'),
|
||||
teamName: m.persona_team_name || null,
|
||||
hidden: !isPersona && hiddenModels.has(baseModelId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@ describe('processModelsResponse — catalog models', () => {
|
||||
assert.equal(models[0].name, 'claude-3-opus');
|
||||
});
|
||||
|
||||
it('marks catalog models as NOT presets', () => {
|
||||
it('marks catalog models as NOT personas', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].isPreset, false);
|
||||
assert.equal(models[0].isPersona, false);
|
||||
});
|
||||
|
||||
it('preserves configId from config_id || provider_config_id', () => {
|
||||
@@ -93,7 +93,7 @@ describe('processModelsResponse — catalog models', () => {
|
||||
|
||||
// ── Preset transform ─────────────────────────
|
||||
|
||||
describe('processModelsResponse — presets', () => {
|
||||
describe('processModelsResponse — personas', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
{
|
||||
@@ -107,47 +107,47 @@ describe('processModelsResponse — presets', () => {
|
||||
avatar: '/avatars/code.png',
|
||||
source: 'persona',
|
||||
// Frontend alias fields
|
||||
is_preset: true,
|
||||
preset_id: 'preset-uuid',
|
||||
preset_scope: 'global',
|
||||
preset_avatar: '/avatars/code.png',
|
||||
preset_team_name: null,
|
||||
is_persona: true,
|
||||
persona_id: 'preset-uuid',
|
||||
persona_scope: 'global',
|
||||
persona_avatar: '/avatars/code.png',
|
||||
persona_team_name: null,
|
||||
config_id: 'cfg-uuid',
|
||||
provider_name: 'OpenAI',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('uses preset_id as ID (not composite)', () => {
|
||||
it('uses persona_id as ID (not composite)', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].id, 'preset-uuid',
|
||||
'preset ID must use preset_id, not config_id:model_id');
|
||||
'persona ID must use persona_id, not config_id:model_id');
|
||||
});
|
||||
|
||||
it('falls back to persona_id when preset_id is missing', () => {
|
||||
it('falls back to persona_id when not present', () => {
|
||||
const resp = {
|
||||
models: [{
|
||||
id: 'p-uuid', model_id: 'gpt-4o',
|
||||
is_preset: true, persona_id: 'p-uuid',
|
||||
is_persona: true, persona_id: 'p-uuid',
|
||||
// NO preset_id — simulates broken alias
|
||||
source: 'persona',
|
||||
}],
|
||||
};
|
||||
const models = processModelsResponse(resp);
|
||||
assert.equal(models[0].id, 'p-uuid',
|
||||
'must fall back to persona_id when preset_id is missing');
|
||||
'must fall back to persona_id');
|
||||
});
|
||||
|
||||
it('marks as preset', () => {
|
||||
it('marks as persona', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].isPreset, true);
|
||||
assert.equal(models[0].isPersona, true);
|
||||
});
|
||||
|
||||
it('preserves preset metadata', () => {
|
||||
it('preserves persona metadata', () => {
|
||||
const models = processModelsResponse(apiResponse);
|
||||
assert.equal(models[0].presetScope, 'global');
|
||||
assert.equal(models[0].presetAvatar, '/avatars/code.png');
|
||||
assert.equal(models[0].presetId, 'preset-uuid');
|
||||
assert.equal(models[0].personaScope, 'global');
|
||||
assert.equal(models[0].personaAvatar, '/avatars/code.png');
|
||||
assert.equal(models[0].personaId, 'preset-uuid');
|
||||
});
|
||||
|
||||
it('baseModelId is the underlying model', () => {
|
||||
@@ -215,16 +215,16 @@ describe('processModelsResponse — hidden models', () => {
|
||||
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
|
||||
});
|
||||
|
||||
it('presets are never hidden via model ID', () => {
|
||||
it('personas are never hidden via model ID', () => {
|
||||
const resp = {
|
||||
models: [
|
||||
{ model_id: 'gpt-4o', is_preset: true, preset_id: 'p1', display_name: 'Preset' },
|
||||
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
|
||||
],
|
||||
};
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const models = processModelsResponse(resp, hidden);
|
||||
assert.equal(models[0].hidden, false,
|
||||
'presets must NOT be hidden by base model hidden pref');
|
||||
'personas must NOT be hidden by base model hidden pref');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,44 +270,44 @@ describe('Model sorting', () => {
|
||||
function sortModels(models) {
|
||||
const scopeOrder = { global: 0, team: 1, personal: 2 };
|
||||
return [...models].sort((a, b) => {
|
||||
if (a.isPreset && !b.isPreset) return -1;
|
||||
if (!a.isPreset && b.isPreset) return 1;
|
||||
if (a.isPreset && b.isPreset) {
|
||||
const sa = scopeOrder[a.presetScope] ?? 9;
|
||||
const sb = scopeOrder[b.presetScope] ?? 9;
|
||||
if (a.isPersona && !b.isPersona) return -1;
|
||||
if (!a.isPersona && b.isPersona) return 1;
|
||||
if (a.isPersona && b.isPersona) {
|
||||
const sa = scopeOrder[a.personaScope] ?? 9;
|
||||
const sb = scopeOrder[b.personaScope] ?? 9;
|
||||
if (sa !== sb) return sa - sb;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
it('presets sort before regular models', () => {
|
||||
it('personas sort before regular models', () => {
|
||||
const models = [
|
||||
{ name: 'GPT-4o', isPreset: false },
|
||||
{ name: 'Code Helper', isPreset: true, presetScope: 'global' },
|
||||
{ name: 'GPT-4o', isPersona: false },
|
||||
{ name: 'Code Helper', isPersona: true, personaScope: 'global' },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].name, 'Code Helper');
|
||||
assert.equal(sorted[1].name, 'GPT-4o');
|
||||
});
|
||||
|
||||
it('global presets sort before team presets', () => {
|
||||
it('global personas sort before team personas', () => {
|
||||
const models = [
|
||||
{ name: 'Team Bot', isPreset: true, presetScope: 'team' },
|
||||
{ name: 'Global Bot', isPreset: true, presetScope: 'global' },
|
||||
{ name: 'My Bot', isPreset: true, presetScope: 'personal' },
|
||||
{ name: 'Team Bot', isPersona: true, personaScope: 'team' },
|
||||
{ name: 'Global Bot', isPersona: true, personaScope: 'global' },
|
||||
{ name: 'My Bot', isPersona: true, personaScope: 'personal' },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].presetScope, 'global');
|
||||
assert.equal(sorted[1].presetScope, 'team');
|
||||
assert.equal(sorted[2].presetScope, 'personal');
|
||||
assert.equal(sorted[0].personaScope, 'global');
|
||||
assert.equal(sorted[1].personaScope, 'team');
|
||||
assert.equal(sorted[2].personaScope, 'personal');
|
||||
});
|
||||
|
||||
it('regular models sort alphabetically', () => {
|
||||
const models = [
|
||||
{ name: 'Zephyr', isPreset: false },
|
||||
{ name: 'Claude', isPreset: false },
|
||||
{ name: 'GPT-4o', isPreset: false },
|
||||
{ name: 'Zephyr', isPersona: false },
|
||||
{ name: 'Claude', isPersona: false },
|
||||
{ name: 'GPT-4o', isPersona: false },
|
||||
];
|
||||
const sorted = sortModels(models);
|
||||
assert.equal(sorted[0].name, 'Claude');
|
||||
|
||||
@@ -65,12 +65,12 @@ describe('Policy wiring audit — source code', () => {
|
||||
|
||||
// ── allow_user_personas ──
|
||||
|
||||
it('UI has checkUserPresetsAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed — preset button will show when policy is off');
|
||||
it('UI has checkUserPersonasAllowed function', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed — persona button will show when policy is off');
|
||||
});
|
||||
|
||||
it('checkUserPresetsAllowed reads App.policies.allow_user_personas', () => {
|
||||
it('checkUserPersonasAllowed reads App.policies.allow_user_personas', () => {
|
||||
assert.ok(uiSrc.includes('allow_user_personas'),
|
||||
'MISSING: allow_user_personas check in UI');
|
||||
});
|
||||
@@ -104,10 +104,10 @@ describe('Policy wiring audit — source code', () => {
|
||||
'MISSING: checkUserProvidersAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls checkUserPresetsAllowed', () => {
|
||||
it('admin save calls checkUserPersonasAllowed', () => {
|
||||
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
|
||||
assert.ok(saveFunc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed call after admin save');
|
||||
assert.ok(saveFunc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed call after admin save');
|
||||
});
|
||||
|
||||
it('admin save calls fetchModels to refresh model list', () => {
|
||||
@@ -118,9 +118,9 @@ describe('Policy wiring audit — source code', () => {
|
||||
|
||||
// ── Models tab calls preset check ──
|
||||
|
||||
it('models tab switch calls checkUserPresetsAllowed', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
|
||||
'MISSING: checkUserPresetsAllowed call on models tab switch');
|
||||
it('models tab switch calls checkUserPersonasAllowed', () => {
|
||||
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
|
||||
'MISSING: checkUserPersonasAllowed call on models tab switch');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,9 +283,9 @@ describe('Critical HTML elements exist in server templates', () => {
|
||||
|
||||
const requiredElements = [
|
||||
// Settings surface — provider section scaffold
|
||||
'userPresetList', // User preset list container
|
||||
'userAddPresetBtn', // New preset button (policy-gated)
|
||||
'userAddPresetForm', // Preset form container
|
||||
'userPersonaList', // User persona list container
|
||||
'userAddPersonaBtn', // New persona button (policy-gated)
|
||||
'userAddPersonaForm', // Persona form container
|
||||
'userProvidersDisabled', // BYOK disabled notice
|
||||
'providerShowAddBtn', // Add provider button (policy-gated)
|
||||
// Admin settings — policy toggles
|
||||
|
||||
@@ -31,7 +31,7 @@ function globalModel(modelId, configId, providerName) {
|
||||
provider_type: 'openai',
|
||||
source: 'catalog',
|
||||
scope: 'global',
|
||||
is_preset: false,
|
||||
is_persona: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
};
|
||||
@@ -48,7 +48,7 @@ function teamModel(modelId, configId, providerName, teamName) {
|
||||
provider_type: 'venice',
|
||||
source: 'catalog',
|
||||
scope: 'team',
|
||||
is_preset: false,
|
||||
is_persona: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
team_name: teamName,
|
||||
@@ -66,21 +66,21 @@ function personalModel(modelId, configId, providerName) {
|
||||
provider_type: 'venice',
|
||||
source: 'catalog',
|
||||
scope: 'personal',
|
||||
is_preset: false,
|
||||
is_persona: false,
|
||||
capabilities: { streaming: true },
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function presetModel(presetId, baseModelId, scope, teamName) {
|
||||
function personaModel(personaId, baseModelId, scope, teamName) {
|
||||
return {
|
||||
id: presetId,
|
||||
id: personaId,
|
||||
model_id: baseModelId,
|
||||
display_name: `Preset: ${baseModelId}`,
|
||||
preset_id: presetId,
|
||||
preset_scope: scope,
|
||||
preset_team_name: teamName || '',
|
||||
is_preset: true,
|
||||
display_name: `Persona: ${baseModelId}`,
|
||||
persona_id: personaId,
|
||||
persona_scope: scope,
|
||||
persona_team_name: teamName || '',
|
||||
is_persona: true,
|
||||
source: 'persona',
|
||||
scope: scope,
|
||||
capabilities: { streaming: true },
|
||||
@@ -144,9 +144,9 @@ describe('User Journey: Platform Admin (no team, no BYOK)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('source is catalog (not preset)', () => {
|
||||
it('source is catalog (not persona)', () => {
|
||||
for (const m of models) {
|
||||
assert.equal(m.isPreset, false);
|
||||
assert.equal(m.isPersona, false);
|
||||
assert.equal(m.source, 'catalog');
|
||||
}
|
||||
});
|
||||
@@ -322,42 +322,42 @@ describe('Edge: Hidden model preferences', () => {
|
||||
assert.equal(main.hidden, false, 'gpt-4o should not be hidden');
|
||||
});
|
||||
|
||||
it('presets are never hidden', () => {
|
||||
const preset = presetModel('preset-1', 'gpt-4o', 'global');
|
||||
it('personas are never hidden', () => {
|
||||
const persona = personaModel('preset-1', 'gpt-4o', 'global');
|
||||
const hidden = new Set(['gpt-4o']);
|
||||
const models = processModelsResponse({ models: [preset] }, hidden);
|
||||
assert.equal(models[0].hidden, false, 'presets must never be hidden');
|
||||
const models = processModelsResponse({ models: [persona] }, hidden);
|
||||
assert.equal(models[0].hidden, false, 'personas must never be hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge: Presets mixed with catalog models', () => {
|
||||
describe('Edge: Personas mixed with catalog models', () => {
|
||||
const apiResponse = {
|
||||
models: [
|
||||
...GLOBAL_MODELS,
|
||||
presetModel('preset-code', 'gpt-4o', 'global'),
|
||||
presetModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
|
||||
personaModel('preset-code', 'gpt-4o', 'global'),
|
||||
personaModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
|
||||
],
|
||||
};
|
||||
const models = processModelsResponse(apiResponse);
|
||||
|
||||
it('presets use preset_id as ID, not composite', () => {
|
||||
const preset = models.find(m => m.presetId === 'preset-code');
|
||||
assert.ok(preset, 'preset-code should be present');
|
||||
assert.equal(preset.id, 'preset-code');
|
||||
assert.equal(preset.isPreset, true);
|
||||
it('personas use persona_id as ID, not composite', () => {
|
||||
const persona = models.find(m => m.personaId === 'preset-code');
|
||||
assert.ok(persona, 'preset-code should be present');
|
||||
assert.equal(persona.id, 'preset-code');
|
||||
assert.equal(persona.isPersona, true);
|
||||
});
|
||||
|
||||
it('preset and catalog model with same model_id have different IDs', () => {
|
||||
const catalog = models.find(m => !m.isPreset && m.baseModelId === 'gpt-4o');
|
||||
const preset = models.find(m => m.isPreset && m.baseModelId === 'gpt-4o');
|
||||
it('persona and catalog model with same model_id have different IDs', () => {
|
||||
const catalog = models.find(m => !m.isPersona && m.baseModelId === 'gpt-4o');
|
||||
const persona = models.find(m => m.isPersona && m.baseModelId === 'gpt-4o');
|
||||
assert.ok(catalog, 'catalog gpt-4o should exist');
|
||||
assert.ok(preset, 'preset gpt-4o should exist');
|
||||
assert.notEqual(catalog.id, preset.id);
|
||||
assert.ok(persona, 'preset gpt-4o should exist');
|
||||
assert.notEqual(catalog.id, persona.id);
|
||||
});
|
||||
|
||||
it('preset team name flows through', () => {
|
||||
const teamPreset = models.find(m => m.presetId === 'preset-team');
|
||||
assert.equal(teamPreset.presetTeamName, 'Engineering');
|
||||
it('persona team name flows through', () => {
|
||||
const teamPersona = models.find(m => m.personaId === 'preset-team');
|
||||
assert.equal(teamPersona.personaTeamName, 'Engineering');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -412,10 +412,10 @@ describe('Full Matrix: 4 actors × backend response → frontend model count', (
|
||||
`${tc.actor}: duplicate IDs: ${JSON.stringify(ids)}`);
|
||||
});
|
||||
|
||||
it(`${tc.actor}: all models have configId or are presets`, () => {
|
||||
it(`${tc.actor}: all models have configId or are personas`, () => {
|
||||
const result = processModelsResponse({ models: tc.models });
|
||||
for (const m of result) {
|
||||
if (!m.isPreset) {
|
||||
if (!m.isPersona) {
|
||||
assert.ok(m.configId,
|
||||
`${tc.actor}: model ${m.baseModelId} missing configId — ` +
|
||||
`frontend will fail to route completions`);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Chat Switchboard – Admin Handlers
|
||||
// ==========================================
|
||||
// Admin actions: user management, roles, model visibility,
|
||||
// presets, team management, global providers.
|
||||
// personas, team management, global providers.
|
||||
|
||||
// ── Admin Actions ────────────────────────────
|
||||
|
||||
@@ -155,7 +155,7 @@ async function toggleUserModelVisibility(modelId, currentlyHidden) {
|
||||
}
|
||||
|
||||
async function bulkSetUserModelVisibility(show) {
|
||||
const models = App.models.filter(m => !m.isPreset);
|
||||
const models = App.models.filter(m => !m.isPersona);
|
||||
if (!models.length) return;
|
||||
const shouldHide = !show;
|
||||
// Only update models not already in the desired state
|
||||
@@ -172,41 +172,41 @@ async function bulkSetUserModelVisibility(show) {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteUserPreset(id, name) {
|
||||
if (!await showConfirm(`Delete preset "${name}"?`)) return;
|
||||
async function deleteUserPersona(id, name) {
|
||||
if (!await showConfirm(`Delete persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.deleteUserPreset(id);
|
||||
UI.toast('Preset deleted');
|
||||
await UI.loadUserPresets();
|
||||
await API.deleteUserPersona(id);
|
||||
UI.toast('Persona deleted');
|
||||
await UI.loadUserPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Admin Presets ────────────────────────────
|
||||
// ── Admin Personas ────────────────────────────
|
||||
|
||||
var _adminPresetForm = null;
|
||||
function ensureAdminPresetForm() {
|
||||
if (_adminPresetForm) return _adminPresetForm;
|
||||
const container = document.getElementById('adminAddPresetForm');
|
||||
var _adminPersonaForm = null;
|
||||
function ensureAdminPersonaForm() {
|
||||
if (_adminPersonaForm) return _adminPersonaForm;
|
||||
const container = document.getElementById('adminAddPersonaForm');
|
||||
if (!container) return null;
|
||||
_adminPresetForm = renderPresetForm(container, {
|
||||
prefix: 'adminPreset',
|
||||
_adminPersonaForm = renderPersonaForm(container, {
|
||||
prefix: 'adminPersona',
|
||||
showAvatar: true,
|
||||
showProviderConfig: true,
|
||||
showKBPicker: true,
|
||||
kbScope: 'admin',
|
||||
onSubmit: (vals) => createAdminPreset(vals),
|
||||
onSubmit: (vals) => createAdminPersona(vals),
|
||||
onCancel: () => {
|
||||
container.style.display = 'none';
|
||||
_editingPresetId = null;
|
||||
_adminPresetForm.setSubmitLabel('Create');
|
||||
_adminPresetForm.clearForm();
|
||||
_editingPersonaId = null;
|
||||
_adminPersonaForm.setSubmitLabel('Create');
|
||||
_adminPersonaForm.clearForm();
|
||||
}
|
||||
});
|
||||
// Override avatar handlers for edit-mode (upload immediately for existing presets)
|
||||
const fileInput = document.getElementById('adminPreset_avatarFileInput');
|
||||
// Override avatar handlers for edit-mode (upload immediately for existing personas)
|
||||
const fileInput = document.getElementById('adminPersona_avatarFileInput');
|
||||
if (fileInput) {
|
||||
// Remove default handler set by renderPresetForm, add edit-aware one
|
||||
// Remove default handler set by renderPersonaForm, add edit-aware one
|
||||
const newInput = fileInput.cloneNode(true);
|
||||
fileInput.parentNode.replaceChild(newInput, fileInput);
|
||||
newInput.addEventListener('change', async function() {
|
||||
@@ -214,55 +214,55 @@ function ensureAdminPresetForm() {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
if (_editingPresetId) {
|
||||
if (_editingPersonaId) {
|
||||
try {
|
||||
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
|
||||
const data = await API.adminUploadPersonaAvatar(_editingPersonaId, reader.result);
|
||||
if (data.avatar) {
|
||||
_adminPresetForm.updateAvatarPreview(data.avatar);
|
||||
await UI.loadAdminPresets(true);
|
||||
_adminPersonaForm.updateAvatarPreview(data.avatar);
|
||||
await UI.loadAdminPersonas(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar updated');
|
||||
UI.toast('Persona avatar updated');
|
||||
}
|
||||
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
|
||||
} else {
|
||||
_adminPresetForm.updateAvatarPreview(reader.result);
|
||||
_adminPersonaForm.updateAvatarPreview(reader.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
this.value = '';
|
||||
});
|
||||
document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
|
||||
document.getElementById('adminPersona_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
|
||||
}
|
||||
const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn');
|
||||
const removeBtn = document.getElementById('adminPersona_avatarRemoveBtn');
|
||||
if (removeBtn) {
|
||||
const newBtn = removeBtn.cloneNode(true);
|
||||
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
|
||||
newBtn.addEventListener('click', async () => {
|
||||
if (_editingPresetId) {
|
||||
if (_editingPersonaId) {
|
||||
try {
|
||||
await API.adminDeletePresetAvatar(_editingPresetId);
|
||||
_adminPresetForm.updateAvatarPreview(null);
|
||||
await UI.loadAdminPresets(true);
|
||||
await API.adminDeletePersonaAvatar(_editingPersonaId);
|
||||
_adminPersonaForm.updateAvatarPreview(null);
|
||||
await UI.loadAdminPersonas(true);
|
||||
await fetchModels();
|
||||
UI.toast('Preset avatar removed');
|
||||
UI.toast('Persona avatar removed');
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
} else {
|
||||
_adminPresetForm.updateAvatarPreview(null);
|
||||
_adminPersonaForm.updateAvatarPreview(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return _adminPresetForm;
|
||||
return _adminPersonaForm;
|
||||
}
|
||||
|
||||
var _editingPresetId = null;
|
||||
function editAdminPreset(id) {
|
||||
const p = UI._presetCache?.[id];
|
||||
var _editingPersonaId = null;
|
||||
function editAdminPersona(id) {
|
||||
const p = UI._personaCache?.[id];
|
||||
if (!p) return;
|
||||
_editingPresetId = id;
|
||||
ensureAdminPresetForm();
|
||||
const form = _adminPresetForm;
|
||||
_editingPersonaId = id;
|
||||
ensureAdminPersonaForm();
|
||||
const form = _adminPersonaForm;
|
||||
if (!form) return;
|
||||
document.getElementById('adminAddPresetForm').style.display = '';
|
||||
document.getElementById('adminAddPersonaForm').style.display = '';
|
||||
form.setValues(p);
|
||||
form.setSubmitLabel('Update');
|
||||
// Load KB bindings (v0.17.0)
|
||||
@@ -271,73 +271,73 @@ function editAdminPreset(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createAdminPreset(vals) {
|
||||
async function createAdminPersona(vals) {
|
||||
if (!vals) {
|
||||
if (_adminPresetForm) vals = _adminPresetForm.getValues();
|
||||
if (_adminPersonaForm) vals = _adminPersonaForm.getValues();
|
||||
else return;
|
||||
}
|
||||
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
|
||||
|
||||
const preset = {
|
||||
const persona = {
|
||||
name: vals.name,
|
||||
base_model_id: vals.base_model_id,
|
||||
description: vals.description || '',
|
||||
system_prompt: vals.system_prompt || '',
|
||||
};
|
||||
if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id;
|
||||
if (vals.temperature != null) preset.temperature = vals.temperature;
|
||||
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
|
||||
if (vals.provider_config_id) persona.provider_config_id = vals.provider_config_id;
|
||||
if (vals.temperature != null) persona.temperature = vals.temperature;
|
||||
if (vals.max_tokens != null) persona.max_tokens = vals.max_tokens;
|
||||
|
||||
try {
|
||||
let presetId = _editingPresetId;
|
||||
if (_editingPresetId) {
|
||||
await API.adminUpdatePreset(_editingPresetId, preset);
|
||||
UI.toast('Preset updated', 'success');
|
||||
let personaId = _editingPersonaId;
|
||||
if (_editingPersonaId) {
|
||||
await API.adminUpdatePersona(_editingPersonaId, preset);
|
||||
UI.toast('Persona updated', 'success');
|
||||
} else {
|
||||
const result = await API.adminCreatePreset(preset);
|
||||
presetId = result?.id || result?.preset?.id;
|
||||
UI.toast('Preset created', 'success');
|
||||
const result = await API.adminCreatePersona(preset);
|
||||
personaId = result?.id || result?.preset?.id;
|
||||
UI.toast('Persona created', 'success');
|
||||
}
|
||||
|
||||
// Upload pending avatar for new presets
|
||||
if (!_editingPresetId && presetId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
||||
// Upload pending avatar for new personas
|
||||
if (!_editingPersonaId && personaId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Persona avatar upload failed:', e.message); }
|
||||
}
|
||||
|
||||
// Save KB bindings (v0.17.0)
|
||||
if (presetId && vals._kbValues && _adminPresetForm?.kbPicker) {
|
||||
try { await API.adminSetPresetKBs(presetId, vals._kbValues); }
|
||||
catch (e) { console.warn('Preset KB binding failed:', e.message); }
|
||||
if (personaId && vals._kbValues && _adminPersonaForm?.kbPicker) {
|
||||
try { await API.adminSetPresetKBs(personaId, vals._kbValues); }
|
||||
catch (e) { console.warn('Persona KB binding failed:', e.message); }
|
||||
}
|
||||
|
||||
_editingPresetId = null;
|
||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||
if (_adminPresetForm) {
|
||||
_adminPresetForm.setSubmitLabel('Create');
|
||||
_adminPresetForm.clearForm();
|
||||
_editingPersonaId = null;
|
||||
document.getElementById('adminAddPersonaForm').style.display = 'none';
|
||||
if (_adminPersonaForm) {
|
||||
_adminPersonaForm.setSubmitLabel('Create');
|
||||
_adminPersonaForm.clearForm();
|
||||
}
|
||||
await UI.loadAdminPresets();
|
||||
await UI.loadAdminPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function toggleAdminPreset(id, active) {
|
||||
async function toggleAdminPersona(id, active) {
|
||||
try {
|
||||
const el = _adminScroll(), pos = el?.scrollTop || 0;
|
||||
await API.adminUpdatePreset(id, { is_active: active });
|
||||
await UI.loadAdminPresets(true);
|
||||
await API.adminUpdatePersona(id, { is_active: active });
|
||||
await UI.loadAdminPersonas(true);
|
||||
_restoreScroll(el, pos);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteAdminPreset(id, name) {
|
||||
if (!await showConfirm(`Delete preset "${name}"?`)) return;
|
||||
async function deleteAdminPersona(id, name) {
|
||||
if (!await showConfirm(`Delete persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.adminDeletePreset(id);
|
||||
UI.toast('Preset deleted', 'success');
|
||||
await UI.loadAdminPresets();
|
||||
await API.adminDeletePersona(id);
|
||||
UI.toast('Persona deleted', 'success');
|
||||
await UI.loadAdminPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
@@ -400,12 +400,12 @@ async function settingsRemoveTeamMember(teamId, memberId, email) {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function settingsDeleteTeamPreset(teamId, presetId, name) {
|
||||
if (!await showConfirm(`Delete team preset "${name}"?`)) return;
|
||||
async function settingsDeleteTeamPersona(teamId, personaId, name) {
|
||||
if (!await showConfirm(`Delete team persona "${name}"?`)) return;
|
||||
try {
|
||||
await API.teamDeletePreset(teamId, presetId);
|
||||
UI.toast('Preset deleted');
|
||||
await UI.loadTeamManagePresets(teamId);
|
||||
await API.teamDeletePreset(teamId, personaId);
|
||||
UI.toast('Persona deleted');
|
||||
await UI.loadTeamManagePersonas(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
@@ -483,14 +483,14 @@ function _initAdminListeners() {
|
||||
// Admin — models
|
||||
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
|
||||
|
||||
// Admin — presets (shared form)
|
||||
document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
|
||||
const form = ensureAdminPresetForm();
|
||||
// Admin — personas (shared form)
|
||||
document.getElementById('adminAddPersonaBtn')?.addEventListener('click', () => {
|
||||
const form = ensureAdminPersonaForm();
|
||||
if (!form) return;
|
||||
_editingPresetId = null;
|
||||
_editingPersonaId = null;
|
||||
form.setSubmitLabel('Create');
|
||||
form.clearForm();
|
||||
const f = document.getElementById('adminAddPresetForm');
|
||||
const f = document.getElementById('adminAddPersonaForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
|
||||
|
||||
372
src/js/admin-scaffold.js
Normal file
372
src/js/admin-scaffold.js
Normal file
@@ -0,0 +1,372 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - Admin Surface Scaffold
|
||||
// ==========================================
|
||||
// Injects section-specific DOM into adminDynamic, wires listeners,
|
||||
// and calls the appropriate ADMIN_LOADERS[section]() from ui-admin.js.
|
||||
// Loaded only on the admin surface (/admin/:section).
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Each admin section loader (ui-admin.js) expects specific container
|
||||
// elements. SCAFFOLDING maps section name -> HTML to inject before
|
||||
// the loader runs.
|
||||
var SCAFFOLDING = {};
|
||||
|
||||
// -- People -----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.users =
|
||||
'<div id="adminUserList" class="admin-list"></div>' +
|
||||
'<div id="adminAddUserForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Create User</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>' +
|
||||
'<div class="form-group"><label>Email</label><input type="text" id="adminNewEmail" placeholder="email@example.com"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>' +
|
||||
'<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminCreateUserBtn">Create User</button>' +
|
||||
'<button class="btn-small" id="adminCancelUserBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.teams =
|
||||
'<div id="adminTeamList" class="admin-list"></div>' +
|
||||
'<div id="adminAddTeamForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Create Team</h4>' +
|
||||
'<div class="form-group"><label>Name</label><input type="text" id="adminTeamName" placeholder="Team name"></div>' +
|
||||
'<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Optional description"></div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminCreateTeamBtn">Create Team</button>' +
|
||||
'<button class="btn-small" id="adminCancelTeamBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="adminTeamDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="adminTeamBackBtn">← Back</button>' +
|
||||
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
|
||||
'</div>' +
|
||||
'<div style="margin-bottom:12px">' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (BYOK)</label>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team providers</label>' +
|
||||
'</div>' +
|
||||
'<div id="adminMemberList" class="admin-list"></div>' +
|
||||
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>User</label><select id="adminMemberUser"><option value="">Select user...</option></select></div>' +
|
||||
'<div class="form-group"><label>Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Admin</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminAddMemberSubmit">Add Member</button>' +
|
||||
'<button class="btn-small" id="adminCancelMemberBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small" id="adminAddMemberBtn" style="margin-top:8px">+ Add Member</button>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.groups =
|
||||
'<div id="adminGroupList" class="admin-list"></div>' +
|
||||
'<div id="adminAddGroupForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Create Group</h4>' +
|
||||
'<div class="form-group"><label>Name</label><input type="text" id="adminGroupName" placeholder="Group name"></div>' +
|
||||
'<div class="form-group"><label>Description</label><input type="text" id="adminGroupDesc" placeholder="Optional description"></div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Scope</label><select id="adminGroupScope"><option value="global">Global</option><option value="team">Team</option></select></div>' +
|
||||
'<div class="form-group" id="adminGroupTeamRow" style="display:none"><label>Team</label><select id="adminGroupTeam"></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminCreateGroupBtn">Create Group</button>' +
|
||||
'<button class="btn-small" id="adminCancelGroupBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="adminGroupDetail" style="display:none">' +
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small" id="adminGroupBackBtn">← Back</button>' +
|
||||
'<h4 id="adminGroupDetailName" style="margin:0"></h4>' +
|
||||
'<span id="adminGroupDetailMeta" class="text-muted"></span>' +
|
||||
'</div>' +
|
||||
'<div id="adminGroupMemberList" class="admin-list"></div>' +
|
||||
'<div id="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>User</label><select id="adminGroupMemberUser"><option value="">Select user...</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminAddGroupMemberSubmit">Add Member</button>' +
|
||||
'<button class="btn-small" id="adminCancelGroupMemberBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn-small" id="adminAddGroupMemberBtn" style="margin-top:8px">+ Add Member</button>' +
|
||||
'</div>';
|
||||
|
||||
// -- AI ---------------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.providers =
|
||||
'<div id="adminProviderList" class="admin-list"></div>' +
|
||||
'<div id="adminAddProviderForm" class="admin-inline-form" style="display:none"></div>';
|
||||
|
||||
SCAFFOLDING.models =
|
||||
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
|
||||
'<button class="btn-small btn-primary" id="adminFetchModelsBtn">Fetch Models</button>' +
|
||||
'<span id="adminModelsHint" class="text-muted" style="font-size:12px"></span>' +
|
||||
'</div>' +
|
||||
'<div id="adminModelList" class="admin-list"></div>';
|
||||
|
||||
SCAFFOLDING.personas =
|
||||
'<div id="adminPersonaList" class="admin-list"></div>' +
|
||||
'<div id="adminAddPersonaForm" class="admin-inline-form" style="display:none"></div>';
|
||||
|
||||
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
|
||||
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
|
||||
SCAFFOLDING.memory = '<div id="adminMemoryContent"></div>';
|
||||
|
||||
// -- System -----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.settings =
|
||||
'<div class="admin-settings-form">' +
|
||||
'<div class="settings-section"><h4>Registration</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminRegToggle"> Allow new user registration</label>' +
|
||||
'<div class="form-group" style="margin-top:8px"><label>Default state for new users</label>' +
|
||||
'<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>System Prompt</h4>' +
|
||||
'<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Default Model</h4>' +
|
||||
'<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Policies</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle"> Allow BYOK (user API keys)</label>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminUserPersonasToggle"> Allow user personas</label>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Allow direct KB access (non-persona)</label>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Environment Banner</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>' +
|
||||
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<div class="form-group"><label>Preset</label><select id="adminBannerPreset"><option value="">Custom</option></select></div>' +
|
||||
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
|
||||
'<div class="form-group"><label>Position</label>' +
|
||||
'<select id="adminBannerPosition"><option value="both">Top & Bottom</option><option value="top">Top</option><option value="bottom">Bottom</option></select>' +
|
||||
'</div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Background</label><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;margin-left:4px"></div>' +
|
||||
'<div class="form-group"><label>Foreground</label><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;margin-left:4px"></div>' +
|
||||
'</div>' +
|
||||
'<div id="bannerPreview" class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px">PREVIEW</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Web Search</h4>' +
|
||||
'<div class="form-group"><label>Provider</label>' +
|
||||
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
|
||||
'</div>' +
|
||||
'<div id="searxngConfigFields" style="display:none">' +
|
||||
'<div class="form-group"><label>Endpoint</label><input type="text" id="adminSearchEndpoint" placeholder="https://searxng.example.com"></div>' +
|
||||
'<div class="form-group"><label>API Key</label><input type="text" id="adminSearchApiKey" placeholder="Optional"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Auto-Compaction</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminCompactionEnabled"> Enable auto-compaction</label>' +
|
||||
'<div id="compactionConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' +
|
||||
'<div class="form-group"><label>Cooldown (min)</label><input type="number" id="adminCompactionCooldown" value="30" min="5" max="1440"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Memory Extraction</h4>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryExtractionEnabled"> Enable memory extraction</label>' +
|
||||
'<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryAutoApprove"> Auto-approve extracted memories</label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="settings-section"><h4>Encryption Vault</h4>' +
|
||||
'<div id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
|
||||
'</div>' +
|
||||
'<div id="roleFallbackBanner"></div>' +
|
||||
'<button class="btn-md btn-primary" id="adminSaveSettings" style="margin-top:16px">Save Settings</button>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
|
||||
|
||||
SCAFFOLDING.extensions =
|
||||
'<div id="adminExtensionsList" class="admin-list"></div>' +
|
||||
'<div id="adminInstallExtForm" class="admin-inline-form" style="display:none">' +
|
||||
'<h4>Install Extension</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>' +
|
||||
'<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>' +
|
||||
'<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0"></div>' +
|
||||
'</div>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor" placeholder="Author"></div>' +
|
||||
'<div class="form-group"><label>System</label>' +
|
||||
'<select id="extInstallSystem"><option value="browser">Browser JS</option><option value="starlark">Starlark</option><option value="sidecar">Sidecar</option></select>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group"><label>Description</label><textarea id="extInstallDesc" rows="2" placeholder="What it does..."></textarea></div>' +
|
||||
'<div class="form-group"><label>Manifest JSON</label><textarea id="extInstallManifest" rows="4"></textarea></div>' +
|
||||
'<div class="form-group"><label>Script Source</label><textarea id="extInstallScript" rows="6"></textarea></div>' +
|
||||
'<label class="checkbox-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>' +
|
||||
'<div class="form-row" style="margin-top:8px">' +
|
||||
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
|
||||
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// -- Routing ----------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.health =
|
||||
'<div style="margin-bottom:12px"><button class="btn-small" id="adminHealthRefreshBtn">Refresh</button></div>' +
|
||||
'<div id="adminHealthContent"><div class="loading">Loading...</div></div>';
|
||||
|
||||
SCAFFOLDING.routing =
|
||||
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
|
||||
'<div id="adminRoutingPolicyList" class="admin-list"></div>' +
|
||||
'<div id="adminRoutingForm" class="admin-inline-form" style="display:none"></div>' +
|
||||
'<div class="settings-section" style="margin-top:24px"><h4>Test Routing</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group"><label>Model</label><input type="text" id="routingTestModel" placeholder="gpt-4o"></div>' +
|
||||
'<div class="form-group"><label>User ID</label><input type="text" id="routingTestUser" placeholder="optional"></div>' +
|
||||
'<button class="btn-small btn-primary" id="routingTestBtn" style="align-self:flex-end">Test</button>' +
|
||||
'</div>' +
|
||||
'<pre id="routingTestResult" class="code-block" style="display:none;margin-top:8px;font-size:12px;max-height:200px;overflow:auto"></pre>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.capabilities =
|
||||
'<div id="adminCapabilityList" class="admin-list"><div class="loading">Loading...</div></div>';
|
||||
|
||||
// -- Monitoring -------------------------------------------------------
|
||||
|
||||
SCAFFOLDING.usage =
|
||||
'<div id="adminUsageTotals"></div>' +
|
||||
'<div id="adminUsageResults" style="margin-top:12px"></div>' +
|
||||
'<div id="adminPricingTable" style="margin-top:24px"></div>';
|
||||
|
||||
SCAFFOLDING.audit =
|
||||
'<div class="form-row" style="margin-bottom:12px">' +
|
||||
'<div class="form-group"><label>Action</label><select id="auditFilterAction"><option value="">All</option></select></div>' +
|
||||
'<div class="form-group"><label>Resource</label><select id="auditFilterResource"><option value="">All</option></select></div>' +
|
||||
'</div>' +
|
||||
'<div id="adminAuditList" class="admin-list"><div class="loading">Loading...</div></div>' +
|
||||
'<div id="auditPagination" style="margin-top:12px;display:flex;align-items:center;gap:8px">' +
|
||||
'<button class="btn-small" id="auditPrevBtn">← Prev</button>' +
|
||||
'<span id="auditPageInfo" class="text-muted" style="font-size:12px"></span>' +
|
||||
'<button class="btn-small" id="auditNextBtn">Next →</button>' +
|
||||
'</div>';
|
||||
|
||||
SCAFFOLDING.stats =
|
||||
'<div id="adminStats"><div class="loading">Loading...</div></div>';
|
||||
|
||||
|
||||
// === Add button actions ==============================================
|
||||
|
||||
var ADD_ACTIONS = {
|
||||
users: function() {
|
||||
var f = document.getElementById('adminAddUserForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
teams: function() {
|
||||
var f = document.getElementById('adminAddTeamForm');
|
||||
if (f) f.style.display = '';
|
||||
var n = document.getElementById('adminTeamName');
|
||||
if (n) n.focus();
|
||||
},
|
||||
providers: function() {
|
||||
var f = document.getElementById('adminAddProviderForm');
|
||||
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode();
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
personas: function() {
|
||||
if (typeof ensureAdminPersonaForm === 'function') {
|
||||
var form = ensureAdminPersonaForm();
|
||||
if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
|
||||
}
|
||||
var f = document.getElementById('adminAddPersonaForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
groups: function() {
|
||||
var f = document.getElementById('adminAddGroupForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
extensions: function() {
|
||||
var f = document.getElementById('adminInstallExtForm');
|
||||
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// === Init on DOMContentLoaded ========================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('adminContentBody');
|
||||
if (!el) return;
|
||||
var section = el.dataset.section;
|
||||
if (!section) return;
|
||||
|
||||
// -- Category / nav resolution -----------
|
||||
var catMap = {
|
||||
users:'people', teams:'people', groups:'people',
|
||||
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
|
||||
health:'routing', routing:'routing', capabilities:'routing',
|
||||
settings:'system', storage:'system', extensions:'system',
|
||||
usage:'monitoring', audit:'monitoring', stats:'monitoring',
|
||||
};
|
||||
var cat = catMap[section] || 'people';
|
||||
var secMap = {
|
||||
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
|
||||
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
|
||||
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
|
||||
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'}],
|
||||
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
|
||||
};
|
||||
|
||||
// Rebuild left nav for current category
|
||||
var navEl = document.getElementById('adminNav');
|
||||
var base = window.__BASE__ || '';
|
||||
if (navEl && secMap[cat]) {
|
||||
navEl.innerHTML = secMap[cat].map(function(s) {
|
||||
return '<a href="' + base + '/admin/' + s.id + '" class="admin-nav-link' + (s.id === section ? ' active' : '') + '">' + s.l + '</a>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Update section title
|
||||
var titleEl = document.getElementById('adminSectionTitle');
|
||||
var curSec = secMap[cat] && secMap[cat].find(function(s) { return s.id === section; });
|
||||
if (titleEl && curSec) titleEl.textContent = curSec.l;
|
||||
|
||||
// -- Inject scaffolding ------------------
|
||||
var target = document.getElementById('adminDynamic');
|
||||
if (target && SCAFFOLDING[section]) {
|
||||
target.innerHTML = SCAFFOLDING[section];
|
||||
} else if (target) {
|
||||
target.innerHTML = '<div class="empty-hint">Section: ' + section + '</div>';
|
||||
}
|
||||
|
||||
// -- Show/wire Add button for sections that have one --
|
||||
var addBtn = document.getElementById('adminAddBtn');
|
||||
if (addBtn && ADD_ACTIONS[section]) {
|
||||
addBtn.style.display = '';
|
||||
addBtn.addEventListener('click', ADD_ACTIONS[section]);
|
||||
}
|
||||
|
||||
// -- Wire install extension cancel -------
|
||||
var extCancel = document.getElementById('adminInstallExtCancelBtn');
|
||||
if (extCancel) {
|
||||
extCancel.addEventListener('click', function() {
|
||||
document.getElementById('adminInstallExtForm').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
|
||||
if (typeof _initAdminListeners === 'function') _initAdminListeners();
|
||||
|
||||
// -- Call the section data loader --------
|
||||
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) {
|
||||
ADMIN_LOADERS[section]();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -311,10 +311,10 @@ const API = {
|
||||
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||
},
|
||||
|
||||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId, disabledTools) {
|
||||
async streamRegenerate(channelId, messageId, signal, model, personaId, apiConfigId, disabledTools) {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
if (personaId) body.persona_id = personaId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||||
|
||||
@@ -370,11 +370,11 @@ const API = {
|
||||
|
||||
// ── Completions ──────────────────────────
|
||||
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds, disabledTools) {
|
||||
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, attachmentIds, disabledTools) {
|
||||
const body = { channel_id: channelId, content, stream: true };
|
||||
if (presetId) {
|
||||
body.preset_id = presetId;
|
||||
// Preset resolves the model on backend; still pass model for channel metadata
|
||||
if (personaId) {
|
||||
body.persona_id = personaId;
|
||||
// Persona resolves the model on backend; still pass model for channel metadata
|
||||
if (model) body.model = model;
|
||||
} else {
|
||||
if (model) body.model = model;
|
||||
@@ -434,14 +434,14 @@ const API = {
|
||||
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
|
||||
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
|
||||
|
||||
// User presets
|
||||
listUserPresets() { return this._get('/api/v1/presets'); },
|
||||
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||
getUserPresetKBs(presetId) { return this._get(`/api/v1/presets/${presetId}/knowledge-bases`); },
|
||||
setUserPresetKBs(presetId, data) { return this._put(`/api/v1/presets/${presetId}/knowledge-bases`, data); },
|
||||
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||
listAllModels() { return this._get('/api/v1/models'); },
|
||||
// User personas
|
||||
listUserPersonas() { return this._get('/api/v1/personas'); },
|
||||
createUserPersona(persona) { return this._post('/api/v1/personas', persona); },
|
||||
getUserPersonaKBs(personaId) { return this._get(`/api/v1/personas/${personaId}/knowledge-bases`); },
|
||||
setUserPersonaKBs(personaId, data) { return this._put(`/api/v1/personas/${personaId}/knowledge-bases`, data); },
|
||||
updateUserPersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
|
||||
deleteUserPersona(id) { return this._del(`/api/v1/personas/${id}`); },
|
||||
listAllModels() { return this._get('/api/v1/models/enabled'); },
|
||||
|
||||
// ── API Configs (user providers) ─────────
|
||||
|
||||
@@ -519,15 +519,15 @@ const API = {
|
||||
},
|
||||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||
|
||||
// ── Admin Presets ────────────────────────
|
||||
adminListPresets() { return this._get('/api/v1/admin/presets'); },
|
||||
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
|
||||
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
||||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||||
adminGetPresetKBs(presetId) { return this._get(`/api/v1/admin/presets/${presetId}/knowledge-bases`); },
|
||||
adminSetPresetKBs(presetId, data) { return this._put(`/api/v1/admin/presets/${presetId}/knowledge-bases`, data); },
|
||||
// ── Admin Personas ────────────────────────
|
||||
adminListPersonas() { return this._get('/api/v1/admin/personas'); },
|
||||
adminCreatePersona(persona) { return this._post('/api/v1/admin/personas', persona); },
|
||||
adminUpdatePersona(id, updates) { return this._put(`/api/v1/admin/personas/${id}`, updates); },
|
||||
adminDeletePersona(id) { return this._del(`/api/v1/admin/personas/${id}`); },
|
||||
adminUploadPersonaAvatar(id, base64Image) { return this._post(`/api/v1/admin/personas/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePersonaAvatar(id) { return this._del(`/api/v1/admin/personas/${id}/avatar`); },
|
||||
adminPersonaKBs(personaId) { return this._get(`/api/v1/admin/personas/${personaId}/knowledge-bases`); },
|
||||
adminSetPersonaKBs(personaId, data) { return this._put(`/api/v1/admin/personas/${personaId}/knowledge-bases`, data); },
|
||||
|
||||
// ── Admin Teams ─────────────────────────
|
||||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||
@@ -591,11 +591,11 @@ const API = {
|
||||
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
|
||||
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
|
||||
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
||||
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
||||
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||||
teamGetPresetKBs(teamId, presetId) { return this._get(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`); },
|
||||
teamSetPresetKBs(teamId, presetId, data) { return this._put(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`, data); },
|
||||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||
teamListPersonas(teamId) { return this._get(`/api/v1/teams/${teamId}/personas`); },
|
||||
teamCreatePersona(teamId, persona) { return this._post(`/api/v1/teams/${teamId}/personas`, persona); },
|
||||
teamGetPersonaKBs(teamId, personaId) { return this._get(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`); },
|
||||
teamSetPersonaKBs(teamId, personaId, data) { return this._put(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`, data); },
|
||||
teamDeletePersona(teamId, personaId) { return this._del(`/api/v1/teams/${teamId}/personas/${personaId}`); },
|
||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
|
||||
|
||||
@@ -616,11 +616,11 @@ const API = {
|
||||
},
|
||||
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
|
||||
|
||||
// ── User Presets ─────────────────────────
|
||||
listPresets() { return this._get('/api/v1/presets'); },
|
||||
createPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||
// ── User Personas ─────────────────────────
|
||||
listPersonas() { return this._get('/api/v1/personas'); },
|
||||
createPersona(persona) { return this._post('/api/v1/personas', persona); },
|
||||
updatePersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
|
||||
deletePersona(id) { return this._del(`/api/v1/personas/${id}`); },
|
||||
|
||||
// ── Notes ────────────────────────────────
|
||||
listNotes(limit = 50, offset = 0, folder, tag, sort) {
|
||||
|
||||
101
src/js/app-state.js
Normal file
101
src/js/app-state.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Shared Application State
|
||||
// ==========================================
|
||||
// Loaded by base.html for ALL surfaces. Contains the App state
|
||||
// object and universal data-loading functions (models, settings).
|
||||
// Surface-specific logic lives in app.js (chat), admin-scaffold.js, etc.
|
||||
|
||||
const App = {
|
||||
chats: [],
|
||||
currentChatId: null,
|
||||
models: [],
|
||||
hiddenModels: new Set(),
|
||||
isGenerating: false,
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
policies: {},
|
||||
stagedAttachments: [],
|
||||
channelAttachments: {},
|
||||
storageConfigured: false,
|
||||
projects: [],
|
||||
collapsedProjects: {},
|
||||
activeProjectId: null,
|
||||
showArchivedProjects: false,
|
||||
|
||||
findModel(id) {
|
||||
if (!id) return null;
|
||||
let m = App.models.find(m => m.id === id);
|
||||
if (m) return m;
|
||||
return App.models.find(m => m.baseModelId === id) || null;
|
||||
},
|
||||
|
||||
settings: {
|
||||
model: '',
|
||||
stream: true,
|
||||
showThinking: false,
|
||||
systemPrompt: '',
|
||||
maxTokens: 0,
|
||||
temperature: 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Capability Resolution ──────────────────
|
||||
// Backend is source of truth. Frontend passes through as-is.
|
||||
function resolveCapabilities(backendCaps, modelId) {
|
||||
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
|
||||
}
|
||||
|
||||
// ── Model Loading ──────────────────────────
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
App.defaultModel = data.default_model || '';
|
||||
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
App.hiddenModels = new Set(
|
||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
||||
);
|
||||
} catch (e) {
|
||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||
}
|
||||
|
||||
App.models = (data.models || []).map(m => {
|
||||
const isPersona = !!m.is_persona;
|
||||
const baseModelId = m.model_id || m.id;
|
||||
const id = isPersona
|
||||
? (m.persona_id || m.id)
|
||||
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
|
||||
return {
|
||||
id,
|
||||
baseModelId,
|
||||
name: m.display_name || baseModelId,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || m.provider_config_id || null,
|
||||
model_type: m.model_type || 'chat',
|
||||
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
||||
isPersona,
|
||||
personaId: m.persona_id || null,
|
||||
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
|
||||
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
|
||||
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
|
||||
source: m.source || (isPersona ? 'persona' : 'global'),
|
||||
teamName: m.persona_team_name || null,
|
||||
hidden: !isPersona && App.hiddenModels.has(baseModelId),
|
||||
};
|
||||
});
|
||||
|
||||
const scopeOrder = { global: 0, team: 1, personal: 2 };
|
||||
App.models.sort((a, b) => {
|
||||
if (a.isPersona && !b.isPersona) return -1;
|
||||
if (!a.isPersona && b.isPersona) return 1;
|
||||
if (a.isPersona && b.isPersona) {
|
||||
const sa = scopeOrder[a.personaScope] ?? 9;
|
||||
const sb = scopeOrder[b.personaScope] ?? 9;
|
||||
if (sa !== sb) return sa - sb;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPersona).length} personas, ${App.models.filter(m => m.hidden).length} hidden)`);
|
||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||
}
|
||||
123
src/js/app.js
123
src/js/app.js
@@ -1,119 +1,14 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Application
|
||||
// Chat Switchboard – Chat Surface Application
|
||||
// ==========================================
|
||||
// State, init, boot, auth, listener dispatch.
|
||||
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
|
||||
// admin-handlers.js. UI rendering in ui-*.js files.
|
||||
|
||||
const App = {
|
||||
chats: [],
|
||||
currentChatId: null,
|
||||
models: [],
|
||||
hiddenModels: new Set(),
|
||||
isGenerating: false,
|
||||
abortController: null,
|
||||
serverSettings: {},
|
||||
policies: {},
|
||||
stagedAttachments: [],
|
||||
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
||||
storageConfigured: false,
|
||||
projects: [], // v0.19.0: loaded from /api/v1/projects
|
||||
collapsedProjects: {}, // projectId → bool — sidebar collapse state
|
||||
activeProjectId: null, // v0.19.1: new chats auto-assign to this project
|
||||
showArchivedProjects: false, // v0.19.2: toggle archived projects in sidebar
|
||||
|
||||
// Find model by composite ID, with fallback to bare model_id match
|
||||
findModel(id) {
|
||||
if (!id) return null;
|
||||
// Exact match on composite ID (configId:modelId)
|
||||
let m = App.models.find(m => m.id === id);
|
||||
if (m) return m;
|
||||
// Fallback: bare model_id (backward compat with stored settings)
|
||||
return App.models.find(m => m.baseModelId === id) || null;
|
||||
},
|
||||
|
||||
settings: {
|
||||
model: '',
|
||||
stream: true,
|
||||
showThinking: false,
|
||||
systemPrompt: '',
|
||||
maxTokens: 0, // 0 = auto from model capabilities
|
||||
temperature: 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Models ───────────────────────────────────
|
||||
|
||||
// ── Capability Resolution ───────────────────────────────────────
|
||||
// The backend is the source of truth for model capabilities.
|
||||
// Resolution chain on the server: catalog (provider API sync) → heuristic.
|
||||
// The frontend does NOT maintain a static model table — the same model
|
||||
// can have different capabilities on different providers (e.g. DeepSeek
|
||||
// on Venice has no tool_calling, same model on OpenRouter does).
|
||||
|
||||
// resolveCapabilities returns backend caps as-is. No client-side override.
|
||||
function resolveCapabilities(backendCaps, modelId) {
|
||||
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const data = await API.listEnabledModels();
|
||||
App.defaultModel = data.default_model || '';
|
||||
// Load user model preferences
|
||||
try {
|
||||
const prefData = await API.getModelPreferences();
|
||||
App.hiddenModels = new Set(
|
||||
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
|
||||
);
|
||||
} catch (e) {
|
||||
// Keep existing preferences on failure (auth dead, network issue).
|
||||
// Only init to empty if there's nothing to preserve.
|
||||
if (!App.hiddenModels) App.hiddenModels = new Set();
|
||||
}
|
||||
|
||||
App.models = (data.models || []).map(m => {
|
||||
const isPreset = !!m.is_preset;
|
||||
const baseModelId = m.model_id || m.id;
|
||||
// Presets use preset_id; base models use configId:modelId to avoid
|
||||
// collisions when the same model exists across team/personal/global providers
|
||||
const id = isPreset
|
||||
? (m.preset_id || m.id)
|
||||
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
|
||||
return {
|
||||
id,
|
||||
baseModelId,
|
||||
name: m.display_name || baseModelId,
|
||||
provider: m.provider_name || m.provider || '',
|
||||
configId: m.config_id || m.provider_config_id || null,
|
||||
model_type: m.model_type || 'chat',
|
||||
capabilities: resolveCapabilities(m.capabilities, baseModelId),
|
||||
isPreset,
|
||||
presetId: m.preset_id || m.persona_id || null,
|
||||
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
|
||||
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
|
||||
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
|
||||
source: m.source || (isPreset ? 'preset' : 'global'),
|
||||
teamName: m.preset_team_name || null,
|
||||
hidden: !isPreset && App.hiddenModels.has(baseModelId),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: presets first (global > team > personal), then regular models
|
||||
const scopeOrder = { global: 0, team: 1, personal: 2 };
|
||||
App.models.sort((a, b) => {
|
||||
if (a.isPreset && !b.isPreset) return -1;
|
||||
if (!a.isPreset && b.isPreset) return 1;
|
||||
if (a.isPreset && b.isPreset) {
|
||||
const sa = scopeOrder[a.presetScope] ?? 9;
|
||||
const sb = scopeOrder[b.presetScope] ?? 9;
|
||||
if (sa !== sb) return sa - sb;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`);
|
||||
} catch (e) { console.warn('Model fetch failed:', e.message); }
|
||||
// Chat-specific init, boot, auth, listener dispatch.
|
||||
// App state, fetchModels, resolveCapabilities defined in app-state.js.
|
||||
// Domain logic in chat.js, notes.js, settings-handlers.js, admin-handlers.js.
|
||||
|
||||
// ── Chat Surface Model Load ────────────────
|
||||
// Wraps shared fetchModels() with chat-specific UI updates.
|
||||
async function fetchModelsAndUpdateUI() {
|
||||
await fetchModels();
|
||||
UI.updateModelSelector();
|
||||
UI.updateCapabilityBadges();
|
||||
}
|
||||
@@ -185,7 +80,7 @@ async function startApp() {
|
||||
await loadChats();
|
||||
await loadProjects();
|
||||
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
|
||||
await fetchModels();
|
||||
await fetchModelsAndUpdateUI();
|
||||
|
||||
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
|
||||
// kick back to login instead of rendering a broken UI.
|
||||
|
||||
@@ -106,7 +106,7 @@ const ChannelModels = {
|
||||
|
||||
// Filter out models already in the roster
|
||||
const rosterModelIds = new Set(this._roster.map(m => m.model_id));
|
||||
const available = App.models.filter(m => !m.isPreset && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
|
||||
const available = App.models.filter(m => !m.isPersona && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
|
||||
|
||||
if (available.length === 0) {
|
||||
UI.toast('All available models are already added to this channel', 'info');
|
||||
|
||||
162
src/js/chat-pane.js
Normal file
162
src/js/chat-pane.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - ChatPane Component
|
||||
// ==========================================
|
||||
// v0.22.7: Mountable, self-contained chat pane instances.
|
||||
// Replaces the hardcoded singleton pattern.
|
||||
// ChatPane.primary is the backward-compat bridge.
|
||||
|
||||
const ChatPane = {
|
||||
primary: null,
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
||||
const instance = {
|
||||
id,
|
||||
messagesEl: opts.messagesEl,
|
||||
inputEl: opts.inputEl,
|
||||
sendBtnEl: opts.sendBtnEl || null,
|
||||
modelSelEl: opts.modelSelEl || null,
|
||||
toolbarEl: opts.toolbarEl || null,
|
||||
channelId: opts.channelId || null,
|
||||
standalone: opts.standalone || false,
|
||||
_cmView: null,
|
||||
_typing: null,
|
||||
_listeners: [],
|
||||
|
||||
renderMessages(messages) {
|
||||
if (!this.messagesEl) return;
|
||||
if (typeof UI !== 'undefined' && UI._renderMessages) {
|
||||
UI._renderMessages(messages, this);
|
||||
} else {
|
||||
this.messagesEl.innerHTML = '';
|
||||
(messages || []).forEach(m => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--' + m.role;
|
||||
div.innerHTML = typeof formatMessage === 'function'
|
||||
? formatMessage(m) : _cpEsc(m.content || '');
|
||||
this.messagesEl.appendChild(div);
|
||||
});
|
||||
}
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
appendChunk(html) {
|
||||
if (!this.messagesEl) return;
|
||||
const last = this.messagesEl.querySelector('.chat-msg--streaming');
|
||||
if (last) {
|
||||
const content = last.querySelector('.chat-msg__content');
|
||||
if (content) content.innerHTML = html;
|
||||
} else {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
|
||||
div.innerHTML = '<div class="chat-msg__content">' + html + '</div>';
|
||||
this.messagesEl.appendChild(div);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
finalizeStream() {
|
||||
if (!this.messagesEl) return;
|
||||
const el = this.messagesEl.querySelector('.chat-msg--streaming');
|
||||
if (el) el.classList.remove('chat-msg--streaming');
|
||||
},
|
||||
|
||||
appendTyping() {
|
||||
if (!this.messagesEl || this._typing) return;
|
||||
this._typing = document.createElement('div');
|
||||
this._typing.className = 'chat-msg chat-msg--typing';
|
||||
this._typing.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
|
||||
this.messagesEl.appendChild(this._typing);
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
removeTyping() {
|
||||
if (this._typing) { this._typing.remove(); this._typing = null; }
|
||||
},
|
||||
|
||||
scrollToBottom(smooth) {
|
||||
if (!this.messagesEl) return;
|
||||
const el = this.messagesEl;
|
||||
if (smooth) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
else el.scrollTop = el.scrollHeight;
|
||||
},
|
||||
|
||||
isScrolledToBottom() {
|
||||
if (!this.messagesEl) return true;
|
||||
const el = this.messagesEl;
|
||||
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 60;
|
||||
},
|
||||
|
||||
setChannel(channelId) { this.channelId = channelId; },
|
||||
getChannel() { return this.channelId; },
|
||||
|
||||
showWelcome(html) {
|
||||
if (!this.messagesEl) return;
|
||||
this.messagesEl.innerHTML = html || '<div class="chat-welcome">' +
|
||||
'<p class="chat-welcome__title">Start a conversation</p>' +
|
||||
'<p class="chat-welcome__hint">Type a message below to get started.</p></div>';
|
||||
},
|
||||
|
||||
clear() {
|
||||
if (this.messagesEl) this.messagesEl.innerHTML = '';
|
||||
this.removeTyping();
|
||||
},
|
||||
|
||||
getInputValue() {
|
||||
if (this._cmView) return this._cmView.state.doc.toString();
|
||||
const ta = this.inputEl && this.inputEl.querySelector('textarea');
|
||||
return ta ? ta.value : '';
|
||||
},
|
||||
|
||||
setInputValue(val) {
|
||||
if (this._cmView) {
|
||||
this._cmView.dispatch({
|
||||
changes: { from: 0, to: this._cmView.state.doc.length, insert: val }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ta = this.inputEl && this.inputEl.querySelector('textarea');
|
||||
if (ta) ta.value = val;
|
||||
},
|
||||
|
||||
clearInput() { this.setInputValue(''); },
|
||||
|
||||
focusInput() {
|
||||
if (this._cmView) this._cmView.focus();
|
||||
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
|
||||
},
|
||||
|
||||
on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
|
||||
this.removeTyping();
|
||||
ChatPane._instances.delete(this.id);
|
||||
if (ChatPane.primary === this) ChatPane.primary = null;
|
||||
},
|
||||
};
|
||||
|
||||
ChatPane._instances.set(id, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
|
||||
forChannel(channelId) {
|
||||
for (const [, inst] of this._instances) {
|
||||
if (inst.channelId === channelId) return inst;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
active() { return this.primary; },
|
||||
};
|
||||
|
||||
function _cpEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
@@ -77,7 +77,7 @@ const ChatInput = {
|
||||
// Hide the textarea, create CM6 editor in its place
|
||||
this._textarea.style.display = 'none';
|
||||
this._editor = CM.chatInput(wrap, {
|
||||
placeholder: 'Send a message...',
|
||||
placeholder: 'Type a message\u2026',
|
||||
onSubmit: () => sendMessage(),
|
||||
onChange: () => {
|
||||
updateInputTokens();
|
||||
@@ -240,7 +240,7 @@ async function selectChat(chatId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the last-used model/preset for this chat
|
||||
// Restore the last-used model/persona for this chat
|
||||
_restoreChatModel(chatId, chat);
|
||||
|
||||
// Load multi-model roster for @mention routing (v0.20.0)
|
||||
@@ -286,7 +286,7 @@ function updateChatTokenCount() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-Chat Model/Preset Persistence ────────
|
||||
// ── Per-Chat Model/Persona Persistence ────────
|
||||
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
|
||||
// localStorage used as write-through cache for instant restore without
|
||||
// waiting for the channel list API call.
|
||||
@@ -345,7 +345,7 @@ function _restoreChatModel(chatId, chat) {
|
||||
|
||||
// 4. Fall back to channel's base model ID (match by baseModelId)
|
||||
if (chat?.model) {
|
||||
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
|
||||
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPersona && !m.hidden);
|
||||
if (match) {
|
||||
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||||
return;
|
||||
@@ -513,9 +513,9 @@ async function sendMessage() {
|
||||
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
// For presets, send the base model ID; for regular models, send the model ID
|
||||
// For personas, send the base model ID; for regular models, send the model ID
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
||||
|
||||
if (!App.currentChatId) {
|
||||
try {
|
||||
@@ -554,7 +554,7 @@ async function sendMessage() {
|
||||
UI.setGenerating(true);
|
||||
|
||||
try {
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds,
|
||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, attachmentIds,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
|
||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||
|
||||
@@ -562,7 +562,7 @@ async function sendMessage() {
|
||||
await reloadActivePath();
|
||||
UI.showRegenerate(true);
|
||||
|
||||
// Persist the model/preset selection for this chat
|
||||
// Persist the model/persona selection for this chat
|
||||
_saveChatModel(App.currentChatId);
|
||||
|
||||
// Auto-name: title the chat after first assistant response (v0.17.0)
|
||||
@@ -636,7 +636,7 @@ async function regenerateMessage(messageId) {
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
||||
|
||||
// Truncate display to the parent of the message being regenerated.
|
||||
// This makes the stream appear in the correct position — as a fresh
|
||||
@@ -657,7 +657,7 @@ async function regenerateMessage(messageId) {
|
||||
try {
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, messageId, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId,
|
||||
model, personaId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
@@ -721,7 +721,7 @@ async function submitEdit(messageId, newContent) {
|
||||
const selectedId = UI.getModelValue();
|
||||
const modelInfo = App.findModel(selectedId);
|
||||
const model = modelInfo?.baseModelId || selectedId;
|
||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
|
||||
|
||||
App.isGenerating = true;
|
||||
App.abortController = new AbortController();
|
||||
@@ -742,7 +742,7 @@ async function submitEdit(messageId, newContent) {
|
||||
// message that streamCompletion would create.
|
||||
const resp = await API.streamRegenerate(
|
||||
App.currentChatId, editResp.id, App.abortController.signal,
|
||||
model, presetId, modelInfo?.configId,
|
||||
model, personaId, modelInfo?.configId,
|
||||
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
|
||||
);
|
||||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||
@@ -875,7 +875,7 @@ function _initChatListeners() {
|
||||
localStorage.setItem('sb_sidebar', '1');
|
||||
});
|
||||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||
await fetchModels();
|
||||
await fetchModelsAndUpdateUI();
|
||||
const visible = App.models.filter(m => !m.hidden).length;
|
||||
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
|
||||
});
|
||||
|
||||
@@ -309,7 +309,7 @@ const MemoryUI = {
|
||||
// Persona Form Memory Fields
|
||||
// ═════════════════════════════════════════
|
||||
|
||||
// Call this after renderPresetForm to append memory fields.
|
||||
// Call this after renderPersonaForm to append memory fields.
|
||||
// container: the form container element
|
||||
// prefix: the form prefix (e.g. 'pf', 'apf')
|
||||
appendMemoryFields(container, prefix) {
|
||||
|
||||
200
src/js/notes.js
200
src/js/notes.js
@@ -744,15 +744,157 @@ function _showSaveToNoteModal(opts) {
|
||||
// ── Notes Listeners (extracted from initListeners) ──
|
||||
|
||||
function _registerNotesPanel() {
|
||||
const el = document.getElementById('sidePanelNotes');
|
||||
if (!el) return;
|
||||
const body = document.getElementById('sidePanelBody');
|
||||
if (!body || typeof PanelRegistry === 'undefined') return;
|
||||
|
||||
// ── Create the panel element dynamically ──
|
||||
const el = document.createElement('div');
|
||||
el.className = 'side-panel-page';
|
||||
el.id = 'sidePanelNotes';
|
||||
el.style.display = 'none';
|
||||
el.innerHTML = `
|
||||
<!-- List view -->
|
||||
<div id="notesListView">
|
||||
<div class="notes-toolbar">
|
||||
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
|
||||
<button id="notesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
|
||||
<button id="notesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
|
||||
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
|
||||
</div>
|
||||
<div class="notes-search-row">
|
||||
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes…" autocomplete="off">
|
||||
</div>
|
||||
<div class="notes-filter-row">
|
||||
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
|
||||
<select id="notesSortSelect" class="notes-filter-select">
|
||||
<option value="updated_desc">Last updated</option>
|
||||
<option value="created_desc">Created</option>
|
||||
<option value="alpha">Alphabetical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="notesList" class="notes-list"></div>
|
||||
<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">
|
||||
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>
|
||||
<span id="notesSelectedCount">0</span> selected
|
||||
<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
|
||||
<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Editor view -->
|
||||
<div id="notesEditorView" style="display:none;">
|
||||
<div class="notes-editor-header">
|
||||
<button id="notesBackBtn" class="btn-small" title="Back to list">← Back</button>
|
||||
<div style="flex:1"></div>
|
||||
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
|
||||
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
|
||||
<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
|
||||
<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
|
||||
<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
<!-- Edit mode -->
|
||||
<div id="noteEditMode" class="notes-editor">
|
||||
<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">
|
||||
<div class="notes-meta-row">
|
||||
<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
|
||||
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
|
||||
</div>
|
||||
<div id="noteEditorContentContainer" class="notes-content-container">
|
||||
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Read mode -->
|
||||
<div id="noteReadMode" style="display:none;">
|
||||
<div class="notes-read-view">
|
||||
<h3 id="noteReadTitle" class="note-read-title"></h3>
|
||||
<div id="noteReadMeta" class="note-read-meta"></div>
|
||||
<div id="noteReadContent" class="note-read-content msg-content"></div>
|
||||
<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Backlinks -->
|
||||
<div id="noteBacklinks" class="note-backlinks" style="display:none;">
|
||||
<div class="note-backlinks-header" onclick="toggleBacklinks()">
|
||||
Backlinks <span id="noteBacklinksCount" class="badge">0</span>
|
||||
</div>
|
||||
<div id="noteBacklinksList" class="note-backlinks-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Graph view -->
|
||||
<div id="notesGraphView" style="display:none;"></div>`;
|
||||
body.appendChild(el);
|
||||
|
||||
// ── Wire listeners ──
|
||||
// Sidebar trigger (lives outside this panel)
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
|
||||
// Toolbar buttons
|
||||
el.querySelector('#notesNewBtn').addEventListener('click', () => openNoteEditor(null));
|
||||
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
|
||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||
});
|
||||
el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote);
|
||||
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
|
||||
if (_notesSelectMode) _exitSelectMode();
|
||||
else _enterSelectMode();
|
||||
});
|
||||
|
||||
// Editor header buttons
|
||||
el.querySelector('#notesBackBtn').addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
||||
el.querySelector('#noteSaveBtn').addEventListener('click', saveNote);
|
||||
el.querySelector('#noteDeleteBtn').addEventListener('click', deleteNote);
|
||||
el.querySelector('#noteDeleteBtn2').addEventListener('click', deleteNote);
|
||||
el.querySelector('#noteEditBtn').addEventListener('click', _showNoteEditMode);
|
||||
el.querySelector('#notePreviewBtn').addEventListener('click', _showNotePreview);
|
||||
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
|
||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||
else showNotesList();
|
||||
});
|
||||
|
||||
// Filters
|
||||
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
|
||||
document.getElementById('notesSearchInput').value = '';
|
||||
_exitSelectMode();
|
||||
loadNotesList(e.target.value);
|
||||
});
|
||||
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
|
||||
_notesSort = e.target.value;
|
||||
_exitSelectMode();
|
||||
loadNotesList();
|
||||
});
|
||||
|
||||
// Multi-select controls
|
||||
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
|
||||
_toggleSelectAll(e.target.checked);
|
||||
});
|
||||
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', _bulkDeleteSelected);
|
||||
el.querySelector('#notesCancelSelectBtn').addEventListener('click', _exitSelectMode);
|
||||
|
||||
// Search with debounce
|
||||
let _notesSearchTimer;
|
||||
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
|
||||
clearTimeout(_notesSearchTimer);
|
||||
const q = e.target.value.trim();
|
||||
_notesSearchTimer = setTimeout(() => {
|
||||
_exitSelectMode();
|
||||
if (q.length >= 2) {
|
||||
document.getElementById('notesFolderFilter').value = '';
|
||||
loadNotesList(null, q);
|
||||
} else if (q.length === 0) {
|
||||
loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// ── Register with PanelRegistry ──
|
||||
PanelRegistry.register('notes', {
|
||||
element: el,
|
||||
label: 'Notes',
|
||||
onOpen() {
|
||||
// Loading is handled by openNotes() which calls loadNotesList
|
||||
},
|
||||
onClose() {
|
||||
_destroyNoteEditor();
|
||||
},
|
||||
saveState() {
|
||||
const listView = document.getElementById('notesListView');
|
||||
const editorView = document.getElementById('notesEditorView');
|
||||
@@ -775,56 +917,8 @@ function _registerNotesPanel() {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated — listeners are now wired in _registerNotesPanel() */
|
||||
function _initNotesListeners() {
|
||||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
||||
document.getElementById('notesGraphBtn')?.addEventListener('click', () => {
|
||||
if (typeof openNoteGraph === 'function') openNoteGraph();
|
||||
});
|
||||
document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote);
|
||||
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
|
||||
if (_notesSelectMode) _exitSelectMode();
|
||||
else _enterSelectMode();
|
||||
});
|
||||
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
||||
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
|
||||
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
||||
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
||||
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
||||
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
||||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||||
else showNotesList();
|
||||
});
|
||||
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
|
||||
document.getElementById('notesSearchInput').value = '';
|
||||
_exitSelectMode();
|
||||
loadNotesList(e.target.value);
|
||||
});
|
||||
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
|
||||
_notesSort = e.target.value;
|
||||
_exitSelectMode();
|
||||
loadNotesList();
|
||||
});
|
||||
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
|
||||
_toggleSelectAll(e.target.checked);
|
||||
});
|
||||
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
|
||||
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
|
||||
|
||||
// Notes search with debounce
|
||||
let _notesSearchTimer;
|
||||
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
|
||||
clearTimeout(_notesSearchTimer);
|
||||
const q = e.target.value.trim();
|
||||
_notesSearchTimer = setTimeout(() => {
|
||||
_exitSelectMode();
|
||||
if (q.length >= 2) {
|
||||
document.getElementById('notesFolderFilter').value = '';
|
||||
loadNotesList(null, q);
|
||||
} else if (q.length === 0) {
|
||||
loadNotesList();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
// No-op: kept for backward compatibility with app.js calling sequence.
|
||||
// All wiring moved into _registerNotesPanel().
|
||||
}
|
||||
|
||||
189
src/js/pages-splash.js
Normal file
189
src/js/pages-splash.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - Splash Surface (pages-splash.js)
|
||||
// ==========================================
|
||||
// v0.22.7: Splash init, login, register, animated grid.
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
var base = window.__BASE__ || '';
|
||||
|
||||
Pages.initSplash = function() {
|
||||
_initAuthTabs();
|
||||
_initLoginForm();
|
||||
_initRegisterForm();
|
||||
_initGridCanvas();
|
||||
if (typeof Theme !== 'undefined') Theme.init();
|
||||
};
|
||||
|
||||
// -- Save tokens via API singleton (sets sb_token cookie) --
|
||||
function _saveAuth(data) {
|
||||
if (typeof API !== 'undefined') {
|
||||
API.accessToken = data.access_token || null;
|
||||
API.refreshToken = data.refresh_token || null;
|
||||
API.user = data.user || null;
|
||||
API.saveTokens();
|
||||
} else {
|
||||
// Fallback: at minimum set the cookie so page auth works
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('token', data.access_token);
|
||||
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Redirect after auth --
|
||||
function _redirectAfterAuth() {
|
||||
// Check for saved redirect (e.g. deep link that bounced to /login)
|
||||
var cookies = document.cookie.split(';');
|
||||
for (var i = 0; i < cookies.length; i++) {
|
||||
var c = cookies[i].trim();
|
||||
if (c.startsWith('redirect_after_login=')) {
|
||||
var dest = decodeURIComponent(c.substring('redirect_after_login='.length));
|
||||
document.cookie = 'redirect_after_login=; path=/; max-age=0';
|
||||
if (dest && dest !== '/login' && dest !== base + '/login') {
|
||||
window.location.href = dest;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.location.href = base + '/';
|
||||
}
|
||||
|
||||
function _initAuthTabs() {
|
||||
var tabs = document.querySelectorAll('.splash-tab');
|
||||
var loginForm = document.getElementById('authLogin');
|
||||
var regForm = document.getElementById('authRegister');
|
||||
if (!tabs.length) return;
|
||||
tabs.forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
tabs.forEach(function(t) { t.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
var target = tab.dataset.tab;
|
||||
if (loginForm) loginForm.style.display = target === 'login' ? '' : 'none';
|
||||
if (regForm) regForm.style.display = target === 'register' ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _initLoginForm() {
|
||||
var btn = document.getElementById('loginBtn');
|
||||
if (!btn) return;
|
||||
var form = document.getElementById('authLogin');
|
||||
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
|
||||
|
||||
btn.addEventListener('click', async function() {
|
||||
var username = _val('loginUsername');
|
||||
var password = _val('loginPassword');
|
||||
var errEl = document.getElementById('loginError');
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Logging in\u2026';
|
||||
try {
|
||||
var resp = await fetch(base + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: username, password: password }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var errData = await resp.json().catch(function() { return {}; });
|
||||
throw new Error(errData.error || 'Invalid credentials');
|
||||
}
|
||||
var data = await resp.json();
|
||||
_saveAuth(data);
|
||||
_redirectAfterAuth();
|
||||
} catch (e) {
|
||||
_showErr(errEl, e.message || 'Login failed');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Log In';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _initRegisterForm() {
|
||||
var btn = document.getElementById('regBtn');
|
||||
if (!btn) return;
|
||||
var avatarEl = document.getElementById('avatarUpload');
|
||||
var avatarFile = null;
|
||||
if (avatarEl && typeof mountAvatarUpload === 'function') {
|
||||
mountAvatarUpload(avatarEl, { onUpload: function(file) { avatarFile = file; } });
|
||||
}
|
||||
var form = document.getElementById('authRegister');
|
||||
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
|
||||
|
||||
btn.addEventListener('click', async function() {
|
||||
var username = _val('regUsername');
|
||||
var displayName = _val('regDisplayName');
|
||||
var email = _val('regEmail');
|
||||
var password = _val('regPassword');
|
||||
var confirm = _val('regConfirm');
|
||||
var errEl = document.getElementById('regError');
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
|
||||
if (password !== confirm) { _showErr(errEl, 'Passwords do not match.'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating account\u2026';
|
||||
try {
|
||||
var body = { username: username, password: password, display_name: displayName, email: email };
|
||||
var resp = await fetch(base + '/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var errData = await resp.json().catch(function() { return {}; });
|
||||
throw new Error(errData.error || 'Registration failed');
|
||||
}
|
||||
var data = await resp.json();
|
||||
// Upload avatar before redirect (token needed for auth)
|
||||
if (avatarFile && data.access_token) {
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('avatar', avatarFile);
|
||||
await fetch(base + '/api/v1/users/me/avatar', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + data.access_token },
|
||||
body: fd,
|
||||
});
|
||||
} catch (e) { console.warn('Avatar upload failed:', e); }
|
||||
}
|
||||
_saveAuth(data);
|
||||
_redirectAfterAuth();
|
||||
} catch (e) {
|
||||
_showErr(errEl, e.message || 'Registration failed');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Account';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _initGridCanvas() {
|
||||
var canvas = document.getElementById('gridCanvas');
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var w, h, offset = 0;
|
||||
function resize() {
|
||||
var rect = canvas.parentElement.getBoundingClientRect();
|
||||
w = canvas.width = rect.width;
|
||||
h = canvas.height = rect.height;
|
||||
}
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
var gap = 40;
|
||||
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#2e2e35';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (var x = (offset % gap); x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
|
||||
for (var y = (offset % gap); y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
offset += 0.15;
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
draw();
|
||||
}
|
||||
|
||||
function _val(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
|
||||
function _showErr(el, msg) { if (!el) return; el.textContent = msg; el.style.display = ''; }
|
||||
})();
|
||||
@@ -76,11 +76,20 @@ const PanelRegistry = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide current active
|
||||
// Hide current active (saves state + fires onClose)
|
||||
if (this._active && this._active !== name) {
|
||||
this._hide(this._active);
|
||||
}
|
||||
|
||||
// Hide ALL panel pages in the body, including unregistered ones
|
||||
// (e.g. sidePanelPreview which is a static placeholder)
|
||||
const body = this._body();
|
||||
if (body) {
|
||||
body.querySelectorAll('.side-panel-page').forEach(el => {
|
||||
if (el !== panel.element) el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Show the secondary pane
|
||||
container.classList.add('open');
|
||||
this._showHandle(true);
|
||||
|
||||
@@ -658,7 +658,7 @@ async function _renderPersonaPicker(currentPersonaId) {
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">None (use model selector)</option>';
|
||||
try {
|
||||
const resp = await API.listPresets();
|
||||
const resp = await API.listPersonas();
|
||||
const personas = resp.data || resp || [];
|
||||
personas.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
|
||||
@@ -200,9 +200,9 @@ async function handleSaveAdminSettings() {
|
||||
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
|
||||
|
||||
// User presets → allow_user_personas policy
|
||||
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
|
||||
// User personas → allow_user_personas policy
|
||||
const userPersonas = document.getElementById('adminUserPersonasToggle').checked;
|
||||
await API.adminUpdateSetting('allow_user_personas', { value: userPersonas ? 'true' : 'false' });
|
||||
|
||||
// Default model → default_model policy
|
||||
const defaultModel = document.getElementById('adminDefaultModel').value;
|
||||
@@ -266,7 +266,7 @@ async function handleSaveAdminSettings() {
|
||||
// Live-apply: refresh policies and dependent UI
|
||||
await initBanners();
|
||||
UI.checkUserProvidersAllowed();
|
||||
UI.checkUserPresetsAllowed();
|
||||
UI.checkUserPersonasAllowed();
|
||||
fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
@@ -544,13 +544,13 @@ function _initSettingsListeners() {
|
||||
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
});
|
||||
var _teamPresetForm = null;
|
||||
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('settingsTeamAddPreset');
|
||||
var _teamPersonaForm = null;
|
||||
document.getElementById('settingsTeamAddPersonaBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('settingsTeamAddPersona');
|
||||
container.style.display = '';
|
||||
if (!_teamPresetForm) {
|
||||
_teamPresetForm = renderPresetForm(container, {
|
||||
prefix: 'teamPreset',
|
||||
if (!_teamPersonaForm) {
|
||||
_teamPersonaForm = renderPersonaForm(container, {
|
||||
prefix: 'teamPersona',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
showKBPicker: true,
|
||||
@@ -561,29 +561,29 @@ function _initSettingsListeners() {
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
try {
|
||||
const result = await API.teamCreatePreset(teamId, vals);
|
||||
const presetId = result?.id;
|
||||
if (presetId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
|
||||
const result = await API.teamCreatePersona(teamId, vals);
|
||||
const personaId = result?.id;
|
||||
if (personaId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('Team persona avatar upload failed:', e.message); }
|
||||
}
|
||||
if (presetId && vals._kbValues) {
|
||||
try { await API.teamSetPresetKBs(teamId, presetId, vals._kbValues); }
|
||||
catch (e) { console.warn('Team preset KB binding failed:', e.message); }
|
||||
if (personaId && vals._kbValues) {
|
||||
try { await API.teamSetPresetKBs(teamId, personaId, vals._kbValues); }
|
||||
catch (e) { console.warn('Team persona KB binding failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_teamPresetForm.clearForm();
|
||||
UI.toast('Team preset created');
|
||||
await UI.loadTeamManagePresets(teamId);
|
||||
_teamPersonaForm.clearForm();
|
||||
UI.toast('Team persona created');
|
||||
await UI.loadTeamManagePersonas(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); }
|
||||
onCancel: () => { container.style.display = 'none'; _teamPersonaForm.clearForm(); }
|
||||
});
|
||||
} else {
|
||||
_teamPresetForm.clearForm();
|
||||
_teamPersonaForm.clearForm();
|
||||
}
|
||||
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
|
||||
UI.loadTeamPersonaModelDropdown(UI._managingTeamId);
|
||||
});
|
||||
|
||||
// Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders)
|
||||
@@ -595,13 +595,13 @@ function _initSettingsListeners() {
|
||||
});
|
||||
|
||||
// User — personal presets
|
||||
var _userPresetForm = null;
|
||||
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('userAddPresetForm');
|
||||
var _userPersonaForm = null;
|
||||
document.getElementById('userAddPersonaBtn')?.addEventListener('click', () => {
|
||||
const container = document.getElementById('userAddPersonaForm');
|
||||
container.style.display = container.style.display === 'none' ? '' : 'none';
|
||||
if (!_userPresetForm) {
|
||||
_userPresetForm = renderPresetForm(container, {
|
||||
prefix: 'userPreset',
|
||||
if (!_userPersonaForm) {
|
||||
_userPersonaForm = renderPersonaForm(container, {
|
||||
prefix: 'userPersona',
|
||||
showAvatar: true,
|
||||
showProviderConfig: false,
|
||||
showKBPicker: true,
|
||||
@@ -610,30 +610,30 @@ function _initSettingsListeners() {
|
||||
if (!vals.name) return UI.toast('Name required', 'error');
|
||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||
try {
|
||||
const result = await API.createUserPreset(vals);
|
||||
const presetId = result?.id;
|
||||
if (presetId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
|
||||
const result = await API.createUserPersona(vals);
|
||||
const personaId = result?.id;
|
||||
if (personaId && vals._pendingAvatar) {
|
||||
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
|
||||
catch (e) { console.warn('User persona avatar upload failed:', e.message); }
|
||||
}
|
||||
if (presetId && vals._kbValues) {
|
||||
try { await API.setUserPresetKBs(presetId, vals._kbValues); }
|
||||
catch (e) { console.warn('User preset KB binding failed:', e.message); }
|
||||
if (personaId && vals._kbValues) {
|
||||
try { await API.setUserPresetKBs(personaId, vals._kbValues); }
|
||||
catch (e) { console.warn('User persona KB binding failed:', e.message); }
|
||||
}
|
||||
container.style.display = 'none';
|
||||
_userPresetForm.clearForm();
|
||||
UI.toast('Preset created');
|
||||
await UI.loadUserPresets();
|
||||
_userPersonaForm.clearForm();
|
||||
UI.toast('Persona created');
|
||||
await UI.loadUserPersonas();
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
},
|
||||
onCancel: () => { container.style.display = 'none'; _userPresetForm.clearForm(); }
|
||||
onCancel: () => { container.style.display = 'none'; _userPersonaForm.clearForm(); }
|
||||
});
|
||||
}
|
||||
const sel = _userPresetForm.getModelSelect();
|
||||
const sel = _userPersonaForm.getModelSelect();
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
App.models.filter(m => !m.isPreset).forEach(m => {
|
||||
App.models.filter(m => !m.isPersona).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
@@ -648,7 +648,7 @@ function _initSettingsListeners() {
|
||||
});
|
||||
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
|
||||
const preset = UI._bannerPresets[e.target.value];
|
||||
if (preset) {
|
||||
if (persona) {
|
||||
document.getElementById('adminBannerText').value = preset.text || '';
|
||||
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
|
||||
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
|
||||
|
||||
43
src/js/surface-nav.js
Normal file
43
src/js/surface-nav.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* surface-nav.js — Navigation overrides for server-rendered surfaces.
|
||||
*
|
||||
* Replaces SPA modal-based openSettings/openAdmin with page navigation
|
||||
* to the dedicated /settings and /admin surface routes.
|
||||
*
|
||||
* Load AFTER ui-core.js and ui-admin.js so it overrides their methods.
|
||||
*
|
||||
* v0.22.8 — FE refactor: surfaces replace modals
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const base = window.__BASE__ || '';
|
||||
|
||||
// ── Settings: navigate to /settings surface ──────────
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.openSettings = function(section) {
|
||||
const sec = section || 'general';
|
||||
window.location.href = base + '/settings/' + sec;
|
||||
};
|
||||
UI.closeSettings = function() {
|
||||
// Navigate back to chat if called from settings surface
|
||||
window.location.href = base + '/';
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin: navigate to /admin surface ────────────────
|
||||
if (typeof UI !== 'undefined') {
|
||||
UI.openAdmin = function(cat, section) {
|
||||
const sec = section || 'users';
|
||||
window.location.href = base + '/admin/' + sec;
|
||||
};
|
||||
UI.closeAdmin = function() {
|
||||
window.location.href = base + '/';
|
||||
};
|
||||
UI.openAdminSection = function(section) {
|
||||
window.location.href = base + '/admin/' + (section || 'users');
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[surface-nav] Navigation overrides active — settings/admin use server surfaces');
|
||||
})();
|
||||
@@ -7,7 +7,7 @@
|
||||
// ── Category → Section mapping ────────────
|
||||
const ADMIN_SECTIONS = {
|
||||
people: ['users', 'teams', 'groups'],
|
||||
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
|
||||
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
|
||||
routing: ['health', 'routing', 'capabilities'],
|
||||
system: ['settings', 'storage', 'extensions'],
|
||||
monitoring: ['usage', 'audit', 'stats'],
|
||||
@@ -29,7 +29,7 @@ const ADMIN_LOADERS = {
|
||||
roles: () => UI.loadAdminRoles(),
|
||||
providers: () => UI.loadAdminProviders(),
|
||||
models: () => UI.loadAdminModels(),
|
||||
presets: () => UI.loadAdminPresets(),
|
||||
personas: () => UI.loadAdminPersonas(),
|
||||
knowledgeBases: () => {
|
||||
if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; }
|
||||
KnowledgeUI.openAdminPanel();
|
||||
@@ -185,7 +185,7 @@ Object.assign(UI, {
|
||||
const teamId = UI._managingTeamId;
|
||||
if (tab === 'members') UI.loadTeamManageMembers(teamId);
|
||||
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
|
||||
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
|
||||
if (tab === 'presets') UI.loadTeamManagePersonas(teamId);
|
||||
if (tab === 'usage') UI.loadTeamUsage();
|
||||
if (tab === 'activity') UI.loadTeamAuditLog(1);
|
||||
if (tab === 'knowledge') {
|
||||
@@ -563,18 +563,18 @@ Object.assign(UI, {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadAdminPresets(quiet) {
|
||||
const el = document.getElementById('adminPresetList');
|
||||
async loadAdminPersonas(quiet) {
|
||||
const el = document.getElementById('adminPersonaList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const data = await API.adminListPresets();
|
||||
const list = data.presets || [];
|
||||
const data = await API.adminListPersonas();
|
||||
const list = data.personas || [];
|
||||
|
||||
// Ensure form is initialized for dropdown population
|
||||
ensureAdminPresetForm();
|
||||
ensureAdminPersonaForm();
|
||||
|
||||
// Populate model dropdown from admin model list (all synced models)
|
||||
const modelSel = _adminPresetForm?.getModelSelect();
|
||||
const modelSel = _adminPersonaForm?.getModelSelect();
|
||||
if (modelSel) {
|
||||
modelSel.innerHTML = '<option value="">Select base model...</option>';
|
||||
try {
|
||||
@@ -588,11 +588,11 @@ Object.assign(UI, {
|
||||
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
|
||||
} catch (e) { console.warn('Failed to load models for persona form:', e.message); }
|
||||
}
|
||||
|
||||
// Populate config dropdown
|
||||
const cfgSel = _adminPresetForm?.getConfigSelect();
|
||||
const cfgSel = _adminPersonaForm?.getConfigSelect();
|
||||
if (cfgSel && cfgSel.options.length <= 1) {
|
||||
try {
|
||||
const cfgData = await API.adminListGlobalConfigs();
|
||||
@@ -606,11 +606,11 @@ Object.assign(UI, {
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
UI._presetCache = {};
|
||||
UI._personaCache = {};
|
||||
el.innerHTML = list.map(p => {
|
||||
UI._presetCache[p.id] = p;
|
||||
UI._personaCache[p.id] = p;
|
||||
const avatarEl = p.avatar
|
||||
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
|
||||
? `<img src="${p.avatar}" class="persona-row-avatar" alt="">`
|
||||
: '';
|
||||
// Scope badge with attribution
|
||||
let scopeBadge = '';
|
||||
@@ -623,20 +623,20 @@ Object.assign(UI, {
|
||||
}
|
||||
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
|
||||
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||
return `<div class="admin-preset-row" data-grant-persona="${p.id}">
|
||||
<div class="preset-info">
|
||||
return `<div class="admin-persona-row" data-grant-persona="${p.id}">
|
||||
<div class="persona-info">
|
||||
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
|
||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
${p.description ? `<div class="persona-desc">${esc(p.description)}</div>` : ''}
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<div class="persona-actions">
|
||||
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona="${p.id}"]')" title="Manage access">🔒 Access</button>
|
||||
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
|
||||
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
<button class="btn-edit" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
|
||||
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPersona('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteAdminPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
|
||||
}).join('') || '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -1185,8 +1185,8 @@ Object.assign(UI, {
|
||||
// User providers / BYOK (policy: allow_user_byok)
|
||||
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
|
||||
|
||||
// User presets / personas (policy: allow_user_personas)
|
||||
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
||||
// User personas / personas (policy: allow_user_personas)
|
||||
document.getElementById('adminUserPersonasToggle').checked = policies.allow_user_personas === 'true';
|
||||
|
||||
// KB direct access (policy: kb_direct_access — v0.17.0)
|
||||
const kbDirectEl = document.getElementById('adminKBDirectAccessToggle');
|
||||
@@ -1196,7 +1196,21 @@ Object.assign(UI, {
|
||||
const defModelSel = document.getElementById('adminDefaultModel');
|
||||
if (defModelSel) {
|
||||
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
|
||||
App.models.filter(m => !m.hidden).forEach(m => {
|
||||
let modelList = typeof App !== 'undefined' && App.models ? App.models : [];
|
||||
// On admin surface, App isn't loaded — fetch from admin API
|
||||
if (modelList.length === 0 && typeof API !== 'undefined' && API.adminListModels) {
|
||||
try {
|
||||
const mr = await API.adminListModels();
|
||||
modelList = (mr.models || mr.data || []).map(m => ({
|
||||
id: m.model_id || m.id,
|
||||
baseModelId: m.model_id || m.id,
|
||||
name: m.display_name || m.model_id || m.id,
|
||||
provider: m.provider_name || '',
|
||||
hidden: !!m.hidden,
|
||||
}));
|
||||
} catch (e) { /* non-critical */ }
|
||||
}
|
||||
modelList.filter(m => !m.hidden).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
@@ -1218,7 +1232,7 @@ Object.assign(UI, {
|
||||
UI.updateBannerPreview();
|
||||
|
||||
// Load banner presets (global_settings)
|
||||
const presets = getSetting('banner_presets', {}) || {};
|
||||
const personas = getSetting('banner_presets', {}) || {};
|
||||
const sel = document.getElementById('adminBannerPreset');
|
||||
sel.innerHTML = '<option value="">Custom</option>';
|
||||
Object.entries(presets).forEach(([key, p]) => {
|
||||
|
||||
@@ -15,22 +15,22 @@ function avatarHTML(role, avatarDataURI) {
|
||||
return role === 'user' ? '👤' : '🤖';
|
||||
}
|
||||
|
||||
// Look up the current model/preset avatar for assistant messages.
|
||||
// Look up the current model/persona 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;
|
||||
// Try to find persona avatar from the model that generated this message
|
||||
const modelId = msg?.model || msg?.persona_id;
|
||||
if (modelId) {
|
||||
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
|
||||
if (m?.presetAvatar) return m.presetAvatar;
|
||||
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
|
||||
if (m?.personaAvatar) return m.personaAvatar;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Shared Preset Form Component ────────────
|
||||
// Renders a preset creation/edit form into a container element.
|
||||
// ── Shared Persona Form Component ────────────
|
||||
// Renders a persona creation/edit form into a container element.
|
||||
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
|
||||
// Returns: { getValues(), setValues(preset), clearForm(), container }
|
||||
function renderPresetForm(containerEl, options = {}) {
|
||||
// Returns: { getValues(), setValues(persona), clearForm(), container }
|
||||
function renderPersonaForm(containerEl, options = {}) {
|
||||
const pfx = options.prefix || 'pf';
|
||||
const showAvatar = options.showAvatar !== false;
|
||||
const showConfig = !!options.showProviderConfig;
|
||||
@@ -164,7 +164,7 @@ function renderPresetForm(containerEl, options = {}) {
|
||||
if (existing) { existing.src = dataURI; }
|
||||
else {
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURI; img.alt = 'Preset avatar';
|
||||
img.src = dataURI; img.alt = 'Persona avatar';
|
||||
preview.insertBefore(img, placeholder);
|
||||
}
|
||||
if (removeBtn) removeBtn.style.display = '';
|
||||
@@ -597,7 +597,7 @@ const UI = {
|
||||
|
||||
const label = modelName || 'Assistant';
|
||||
const currentModel = App.findModel(App.settings.model);
|
||||
const streamAvatar = currentModel?.presetAvatar || null;
|
||||
const streamAvatar = currentModel?.personaAvatar || null;
|
||||
|
||||
// Multi-model state: when model_start events arrive, we create
|
||||
// separate message bubbles per model. Without them, single-bubble.
|
||||
@@ -836,10 +836,10 @@ const UI = {
|
||||
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
|
||||
UI.setModelValue('', 'No models loaded');
|
||||
} else {
|
||||
const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global');
|
||||
const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team');
|
||||
const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal');
|
||||
const models = App.models.filter(m => !m.isPreset && !m.hidden);
|
||||
const globalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'global');
|
||||
const teamPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'team');
|
||||
const personalPresets = App.models.filter(m => m.isPersona && m.personaScope === 'personal');
|
||||
const models = App.models.filter(m => !m.isPersona && !m.hidden);
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
if (items.length === 0) return;
|
||||
@@ -850,22 +850,22 @@ const UI = {
|
||||
items.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
|
||||
};
|
||||
|
||||
addGroup('⚡ Presets', globalPresets);
|
||||
addGroup('⚡ Personas', globalPersonas);
|
||||
|
||||
// Group team presets by team name
|
||||
// Group team personas by team name
|
||||
const teamGroups = {};
|
||||
teamPresets.forEach(m => {
|
||||
const tn = m.presetTeamName || 'Team';
|
||||
teamPersonas.forEach(m => {
|
||||
const tn = m.personaTeamName || 'Team';
|
||||
(teamGroups[tn] = teamGroups[tn] || []).push(m);
|
||||
});
|
||||
Object.keys(teamGroups).sort().forEach(tn => {
|
||||
addGroup(`👥 ${tn}`, teamGroups[tn]);
|
||||
});
|
||||
|
||||
addGroup('🔧 My Presets', personalPresets);
|
||||
addGroup('🔧 My Personas', personalPresets);
|
||||
|
||||
// No team model groups — team providers are for preset building only.
|
||||
// Team members access team models through curated presets.
|
||||
// No team model groups — team providers are for persona building only.
|
||||
// Team members access team models through curated personas.
|
||||
addGroup('Models', models.filter(m => m.source !== 'personal'));
|
||||
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
|
||||
|
||||
@@ -909,7 +909,7 @@ const UI = {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'model-dropdown-item';
|
||||
div.dataset.value = m.id;
|
||||
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
||||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||
div.addEventListener('click', () => {
|
||||
@@ -1027,7 +1027,7 @@ const UI = {
|
||||
if (on) {
|
||||
const container = document.getElementById('chatMessages');
|
||||
const currentModel = App.findModel(App.settings.model);
|
||||
const typingAvatar = currentModel?.presetAvatar || null;
|
||||
const typingAvatar = currentModel?.personaAvatar || null;
|
||||
const typing = document.createElement('div');
|
||||
typing.id = 'typingIndicator';
|
||||
typing.className = 'message assistant';
|
||||
@@ -1084,7 +1084,7 @@ const UI = {
|
||||
UI.loadProfileIntoSettings();
|
||||
UI.loadMyTeams();
|
||||
UI.checkUserProvidersAllowed();
|
||||
UI.checkUserPresetsAllowed();
|
||||
UI.checkUserPersonasAllowed();
|
||||
UI.switchSettingsTab('general');
|
||||
openModal('settingsModal');
|
||||
},
|
||||
@@ -1101,7 +1101,7 @@ const UI = {
|
||||
UI.checkUserProvidersAllowed();
|
||||
}
|
||||
if (tab === 'models') UI.loadUserModels();
|
||||
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
|
||||
if (tab === 'personas') { UI.loadUserPersonas(); UI.checkUserPersonasAllowed(); }
|
||||
if (tab === 'usage') UI.loadMyUsage();
|
||||
if (tab === 'roles') UI.loadUserRoles();
|
||||
if (tab === 'knowledgeBases') {
|
||||
|
||||
199
src/js/ui-primitives-additions.js
Normal file
199
src/js/ui-primitives-additions.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard - UI Primitives Additions
|
||||
// ==========================================
|
||||
// v0.22.7: Toast, Badge, IconBtn, Theme toggle, confirm dialog, avatar upload.
|
||||
// Load AFTER ui-primitives.js in base.html.
|
||||
|
||||
// -- Toast ------------------------------------------------
|
||||
var Toast = {
|
||||
_container: null,
|
||||
_init: function() {
|
||||
if (this._container) return;
|
||||
this._container = document.getElementById('toastContainer');
|
||||
if (!this._container) {
|
||||
this._container = document.createElement('div');
|
||||
this._container.id = 'toastContainer';
|
||||
this._container.className = 'toast-stack';
|
||||
document.body.appendChild(this._container);
|
||||
}
|
||||
},
|
||||
show: function(msg, type) {
|
||||
type = type || 'info';
|
||||
this._init();
|
||||
var el = document.createElement('div');
|
||||
el.className = 'toast toast-' + type;
|
||||
el.innerHTML = '<span class="toast-icon"></span><span class="toast-msg">' + (typeof esc === 'function' ? esc(msg) : msg) + '</span>';
|
||||
this._container.appendChild(el);
|
||||
setTimeout(function() {
|
||||
el.classList.add('toast-exit');
|
||||
setTimeout(function() { el.remove(); }, 300);
|
||||
}, 3000);
|
||||
},
|
||||
success: function(msg) { this.show(msg, 'success'); },
|
||||
error: function(msg) { this.show(msg, 'error'); },
|
||||
warning: function(msg) { this.show(msg, 'warning'); },
|
||||
info: function(msg) { this.show(msg, 'info'); },
|
||||
};
|
||||
|
||||
// -- Badge ------------------------------------------------
|
||||
function renderBadge(text, color) {
|
||||
color = color || 'muted';
|
||||
var e = typeof esc === 'function' ? esc : function(s) { return s; };
|
||||
return '<span class="badge badge-' + e(color) + '">' + e(text) + '</span>';
|
||||
}
|
||||
|
||||
// -- Icon SVG ---------------------------------------------
|
||||
var _ICONS = {
|
||||
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
back: '<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>',
|
||||
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
trash: '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
send: '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
|
||||
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
||||
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>',
|
||||
key: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 010-7.777L12 4l3.5 3.5L18 5l3-3"/>',
|
||||
settings:'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.2.65.7 1.15 1.33 1.34H21a2 2 0 010 4h-.09c-.63.2-1.13.7-1.33 1.34z"/>',
|
||||
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>',
|
||||
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
|
||||
msg: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>',
|
||||
globe: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
|
||||
};
|
||||
|
||||
function renderIcon(name, size) {
|
||||
size = size || 16;
|
||||
var d = _ICONS[name];
|
||||
if (!d) return '';
|
||||
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
|
||||
}
|
||||
|
||||
function renderIconBtn(icon, opts) {
|
||||
opts = opts || {};
|
||||
var title = opts.title || '';
|
||||
var onclick = opts.onclick || '';
|
||||
var cls = opts.cls || '';
|
||||
var size = opts.size || 16;
|
||||
var activeClass = opts.active ? ' active' : '';
|
||||
var e = typeof esc === 'function' ? esc : function(s) { return s; };
|
||||
return '<button class="icon-btn' + activeClass + ' ' + cls + '" title="' + e(title) + '"' +
|
||||
(onclick ? ' onclick="' + onclick + '"' : '') + '>' + renderIcon(icon, size) + '</button>';
|
||||
}
|
||||
|
||||
// -- Theme ------------------------------------------------
|
||||
var Theme = {
|
||||
_key: 'switchboard_theme',
|
||||
init: function() {
|
||||
var saved = localStorage.getItem(this._key) || 'system';
|
||||
this.set(saved);
|
||||
},
|
||||
set: function(mode) {
|
||||
localStorage.setItem(this._key, mode);
|
||||
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
|
||||
else document.documentElement.setAttribute('data-theme', mode);
|
||||
},
|
||||
get: function() { return localStorage.getItem(this._key) || 'system'; },
|
||||
resolved: function() {
|
||||
var mode = this.get();
|
||||
if (mode !== 'system') return mode;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
},
|
||||
renderToggle: function(containerId) {
|
||||
var el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
var current = this.get();
|
||||
var options = [
|
||||
{ id: 'light', label: 'Light', icon: '\u2600' },
|
||||
{ id: 'dark', label: 'Dark', icon: '\u263E' },
|
||||
{ id: 'system',label: 'System',icon: '\u229E' },
|
||||
];
|
||||
var self = this;
|
||||
el.innerHTML = '<div class="theme-toggle">' + options.map(function(o) {
|
||||
return '<button class="theme-toggle__btn' + (current === o.id ? ' active' : '') + '" data-theme="' + o.id + '" title="' + o.label + '">' + o.icon + '</button>';
|
||||
}).join('') + '</div>';
|
||||
el.querySelectorAll('.theme-toggle__btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
self.set(btn.dataset.theme);
|
||||
self.renderToggle(containerId);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// -- Confirm Dialog (enhanced) ----------------------------
|
||||
function showConfirmDialog(opts) {
|
||||
opts = opts || {};
|
||||
var title = opts.title || 'Confirm';
|
||||
var message = opts.message || 'Are you sure?';
|
||||
var confirmLabel = opts.confirmLabel || 'Confirm';
|
||||
var cancelLabel = opts.cancelLabel || 'Cancel';
|
||||
var variant = opts.variant || 'primary';
|
||||
var onConfirm = opts.onConfirm;
|
||||
var onCancel = opts.onCancel;
|
||||
var e = typeof esc === 'function' ? esc : function(s) { return s; };
|
||||
|
||||
var overlay = document.getElementById('confirmOverlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'confirmOverlay';
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.style.display = 'none';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
overlay.innerHTML =
|
||||
'<div class="confirm-dialog">' +
|
||||
'<div class="confirm-dialog__title">' + e(title) + '</div>' +
|
||||
'<div class="confirm-dialog__msg">' + e(message) + '</div>' +
|
||||
'<div class="confirm-dialog__actions">' +
|
||||
'<button class="btn btn-ghost" id="confirmCancelBtn">' + e(cancelLabel) + '</button>' +
|
||||
'<button class="btn btn-' + variant + '" id="confirmOkBtn">' + e(confirmLabel) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
overlay.style.display = 'flex';
|
||||
|
||||
function close() { overlay.style.display = 'none'; overlay.innerHTML = ''; }
|
||||
document.getElementById('confirmCancelBtn').addEventListener('click', function() { close(); if (onCancel) onCancel(); });
|
||||
document.getElementById('confirmOkBtn').addEventListener('click', function() { close(); if (onConfirm) onConfirm(); });
|
||||
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) { close(); if (onCancel) onCancel(); } });
|
||||
}
|
||||
|
||||
// -- Avatar Upload Component ------------------------------
|
||||
function mountAvatarUpload(containerEl, opts) {
|
||||
opts = opts || {};
|
||||
var current = opts.current;
|
||||
var size = opts.size || 80;
|
||||
var onUpload = opts.onUpload;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.style.display = 'none';
|
||||
|
||||
containerEl.className = 'avatar-upload';
|
||||
containerEl.style.width = size + 'px';
|
||||
containerEl.style.height = size + 'px';
|
||||
|
||||
function render(src) {
|
||||
var e = typeof esc === 'function' ? esc : function(s) { return s; };
|
||||
if (src) containerEl.innerHTML = '<img src="' + e(src) + '" alt="Avatar">';
|
||||
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
|
||||
containerEl.appendChild(input);
|
||||
}
|
||||
|
||||
render(current || null);
|
||||
containerEl.addEventListener('click', function() { input.click(); });
|
||||
input.addEventListener('change', function() {
|
||||
var file = input.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
render(ev.target.result);
|
||||
if (onUpload) onUpload(file, ev.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
return { setImage: render };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
|
||||
// Single source of truth for constants + reusable rendering functions.
|
||||
// Loaded after ui-format.js, before ui-core.js.
|
||||
// Follows the renderPresetForm() pattern: (container, options) → control object.
|
||||
// Follows the renderPersonaForm() pattern: (container, options) → control object.
|
||||
// Designed for extension hooks: registries are open for .add(), primitives
|
||||
// accept options for customization, and return handles for programmatic control.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,8 +5,138 @@
|
||||
// providers, usage, model roles, and user preferences.
|
||||
|
||||
Object.assign(UI, {
|
||||
// ── General Settings (Settings Surface) ──
|
||||
|
||||
async loadGeneralSettings() {
|
||||
// Fetch settings from API into App.settings
|
||||
try {
|
||||
const remote = await API.getSettings();
|
||||
if (remote && typeof remote === 'object') {
|
||||
if (remote.model) App.settings.model = remote.model;
|
||||
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
|
||||
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
|
||||
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
|
||||
if (remote.show_thinking !== undefined) App.settings.showThinking = remote.show_thinking;
|
||||
}
|
||||
} catch (e) { console.warn('Settings load failed:', e.message); }
|
||||
|
||||
// Fetch models for the model dropdown
|
||||
await fetchModels();
|
||||
|
||||
// Populate form elements
|
||||
const modelSel = document.getElementById('settingsModel');
|
||||
if (modelSel) {
|
||||
modelSel.innerHTML = '';
|
||||
App.models.filter(m => !m.hidden).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
modelSel.appendChild(opt);
|
||||
});
|
||||
if (App.settings.model) modelSel.value = App.settings.model;
|
||||
}
|
||||
|
||||
const sysEl = document.getElementById('settingsSystemPrompt');
|
||||
if (sysEl) sysEl.value = App.settings.systemPrompt || '';
|
||||
|
||||
const maxEl = document.getElementById('settingsMaxTokens');
|
||||
if (maxEl) maxEl.value = App.settings.maxTokens || '';
|
||||
|
||||
const tempEl = document.getElementById('settingsTemp');
|
||||
const tempVal = document.getElementById('settingsTempValue');
|
||||
if (tempEl) {
|
||||
tempEl.value = App.settings.temperature;
|
||||
if (tempVal) tempVal.textContent = App.settings.temperature;
|
||||
tempEl.addEventListener('input', () => {
|
||||
if (tempVal) tempVal.textContent = tempEl.value;
|
||||
});
|
||||
}
|
||||
|
||||
const thinkEl = document.getElementById('settingsThinking');
|
||||
if (thinkEl) thinkEl.checked = App.settings.showThinking;
|
||||
|
||||
// Model change → update max tokens hint
|
||||
if (modelSel) {
|
||||
modelSel.addEventListener('change', () => {
|
||||
const model = App.findModel(modelSel.value);
|
||||
const caps = model?.capabilities || {};
|
||||
const hint = document.getElementById('settingsMaxHint');
|
||||
if (hint) {
|
||||
hint.textContent = caps.max_output_tokens > 0
|
||||
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)` : '';
|
||||
}
|
||||
});
|
||||
// Trigger once to show current model's hint
|
||||
modelSel.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
// Save button (reuse existing pattern — form auto-saves are not a thing yet)
|
||||
const section = document.querySelector('.settings-section');
|
||||
if (section && !section.querySelector('.settings-save-btn')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-md btn-primary settings-save-btn';
|
||||
btn.textContent = 'Save';
|
||||
btn.style.marginTop = '12px';
|
||||
btn.addEventListener('click', async () => {
|
||||
App.settings.model = modelSel?.value || App.settings.model;
|
||||
App.settings.systemPrompt = sysEl?.value?.trim() || '';
|
||||
App.settings.maxTokens = parseInt(maxEl?.value) || 0;
|
||||
App.settings.temperature = parseFloat(tempEl?.value) || 0.7;
|
||||
App.settings.showThinking = thinkEl?.checked || false;
|
||||
try {
|
||||
await API.updateSettings({
|
||||
model: App.settings.model,
|
||||
system_prompt: App.settings.systemPrompt,
|
||||
max_tokens: App.settings.maxTokens,
|
||||
temperature: App.settings.temperature,
|
||||
show_thinking: App.settings.showThinking,
|
||||
});
|
||||
UI.toast('Settings saved', 'success');
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
section.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Appearance Settings ─────────────────
|
||||
|
||||
saveAppearance() {
|
||||
const scaleEl = document.getElementById('settingsScale');
|
||||
const msgFontEl = document.getElementById('settingsMsgFont');
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
|
||||
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
|
||||
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
|
||||
UI.toast('Appearance saved', 'success');
|
||||
},
|
||||
|
||||
// ── Teams Settings (Settings Surface) ────
|
||||
|
||||
async loadTeamsSettings() {
|
||||
const el = document.getElementById('settingsDynamic');
|
||||
if (!el) return;
|
||||
try {
|
||||
const resp = await API.listMyTeams();
|
||||
const teams = resp.data || [];
|
||||
if (!teams.length) {
|
||||
el.innerHTML = '<div class="empty-hint">You are not a member of any teams.</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<h3 style="margin:0">${esc(t.name)}</h3>
|
||||
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}" style="font-size:11px;">${t.my_role}</span>
|
||||
</div>
|
||||
${t.description ? `<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">${esc(t.description)}</p>` : ''}
|
||||
<div style="font-size:12px;color:var(--text-3);">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty-hint">Failed to load teams.</div>';
|
||||
}
|
||||
},
|
||||
|
||||
loadAppearanceSettings() {
|
||||
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||
const scale = prefs.scale || 100;
|
||||
@@ -125,7 +255,7 @@ Object.assign(UI, {
|
||||
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
|
||||
// Recheck tab overflow after layout settles with new zoom
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
|
||||
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); });
|
||||
});
|
||||
},
|
||||
|
||||
@@ -147,9 +277,9 @@ Object.assign(UI, {
|
||||
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
|
||||
},
|
||||
|
||||
checkUserPresetsAllowed() {
|
||||
const addBtn = document.getElementById('userAddPresetBtn');
|
||||
const addForm = document.getElementById('userAddPresetForm');
|
||||
checkUserPersonasAllowed() {
|
||||
const addBtn = document.getElementById('userAddPersonaBtn');
|
||||
const addForm = document.getElementById('userAddPersonaForm');
|
||||
const tabBtn = document.getElementById('settingsPersonasTabBtn');
|
||||
const allowed = App.policies?.allow_user_personas === 'true';
|
||||
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
|
||||
@@ -227,7 +357,7 @@ Object.assign(UI, {
|
||||
document.getElementById('teamAdminContent').style.display = '';
|
||||
|
||||
// Reset forms (null-safe — not all form containers may exist)
|
||||
['settingsTeamAddMember', 'settingsTeamAddPreset', 'settingsTeamProviderForm'].forEach(id => {
|
||||
['settingsTeamAddMember', 'settingsTeamAddPersona', 'settingsTeamProviderForm'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
@@ -359,22 +489,22 @@ Object.assign(UI, {
|
||||
await UI._teamProvList.refresh();
|
||||
},
|
||||
|
||||
async loadTeamManagePresets(teamId) {
|
||||
const el = document.getElementById('settingsTeamPresets');
|
||||
async loadTeamManagePersonas(teamId) {
|
||||
const el = document.getElementById('settingsTeamPersonas');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListPresets(teamId);
|
||||
const presets = resp.presets || [];
|
||||
el.innerHTML = presets.map(p => {
|
||||
const resp = await API.teamListPersonas(teamId);
|
||||
const personas = resp.personas || [];
|
||||
el.innerHTML = personas.map(p => {
|
||||
const icon = p.icon || '';
|
||||
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||
<div class="preset-info">
|
||||
return `<div class="admin-persona-row" style="padding:6px 0">
|
||||
<div class="persona-info">
|
||||
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
|
||||
<div class="preset-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
|
||||
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
|
||||
</div>
|
||||
<button class="btn-delete" onclick="settingsDeleteTeamPreset('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
|
||||
<button class="btn-delete" onclick="settingsDeleteTeamPersona('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No team presets — create one to give your team preconfigured models</div>';
|
||||
}).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
@@ -534,8 +664,8 @@ Object.assign(UI, {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamPresetModelDropdown(teamId) {
|
||||
const sel = document.getElementById('teamPreset_model');
|
||||
async loadTeamPersonaModelDropdown(teamId) {
|
||||
const sel = document.getElementById('teamPersona_model');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Loading models...</option>';
|
||||
try {
|
||||
@@ -566,7 +696,7 @@ Object.assign(UI, {
|
||||
} catch (e) {
|
||||
// Fallback to App.models if team endpoint fails
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
App.models.filter(m => !m.isPreset).forEach(m => {
|
||||
App.models.filter(m => !m.isPersona).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
@@ -610,7 +740,7 @@ Object.assign(UI, {
|
||||
}
|
||||
|
||||
const data = await API.listEnabledModels();
|
||||
const models = (data.models || []).filter(m => !m.is_preset);
|
||||
const models = (data.models || []).filter(m => !m.is_persona);
|
||||
if (!models.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No models available</div>';
|
||||
return;
|
||||
@@ -635,26 +765,26 @@ Object.assign(UI, {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadUserPresets() {
|
||||
const el = document.getElementById('userPresetList');
|
||||
async loadUserPersonas() {
|
||||
const el = document.getElementById('userPersonaList');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await API.listUserPresets();
|
||||
const data = await API.listUserPersonas();
|
||||
// Only show personal presets owned by user
|
||||
const presets = (data.presets || []).filter(p => p.scope === 'personal');
|
||||
if (!presets.length) {
|
||||
const personas = (data.personas || []).filter(p => p.scope === 'personal');
|
||||
if (!personas.length) {
|
||||
el.innerHTML = '<div class="empty-hint">No personal presets yet</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = presets.map(p => {
|
||||
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="preset-row-avatar" alt="">` : '';
|
||||
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||
<div class="preset-info">
|
||||
el.innerHTML = personas.map(p => {
|
||||
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="persona-row-avatar" alt="">` : '';
|
||||
return `<div class="admin-persona-row" style="padding:6px 0">
|
||||
<div class="persona-info">
|
||||
<strong>${avatarEl}${esc(p.name)}</strong>
|
||||
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<button class="btn-delete" onclick="deleteUserPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
<div class="persona-actions">
|
||||
<button class="btn-delete" onclick="deleteUserPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
Reference in New Issue
Block a user