774 lines
27 KiB
Markdown
774 lines
27 KiB
Markdown
# DESIGN — Surface & Extension Architecture
|
||
|
||
**Status:** Accepted (Phases 1–2 implemented in v0.25.1)
|
||
**Scope:** Primitives, Components, Surfaces, Extension hooks, Themes
|
||
**Depends on:** v0.22.5 (Go template engine), v0.22.7 (ChatPane), v0.25.1 (audit)
|
||
**Informs:** v0.22.8+, EXTENSIONS.md rewrite, ARCHITECTURE.md update, ICD-API, ICD-SURFACE
|
||
**Note:** For current package/surface authoring, see [PACKAGES.md](PACKAGES.md) and [EXTENSION-SURFACES.md](EXTENSION-SURFACES.md). For workflow surfaces, see [WORKFLOW-PACKAGES.md](WORKFLOW-PACKAGES.md).
|
||
|
||
---
|
||
|
||
## 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` (factory functions, `esc()`,
|
||
`createComponentRegistry()`, `componentMixin()`, `showConfirm()`,
|
||
`showPrompt()`, `Theme`, `mountAvatarUpload()`)
|
||
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 `createComponentRegistry()` and `componentMixin()` from
|
||
`ui-primitives.js` for shared lifecycle (instance tracking, event
|
||
listener cleanup, destroy):
|
||
|
||
```js
|
||
const ChatPane = {
|
||
...createComponentRegistry('ChatPane'),
|
||
|
||
create(opts) {
|
||
const instance = componentMixin({
|
||
id: opts.id || 'pane-' + Date.now(),
|
||
messagesEl: opts.messagesEl,
|
||
inputEl: opts.inputEl,
|
||
channelId: opts.channelId || null,
|
||
// ... domain-specific state
|
||
|
||
// Component-specific teardown (called by mixin's destroy())
|
||
_cleanup() {
|
||
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
|
||
},
|
||
}, ChatPane);
|
||
|
||
ChatPane._register(instance.id, instance);
|
||
return instance;
|
||
},
|
||
|
||
// Custom queries beyond .get(id)
|
||
forChannel(channelId) { /* ... */ },
|
||
};
|
||
```
|
||
|
||
The mixin provides `_on(el, event, handler)` for tracked event binding
|
||
and `destroy()` which calls `_cleanup()` then removes all listeners and
|
||
unregisters from the registry. Components that need extra teardown define
|
||
`_cleanup()`.
|
||
|
||
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-surface.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` (merged, with `esc()`, `createComponentRegistry()`, `componentMixin()`) | ✅ Done (v0.25.1) |
|
||
| `ChatPane.create()` + 5 other components using shared mixin | ✅ Done (v0.25.1) |
|
||
| 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** ✅ (v0.25.1)
|
||
- `ui-primitives-additions.js` deleted, survivors merged into
|
||
`ui-primitives.js`
|
||
- `esc()` centralized (13 private copies eliminated)
|
||
- Badge system unified (24 scattered classes → grouped in primitives.css)
|
||
- CSS variable names normalized, duplicate selectors resolved
|
||
- `showConfirm()` is the sole confirm pattern (bare `confirm()` purged)
|
||
|
||
**Phase 2: Component formalization** ✅ (v0.25.1)
|
||
- `createComponentRegistry()` + `componentMixin()` extracted
|
||
- 6 components (ChatPane, ModelSelector, UserMenu, CodeEditor, FileTree,
|
||
NoteEditor) use the shared mixin. ~100 lines of boilerplate eliminated.
|
||
- Each component has a Go template partial + JS hydration
|
||
- `surface-nav.js` deleted; navigation is native in `ui-core.js`
|
||
- Vendor scripts (`marked.min.js`, `purify.min.js`) and `ui-format.js`
|
||
moved to `base.html` (loaded once, available to all surfaces)
|
||
|
||
**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?
|