Changeset 0.22.8 (#150)
This commit is contained in:
@@ -1,584 +0,0 @@
|
||||
# DESIGN-0.15.0 — Compaction
|
||||
|
||||
## Overview
|
||||
|
||||
Automatic conversation compaction. A background service monitors channels
|
||||
for context pressure and triggers summarization via the utility model role,
|
||||
replacing the manual "Summarize & Continue" button as the primary
|
||||
compaction path. Manual summarization remains available as an explicit
|
||||
user action.
|
||||
|
||||
Depends on: utility model role (v0.10.0), summarize handler (v0.10.2).
|
||||
|
||||
**Design principle: don't reinvent the wheel.** The existing
|
||||
`SummarizeHandler` already has the full pipeline — path loading, summary
|
||||
boundary detection, prompt construction, role resolution, tree insertion,
|
||||
cursor update, usage logging. This design extracts that core logic into
|
||||
a shared `compaction` package and adds a background scanner on top.
|
||||
|
||||
---
|
||||
|
||||
## 1. Extract: `compaction` Package
|
||||
|
||||
Move the reusable summarization pipeline out of `handlers/summarize.go`
|
||||
into `server/compaction/compaction.go`. The HTTP handler becomes a thin
|
||||
wrapper.
|
||||
|
||||
### Core type
|
||||
|
||||
```go
|
||||
package compaction
|
||||
|
||||
// Service provides conversation compaction (summarization).
|
||||
// Used by both the HTTP handler (manual) and the background scanner (auto).
|
||||
type Service struct {
|
||||
stores store.Stores
|
||||
resolver *roles.Resolver
|
||||
}
|
||||
|
||||
func NewService(stores store.Stores, resolver *roles.Resolver) *Service {
|
||||
return &Service{stores: stores, resolver: resolver}
|
||||
}
|
||||
```
|
||||
|
||||
### Extracted method: `Compact`
|
||||
|
||||
The current `SummarizeHandler.Summarize` body becomes `Service.Compact`:
|
||||
|
||||
```go
|
||||
type CompactRequest struct {
|
||||
ChannelID string
|
||||
UserID string // channel owner
|
||||
TeamID *string // for role resolution
|
||||
Trigger string // "manual" | "auto"
|
||||
}
|
||||
|
||||
type CompactResult struct {
|
||||
SummaryID string
|
||||
SummarizedCount int
|
||||
Model string
|
||||
UsedFallback bool
|
||||
Content string
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
}
|
||||
|
||||
func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error)
|
||||
```
|
||||
|
||||
The method performs the same steps as today's handler:
|
||||
|
||||
1. Load active path via `getActivePath` (already exported within `handlers`)
|
||||
2. Find summary boundary, collect messages to summarize
|
||||
3. Guard: minimum 4 messages since last summary
|
||||
4. Build summarization prompt
|
||||
5. Call `resolver.Complete(ctx, RoleUtility, ...)`
|
||||
6. Insert summary tree node with metadata
|
||||
7. Update cursor
|
||||
8. Log usage
|
||||
|
||||
The only new metadata field is `"trigger"` (`"manual"` or `"auto"`) so
|
||||
the frontend can distinguish how the summary was created.
|
||||
|
||||
### Refactored handler
|
||||
|
||||
`handlers/summarize.go` shrinks to:
|
||||
|
||||
```go
|
||||
func (h *SummarizeHandler) Summarize(c *gin.Context) {
|
||||
// ownership check, rate limit check (unchanged)
|
||||
// ...
|
||||
result, err := h.compaction.Compact(c.Request.Context(), compaction.CompactRequest{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Trigger: "manual",
|
||||
})
|
||||
// return JSON response (unchanged)
|
||||
}
|
||||
```
|
||||
|
||||
### Tree helpers
|
||||
|
||||
`getActivePath`, `isSummaryMessage`, `nextSiblingIndex`, `updateCursor`
|
||||
are currently unexported in `handlers/tree.go`. Two options:
|
||||
|
||||
**Option A** — Export them from `handlers` and import in `compaction`.
|
||||
Clean but creates a dependency from `compaction` → `handlers`.
|
||||
|
||||
**Option B** — Move tree helpers into a `treepath` (or similar) package
|
||||
imported by both `handlers` and `compaction`.
|
||||
|
||||
Recommend **Option B** to keep the dependency graph clean:
|
||||
`handlers` → `compaction` → `treepath` ← `handlers`. The `treepath`
|
||||
package is pure data + SQL queries with no handler logic.
|
||||
|
||||
```
|
||||
server/
|
||||
treepath/ ← NEW: extracted from handlers/tree.go
|
||||
path.go (getActivePath, getPathToLeaf, getActiveLeaf)
|
||||
summary.go (isSummaryMessage, summary metadata helpers)
|
||||
siblings.go (getSiblingCount, getSiblings, findLeafFromMessage)
|
||||
cursor.go (updateCursor, nextSiblingIndex)
|
||||
compaction/ ← NEW
|
||||
compaction.go (Service, Compact)
|
||||
scanner.go (Scanner, background loop)
|
||||
estimator.go (token estimation)
|
||||
handlers/
|
||||
tree.go → thin wrappers or deleted, imports treepath
|
||||
summarize.go → thin HTTP wrapper, delegates to compaction.Service
|
||||
completion.go → imports treepath for path building
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Server-Side Token Estimation
|
||||
|
||||
The background scanner needs to evaluate context pressure without making
|
||||
an LLM call. The frontend already does this with a `chars / 4` heuristic
|
||||
(`tokens.js`). Replicate server-side:
|
||||
|
||||
```go
|
||||
// compaction/estimator.go
|
||||
|
||||
// EstimateTokens returns a rough token count using the ~4 chars/token
|
||||
// heuristic. Matches the frontend Tokens.estimate() for consistency.
|
||||
func EstimateTokens(text string) int {
|
||||
return (len(text) + 3) / 4
|
||||
}
|
||||
|
||||
// EstimatePath returns total estimated tokens for a message path,
|
||||
// including system prompt overhead.
|
||||
func EstimatePath(path []treepath.PathMessage, systemPromptLen int) int {
|
||||
total := 0
|
||||
if systemPromptLen > 0 {
|
||||
total += EstimateTokens(string(make([]byte, systemPromptLen))) + 4
|
||||
}
|
||||
for _, m := range path {
|
||||
total += EstimateTokens(m.Content) + 4 // +4 per-message overhead
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
Not exact, but it's the same bar the user already sees in the UI. If we
|
||||
need better accuracy later, swap in a tokenizer library without changing
|
||||
the interface.
|
||||
|
||||
### Context budget resolution
|
||||
|
||||
The scanner needs `max_context` for whatever model the channel is using.
|
||||
Resolution chain:
|
||||
|
||||
1. Channel has a `model` field → look up `model_catalog` for capabilities
|
||||
2. Channel has a `provider_config_id` → use that provider's catalog entry
|
||||
3. Fallback: use the utility role's model context window as a proxy (it's
|
||||
the model that would be doing the summarization anyway)
|
||||
4. Hard fallback: 128K tokens (conservative default)
|
||||
|
||||
```go
|
||||
func (s *Scanner) getContextBudget(ctx context.Context, ch *models.Channel) int {
|
||||
if ch.Model != "" {
|
||||
if caps, err := s.stores.Catalog.GetCapabilities(ctx, ch.Model); err == nil {
|
||||
if caps.MaxContext > 0 {
|
||||
return caps.MaxContext
|
||||
}
|
||||
}
|
||||
}
|
||||
return DefaultContextBudget // 128000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Background Scanner
|
||||
|
||||
Follows the Ingester pattern from `knowledge/ingest.go`: goroutine pool
|
||||
with semaphore, WaitGroup for graceful shutdown.
|
||||
|
||||
### Scanner type
|
||||
|
||||
```go
|
||||
// compaction/scanner.go
|
||||
|
||||
type Scanner struct {
|
||||
service *Service
|
||||
stores store.Stores
|
||||
sem chan struct{}
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
ticker *time.Ticker
|
||||
}
|
||||
|
||||
type ScannerConfig struct {
|
||||
Enabled bool // global kill switch
|
||||
Interval time.Duration // scan frequency (default: 5 minutes)
|
||||
Concurrency int // max parallel compactions (default: 2)
|
||||
}
|
||||
|
||||
func NewScanner(svc *Service, stores store.Stores, cfg ScannerConfig) *Scanner
|
||||
func (sc *Scanner) Start()
|
||||
func (sc *Scanner) Stop() // signals stop + drains in-flight
|
||||
```
|
||||
|
||||
### Scan loop
|
||||
|
||||
```
|
||||
every <interval>:
|
||||
if !globalEnabled(): continue
|
||||
|
||||
candidates = findCandidates()
|
||||
for each candidate:
|
||||
sem.acquire()
|
||||
go compact(candidate)
|
||||
```
|
||||
|
||||
### Candidate query
|
||||
|
||||
A single SQL query finds channels that need compaction:
|
||||
|
||||
```sql
|
||||
SELECT c.id, c.user_id, c.model, c.settings,
|
||||
COUNT(m.id) as msg_count,
|
||||
SUM(LENGTH(m.content)) as total_chars
|
||||
FROM channels c
|
||||
JOIN messages m ON m.channel_id = c.id
|
||||
AND m.deleted_at IS NULL
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND c.type = 'direct' -- only user conversations (not groups yet)
|
||||
AND c.is_archived = false
|
||||
-- Skip channels with very recent activity (let the user finish typing)
|
||||
AND c.updated_at < NOW() - INTERVAL '2 minutes'
|
||||
-- Only channels active in the last 7 days (don't compact stale channels)
|
||||
AND c.updated_at > NOW() - INTERVAL '7 days'
|
||||
GROUP BY c.id
|
||||
HAVING COUNT(m.id) >= 10 -- minimum message count
|
||||
AND SUM(LENGTH(m.content)) > 20000 -- rough: 20K chars ≈ 5K tokens minimum
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 50 -- batch size cap
|
||||
```
|
||||
|
||||
This is a coarse filter. Each candidate then gets a precise check:
|
||||
|
||||
```go
|
||||
func (sc *Scanner) shouldCompact(ctx context.Context, ch *models.Channel) bool {
|
||||
// 1. Check channel-level opt-out
|
||||
if ch.Settings != nil {
|
||||
if v, ok := ch.Settings["auto_compaction"]; ok && v == false {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Load active path for the channel owner
|
||||
path, err := treepath.GetActivePath(ch.ID, ch.UserID)
|
||||
if err != nil || len(path) < 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Find post-summary messages only
|
||||
startIdx := 0
|
||||
for i, m := range path {
|
||||
if treepath.IsSummaryMessage(&m) {
|
||||
startIdx = i + 1
|
||||
}
|
||||
}
|
||||
postSummary := path[startIdx:]
|
||||
if len(postSummary) < 8 {
|
||||
return false // not enough new messages since last summary
|
||||
}
|
||||
|
||||
// 4. Estimate tokens and check threshold
|
||||
tokens := EstimatePath(postSummary, 0)
|
||||
budget := sc.getContextBudget(ctx, ch)
|
||||
threshold := sc.getThreshold(ch) // default 0.70
|
||||
|
||||
return float64(tokens) / float64(budget) >= threshold
|
||||
}
|
||||
```
|
||||
|
||||
### Threshold
|
||||
|
||||
Default: **0.70** (70% of context window). This is intentionally lower
|
||||
than the frontend's 75% warning bar — auto-compaction should fire before
|
||||
the user sees a warning, not after.
|
||||
|
||||
Configurable per-channel via `settings.compaction_threshold` (float) and
|
||||
globally via `global_settings` key `auto_compaction_threshold`.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
Auto-compaction calls use the utility role and are org-funded. They share
|
||||
the existing `utility_rate_limit` budget but get a separate counter
|
||||
prefix so admins can see auto vs manual usage:
|
||||
|
||||
```go
|
||||
entry := &models.UsageEntry{
|
||||
// ...
|
||||
Role: &role, // "utility"
|
||||
Metadata: models.JSONMap{
|
||||
"trigger": "auto", // distinguishes from manual
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If the global utility rate limit is hit, auto-compaction silently skips
|
||||
the channel and retries next scan cycle. It never blocks or errors.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-Channel Configuration
|
||||
|
||||
Uses the existing `Channel.Settings` JSONB column. No migration needed.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `auto_compaction` | `bool` | `true` (from global) | Enable/disable for this channel |
|
||||
| `compaction_threshold` | `float` | `0.70` (from global) | Context usage ratio to trigger |
|
||||
|
||||
### Frontend: channel settings
|
||||
|
||||
Add a "Compaction" section to the channel settings panel (the gear icon
|
||||
in the chat header). Two controls:
|
||||
|
||||
```
|
||||
┌─ Compaction ──────────────────────────────────┐
|
||||
│ Auto-compact [✓] │
|
||||
│ Threshold [70]% of context window │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The manual "Summarize & Continue" button remains in the context warning
|
||||
bar and works regardless of auto-compaction settings.
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin Controls
|
||||
|
||||
New `global_settings` keys:
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `auto_compaction_enabled` | `bool` | `false` | Global kill switch. Ships **off** — admins opt in. |
|
||||
| `auto_compaction_threshold` | `float` | `0.70` | Default threshold for channels without override |
|
||||
| `auto_compaction_interval_minutes` | `int` | `5` | Scanner tick interval |
|
||||
| `auto_compaction_concurrency` | `int` | `2` | Max parallel auto-compactions |
|
||||
| `auto_compaction_cooldown_minutes` | `int` | `30` | Min time between auto-compactions of the same channel |
|
||||
|
||||
### Admin UI
|
||||
|
||||
Add an "Auto-Compaction" card to the existing admin AI settings panel
|
||||
(alongside the utility rate limit and model roles):
|
||||
|
||||
```
|
||||
┌─ Auto-Compaction ─────────────────────────────┐
|
||||
│ Enabled [✓] │
|
||||
│ Threshold [70]% │
|
||||
│ Scan interval [5] minutes │
|
||||
│ Concurrency [2] parallel │
|
||||
│ Cooldown [30] minutes per channel │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Cooldown + Dedup
|
||||
|
||||
Prevent rapid re-compaction of the same channel:
|
||||
|
||||
1. **Cooldown** — after compacting channel X, don't re-evaluate it for
|
||||
`cooldown_minutes`. Track in-memory:
|
||||
```go
|
||||
lastCompacted map[string]time.Time // channelID → timestamp
|
||||
```
|
||||
|
||||
2. **In-flight dedup** — if channel X is currently being compacted (sem
|
||||
acquired, goroutine running), skip it in the current scan. Track via:
|
||||
```go
|
||||
inFlight sync.Map // channelID → struct{}
|
||||
```
|
||||
|
||||
3. **Stacking** — the existing summary boundary logic already handles
|
||||
multiple summaries correctly. Auto-compaction produces the same tree
|
||||
nodes as manual, so stacking "just works."
|
||||
|
||||
---
|
||||
|
||||
## 7. Lifecycle + Wiring
|
||||
|
||||
### main.go changes
|
||||
|
||||
```go
|
||||
// After role resolver and stores setup:
|
||||
compactionSvc := compaction.NewService(stores, roleResolver)
|
||||
compactionScanner := compaction.NewScanner(compactionSvc, stores, compaction.ScannerConfig{
|
||||
Enabled: getGlobalBool(stores, "auto_compaction_enabled", false),
|
||||
Interval: time.Duration(getGlobalInt(stores, "auto_compaction_interval_minutes", 5)) * time.Minute,
|
||||
Concurrency: getGlobalInt(stores, "auto_compaction_concurrency", 2),
|
||||
})
|
||||
compactionScanner.Start()
|
||||
defer compactionScanner.Stop() // drain in-flight on shutdown
|
||||
|
||||
// Summarize handler now wraps compaction service:
|
||||
summarize := handlers.NewSummarizeHandler(stores, roleResolver, compactionSvc)
|
||||
```
|
||||
|
||||
Follows the same pattern as `kbIngester` with `defer kbIngester.Wait()`.
|
||||
|
||||
### Config reload
|
||||
|
||||
The scanner re-reads global settings at the start of each scan cycle.
|
||||
No restart required to change thresholds, intervals, or the kill switch.
|
||||
If `auto_compaction_enabled` flips to `false`, the scanner skips all
|
||||
work on the next tick.
|
||||
|
||||
---
|
||||
|
||||
## 8. Observability
|
||||
|
||||
### Logging
|
||||
|
||||
```
|
||||
🔍 compaction: scanning 50 candidates
|
||||
🔍 compaction: channel abc123 qualifies (78% of 128K context, 42 messages post-summary)
|
||||
📝 compaction: compacting channel abc123 for user xyz (trigger=auto)
|
||||
✅ compaction: channel abc123 done (42 messages → 847 chars, model=claude-sonnet-4-5-20250929)
|
||||
⏭ compaction: channel def456 skipped (cooldown, 12m remaining)
|
||||
⏭ compaction: channel ghi789 skipped (rate limit)
|
||||
```
|
||||
|
||||
### Audit log
|
||||
|
||||
Auto-compaction events are logged to `audit_log`:
|
||||
|
||||
```go
|
||||
stores.Audit.Log(ctx, &models.AuditEntry{
|
||||
Actor: "system:compaction",
|
||||
Action: "compaction.auto",
|
||||
Resource: "channel:" + channelID,
|
||||
Details: models.JSONMap{
|
||||
"summarized_count": result.SummarizedCount,
|
||||
"model": result.Model,
|
||||
"trigger": "auto",
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Usage tracking
|
||||
|
||||
All auto-compaction calls flow through the existing usage logging path
|
||||
(same as manual summarization). The `metadata.trigger = "auto"` field
|
||||
lets the admin usage dashboard distinguish auto from manual.
|
||||
|
||||
---
|
||||
|
||||
## 9. Frontend Changes
|
||||
|
||||
Minimal — the backend does the heavy lifting.
|
||||
|
||||
### Auto-compaction indicator
|
||||
|
||||
When loading a conversation that was auto-compacted, the frontend
|
||||
already handles summary boundaries correctly (the collapsed history
|
||||
toggle, the "X earlier messages summarized" bar). The only new UI is
|
||||
a subtle indicator on the summary node:
|
||||
|
||||
```
|
||||
▸ 42 earlier messages summarized (auto)
|
||||
```
|
||||
|
||||
The `(auto)` label comes from `metadata.trigger` on the summary message.
|
||||
|
||||
### Channel settings
|
||||
|
||||
Add compaction controls to the channel settings panel (Section 4 above).
|
||||
Saves to `PATCH /channels/:id` with `settings` field — no new endpoint.
|
||||
|
||||
### Admin panel
|
||||
|
||||
Add the auto-compaction card to the AI settings section (Section 5).
|
||||
Saves via existing `PUT /admin/settings/:key` endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration
|
||||
|
||||
**No new migration required.**
|
||||
|
||||
- Channel compaction settings → existing `Channel.Settings` JSONB
|
||||
- Admin settings → existing `global_settings` table
|
||||
- Summary messages → existing `messages` table with metadata
|
||||
- Usage tracking → existing `usage_log` table
|
||||
- Audit logging → existing `audit_log` table
|
||||
|
||||
---
|
||||
|
||||
## 11. File Manifest
|
||||
|
||||
```
|
||||
server/
|
||||
treepath/ ← NEW package
|
||||
path.go (extracted from handlers/tree.go)
|
||||
summary.go (isSummaryMessage + helpers)
|
||||
siblings.go (sibling queries)
|
||||
cursor.go (cursor + sibling index)
|
||||
compaction/ ← NEW package
|
||||
compaction.go (Service, Compact, CompactRequest/Result)
|
||||
scanner.go (Scanner, scan loop, candidate query)
|
||||
estimator.go (EstimateTokens, EstimatePath)
|
||||
handlers/
|
||||
tree.go (updated: delegates to treepath)
|
||||
summarize.go (updated: delegates to compaction.Service)
|
||||
completion.go (updated: imports treepath)
|
||||
main.go (updated: wire compaction scanner)
|
||||
src/js/
|
||||
ui-core.js (updated: show "(auto)" on auto-compacted summaries)
|
||||
ui-settings.js (updated: channel compaction controls)
|
||||
ui-admin.js (updated: auto-compaction admin card)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Order
|
||||
|
||||
**Phase 1: Extract** — Move tree helpers to `treepath`, core
|
||||
summarization to `compaction.Service`. Refactor `SummarizeHandler` to
|
||||
delegate. All existing tests must still pass. No new behavior.
|
||||
|
||||
**Phase 2: Estimator + Scanner** — Add `estimator.go`, `scanner.go`,
|
||||
candidate query, `shouldCompact` logic. Wire into `main.go` with
|
||||
`Start()`/`Stop()`. Global settings for admin controls. Ship with
|
||||
`auto_compaction_enabled` defaulting to `false`.
|
||||
|
||||
**Phase 3: Frontend** — Channel compaction settings, admin panel card,
|
||||
auto-compaction indicator on summary nodes.
|
||||
|
||||
**Phase 4: Soak + tune** — Enable on a test instance, watch logs, tune
|
||||
default threshold and cooldown. Adjust candidate query filters if scan
|
||||
is too expensive.
|
||||
|
||||
---
|
||||
|
||||
## 13. What This Intentionally Doesn't Do
|
||||
|
||||
- **Token-accurate estimation** — chars/4 is good enough. A real
|
||||
tokenizer adds a dependency for marginal accuracy gain. Revisit if
|
||||
users report summaries firing too early or too late.
|
||||
|
||||
- **Per-model prompt tuning** — the summarization prompt is one-size-fits
|
||||
-all. Works fine across Claude/GPT/Llama. Revisit if quality varies
|
||||
significantly across providers.
|
||||
|
||||
- **Group channel compaction** — auto-compaction targets `direct` channels
|
||||
only (single-owner). Group/channel types need multi-cursor awareness
|
||||
(which user's path to compact?). Deferred to v0.19.0 multi-participant.
|
||||
|
||||
- **Compaction snapshots for KB** — the roadmap mentions this. The
|
||||
summary nodes we create here are already stored as messages with full
|
||||
metadata. KB integration can query them later without changes to this
|
||||
design.
|
||||
|
||||
- **Event-driven triggers** — a post-completion hook that checks
|
||||
immediately after each message would reduce latency vs. the ticker.
|
||||
Worth adding later but the ticker is simpler to ship and debug. The
|
||||
2-minute activity gap in the candidate query achieves a similar effect
|
||||
(don't compact mid-conversation).
|
||||
@@ -1,332 +0,0 @@
|
||||
# DESIGN-0.15.1 — Context Recall Tools
|
||||
|
||||
## Overview
|
||||
|
||||
After compaction (v0.15.0) or in long conversations, the model loses access
|
||||
to earlier material — attachment content injected once at send time, and
|
||||
message details compressed into summaries. These tools let the model
|
||||
re-read source material on demand.
|
||||
|
||||
Depends on: file handling (v0.12.0), tool framework (v0.11.0), compaction (v0.15.0).
|
||||
|
||||
**Four work streams:**
|
||||
|
||||
1. `attachment_recall` — re-read attachment content from storage
|
||||
2. `conversation_search` — keyword search over channel messages
|
||||
3. Token estimator improvements — attachment-aware context counting
|
||||
4. KB auto-injection — automatic knowledge base context in system prompt
|
||||
|
||||
---
|
||||
|
||||
## 1. `attachment_recall` Tool
|
||||
|
||||
### Purpose
|
||||
|
||||
Attachments are injected into context once (at the user message that
|
||||
references them). On subsequent turns the model relies on its own response
|
||||
as a natural summary. After compaction, that summary is gone. This tool
|
||||
lets the model re-read any attachment in the current channel.
|
||||
|
||||
### Two-action design
|
||||
|
||||
Single tool, `action` parameter selects behavior:
|
||||
|
||||
- **`list`** — returns metadata for all attachments in the channel
|
||||
(id, filename, content_type, size, created_at). The model calls this
|
||||
first to discover what's available.
|
||||
- **`read`** — returns content for a specific attachment_id.
|
||||
Documents → `extracted_text` from DB. Images → base64 data URI.
|
||||
|
||||
### Security
|
||||
|
||||
Channel-scoped: `attachment_id` must belong to `ExecutionContext.ChannelID`.
|
||||
The store query is `GetByChannel(channelID)` so cross-channel access is
|
||||
impossible by construction.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Late registration (same pattern as `kb_search`):
|
||||
|
||||
```go
|
||||
func RegisterAttachmentRecall(stores store.Stores, objStore storage.ObjectStore)
|
||||
```
|
||||
|
||||
Called from `main.go` after storage init. If `objStore` is nil (storage
|
||||
not configured), the tool is **not registered** — it simply won't appear
|
||||
in the tool list.
|
||||
|
||||
### Implementation: `server/tools/attachment_recall.go`
|
||||
|
||||
```go
|
||||
type attachmentRecallTool struct {
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
func (t *attachmentRecallTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "attachment_recall",
|
||||
DisplayName: "Attachments",
|
||||
Category: "context",
|
||||
Description: "Re-read file attachments from this conversation. " +
|
||||
"Use action='list' to see available files, then action='read' " +
|
||||
"with an attachment_id to get the content.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"action": PropEnum("Action to perform", "list", "read"),
|
||||
"attachment_id": Prop("string", "Attachment ID (required for read)"),
|
||||
}, []string{"action"}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`list` action returns:**
|
||||
```json
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"id": "abc-123",
|
||||
"filename": "report.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size_bytes": 45200,
|
||||
"has_text": true,
|
||||
"created_at": "2026-02-26T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**`read` action returns:**
|
||||
```json
|
||||
{
|
||||
"id": "abc-123",
|
||||
"filename": "report.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"content": "... extracted text ...",
|
||||
"content_type_returned": "text"
|
||||
}
|
||||
```
|
||||
|
||||
For images:
|
||||
```json
|
||||
{
|
||||
"id": "def-456",
|
||||
"filename": "diagram.png",
|
||||
"content_type": "image/png",
|
||||
"content": "data:image/png;base64,...",
|
||||
"content_type_returned": "base64_image"
|
||||
}
|
||||
```
|
||||
|
||||
Image base64 is capped at 10MB to prevent context blow-up. Larger images
|
||||
return an error suggesting the user re-upload.
|
||||
|
||||
---
|
||||
|
||||
## 2. `conversation_search` Tool
|
||||
|
||||
### Purpose
|
||||
|
||||
After compaction, message details are compressed into summaries. If the
|
||||
user asks about something the summary omitted, the model needs to search
|
||||
the original messages. Also useful in long pre-compaction conversations
|
||||
where the model's attention has drifted.
|
||||
|
||||
### Design
|
||||
|
||||
Keyword search (full-text) over messages in the current channel using
|
||||
PostgreSQL's `plainto_tsquery`. No migration needed — `to_tsvector` is
|
||||
computed on the fly. This is acceptable because:
|
||||
|
||||
- Search is scoped to a single channel (bounded message count)
|
||||
- Called infrequently (tool invocation, not every request)
|
||||
- Channel messages rarely exceed ~10K rows
|
||||
|
||||
If performance becomes an issue, a `search_vector tsvector` column with
|
||||
GIN index can be added in a future migration.
|
||||
|
||||
### Parameters
|
||||
|
||||
```go
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"query": Prop("string", "Search keywords"),
|
||||
"max_results": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "Maximum results (1-20, default 5)",
|
||||
},
|
||||
"role_filter": PropEnum("Filter by message role", "all", "user", "assistant"),
|
||||
}, []string{"query"})
|
||||
```
|
||||
|
||||
### Returns
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"message_id": "...",
|
||||
"role": "user",
|
||||
"excerpt": "...highlighted excerpt...",
|
||||
"timestamp": "2026-02-26T10:00:00Z",
|
||||
"rank": 0.95
|
||||
}
|
||||
],
|
||||
"query": "original query",
|
||||
"total": 3,
|
||||
"channel_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Uses `ts_headline` for highlighted excerpts (same pattern as `note_search`).
|
||||
|
||||
### Security
|
||||
|
||||
Channel-scoped via `ExecutionContext.ChannelID`. Only searches messages
|
||||
in the current channel. Soft-deleted messages (`deleted_at IS NOT NULL`)
|
||||
are excluded.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Late registration:
|
||||
|
||||
```go
|
||||
func RegisterConversationSearch(stores store.Stores)
|
||||
```
|
||||
|
||||
No external dependencies beyond stores (uses `database.DB` directly for
|
||||
the full-text query, same pattern as `note_search`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Token Estimator Improvements
|
||||
|
||||
### Frontend (`tokens.js`)
|
||||
|
||||
Current `estimateConversation` counts message text only. Enhancements:
|
||||
|
||||
- **Staged attachments**: Before send, show estimated tokens for queued
|
||||
attachments. Documents: `extracted_text.length / 4`. Images: flat
|
||||
~765 tokens per image (OpenAI's base tile estimate, reasonable cross-provider).
|
||||
- **Sent attachments**: After send, the augmented content is already in
|
||||
the message text (documents are inlined). Images were ephemeral
|
||||
ContentParts — add their estimate to the message's token count.
|
||||
|
||||
### Implementation
|
||||
|
||||
Add to `Tokens`:
|
||||
|
||||
```javascript
|
||||
estimateAttachments(attachments) {
|
||||
let total = 0;
|
||||
for (const att of attachments) {
|
||||
if (att.content_type?.startsWith('image/')) {
|
||||
total += 765; // base tile estimate
|
||||
} else if (att.extracted_text) {
|
||||
total += this.estimate(att.extracted_text);
|
||||
} else {
|
||||
total += Math.ceil(att.size_bytes / 4); // rough fallback
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
```
|
||||
|
||||
Wire into `updateInputTokens()` to include staged attachment estimates.
|
||||
|
||||
---
|
||||
|
||||
## 4. KB Auto-Injection
|
||||
|
||||
### Purpose
|
||||
|
||||
Currently the model must explicitly call `kb_search` to access knowledge
|
||||
base content. For common patterns (channel linked to a KB, user asks a
|
||||
question), automatic injection into the system prompt is more natural.
|
||||
|
||||
### Design
|
||||
|
||||
In the completion handler, after loading conversation history and before
|
||||
building the provider request:
|
||||
|
||||
1. Check if channel has linked KBs (or user has personal KBs)
|
||||
2. Extract the user's latest message as the query
|
||||
3. Run similarity search (top-K, K=3 default)
|
||||
4. If results found AND context budget allows, prepend to system prompt
|
||||
|
||||
### Context budget check
|
||||
|
||||
```go
|
||||
contextUsed := estimateTokens(systemPrompt + allMessages)
|
||||
kbTokens := estimateTokens(kbContent)
|
||||
if contextUsed + kbTokens > maxContext * 0.85 {
|
||||
// Skip injection — context too full
|
||||
log.Printf("⏭ KB auto-inject skipped: context %.0f%% full", pct*100)
|
||||
}
|
||||
```
|
||||
|
||||
### Channel settings
|
||||
|
||||
```json
|
||||
{
|
||||
"kb_injection": "auto" | "tool_only" | "both" | "off"
|
||||
}
|
||||
```
|
||||
|
||||
Default: `"auto"` — inject when KBs are linked. `"tool_only"` disables
|
||||
auto-injection but keeps the `kb_search` tool available. `"both"` does
|
||||
both. `"off"` disables all KB context.
|
||||
|
||||
### System prompt format
|
||||
|
||||
```
|
||||
[Knowledge Base Context]
|
||||
The following information was retrieved from the user's knowledge bases
|
||||
and may be relevant to this conversation:
|
||||
|
||||
---
|
||||
Source: Technical Docs (chunk 3 of report.pdf, similarity: 0.87)
|
||||
Content: ...
|
||||
|
||||
---
|
||||
Source: FAQ (chunk 1 of faq.md, similarity: 0.82)
|
||||
Content: ...
|
||||
[End Knowledge Base Context]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Inventory
|
||||
|
||||
### New files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `server/tools/attachment_recall.go` | attachment_recall tool |
|
||||
| `server/tools/conversation_search.go` | conversation_search tool |
|
||||
| `docs/DESIGN-0.15.1.md` | This design doc |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `server/main.go` | Register new tools |
|
||||
| `server/handlers/completion.go` | KB auto-injection in completion flow |
|
||||
| `src/js/tokens.js` | Attachment-aware token estimation |
|
||||
| `docs/ROADMAP.md` | Mark v0.15.1 items |
|
||||
|
||||
### No migration required
|
||||
|
||||
- `conversation_search` uses on-the-fly `to_tsvector` (no new column)
|
||||
- `attachment_recall` reads existing `extracted_text` column
|
||||
- KB auto-injection uses existing `kb_search` infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1**: `attachment_recall` tool + registration ✅
|
||||
2. **Phase 2**: `conversation_search` tool + registration ✅
|
||||
3. **Phase 3**: Token estimator improvements (frontend) ✅
|
||||
4. **Phase 4**: KB auto-injection (completion handler) — separate delivery,
|
||||
requires adding embedder dependency to `CompletionHandler` and careful
|
||||
latency budgeting (embedding query on every completion call)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,235 +0,0 @@
|
||||
# DESIGN-0.18.0: Memory System
|
||||
|
||||
**Version:** 0.18.0
|
||||
**Status:** Phase 1 Complete
|
||||
**Depends on:** compaction (v0.15.0), knowledge bases (v0.14.0), persona-KB binding (v0.17.0), SQLite backend (v0.17.1)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Memory provides long-term fact persistence across conversations. Unlike
|
||||
compaction (within-conversation context) or knowledge bases (static
|
||||
documents), memory captures preferences, facts, and context that
|
||||
accumulate over time from natural conversation.
|
||||
|
||||
**Key differentiator: Persona-scoped memory.** Each Persona builds
|
||||
its own memory independently, preventing cross-context contamination.
|
||||
A helpdesk Persona accumulates FAQ knowledge; a tutoring Persona
|
||||
tracks per-student progress; a user's personal preferences stay
|
||||
separate from any Persona context.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scope Model
|
||||
|
||||
Three memory scopes with clear ownership:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ user scope │
|
||||
│ owner_id = user_id │
|
||||
│ "Jeff prefers Go over Python" │
|
||||
│ "Deployment uses Kubernetes + Traefik" │
|
||||
│ Visible in ALL conversations for this user │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ persona scope │
|
||||
│ owner_id = persona_id │
|
||||
│ "Common question: how to reset password" │
|
||||
│ "Policy: refunds within 30 days only" │
|
||||
│ Shared across ALL users of this Persona │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ persona_user scope │
|
||||
│ owner_id = persona_id, user_id = user_id │
|
||||
│ "Student struggles with recursion" │
|
||||
│ "Customer prefers email over phone" │
|
||||
│ Per-user within a specific Persona │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Scope resolution at recall time:**
|
||||
|
||||
When a Persona is active, all three scopes are queried and merged
|
||||
with priority ordering: persona_user > persona > user. When no
|
||||
Persona is active, only user scope is queried.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 Table: `memories`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID / TEXT | Primary key |
|
||||
| `scope` | TEXT | `user`, `persona`, `persona_user` |
|
||||
| `owner_id` | UUID / TEXT | user_id (user scope) or persona_id (persona/persona_user) |
|
||||
| `user_id` | UUID / TEXT | Only for persona_user scope — identifies which user |
|
||||
| `key` | TEXT | Short label (2-6 words) |
|
||||
| `value` | TEXT | The fact/detail |
|
||||
| `source_channel_id` | UUID / TEXT | Provenance — which conversation |
|
||||
| `confidence` | REAL | 0.0-1.0, LLM-provided |
|
||||
| `status` | TEXT | active, pending_review, archived |
|
||||
| `embedding` | vector(3072) / TEXT | For semantic recall (Phase 2) |
|
||||
| `created_at` | TIMESTAMPTZ / TEXT | |
|
||||
| `updated_at` | TIMESTAMPTZ / TEXT | |
|
||||
|
||||
### 3.2 Indexes
|
||||
|
||||
- **Primary lookup:** `(scope, owner_id, status)` — filtered to `status = 'active'`
|
||||
- **Persona+user:** `(owner_id, user_id)` — filtered to `scope = 'persona_user'`
|
||||
- **Dedup:** unique on `(scope, owner_id, COALESCE(user_id, nil_uuid), key)`
|
||||
- **FTS:** GIN on `to_tsvector(key || ' ' || value)` (Postgres) / LIKE fallback (SQLite)
|
||||
- **Provenance:** `(source_channel_id)` — "where did this memory come from?"
|
||||
|
||||
### 3.3 Model Struct
|
||||
|
||||
```go
|
||||
type Memory struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
SourceChannelID *string `json:"source_channel_id,omitempty"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Store Interface
|
||||
|
||||
```go
|
||||
type MemoryStore interface {
|
||||
Upsert(ctx, *Memory) error // create or update (matched on scope+owner+user+key)
|
||||
GetByID(ctx, id) (*Memory, error)
|
||||
List(ctx, MemoryFilter) ([]Memory, error)
|
||||
Delete(ctx, id) error // hard delete
|
||||
Archive(ctx, id) error // soft delete (status → archived)
|
||||
Recall(ctx, userID, *personaID, query, limit) ([]Memory, error) // scope-merged search
|
||||
CountByOwner(ctx, scope, ownerID) (int, error)
|
||||
}
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
|
||||
- `Upsert` uses the unique index for conflict resolution — same key = update value
|
||||
- `Recall` does a UNION across all applicable scopes with priority ordering
|
||||
- Both Postgres and SQLite implementations exist (same pattern as all other stores)
|
||||
- SQLite falls back to LIKE for text search (no tsvector)
|
||||
|
||||
---
|
||||
|
||||
## 5. Tools
|
||||
|
||||
### 5.1 `memory_save`
|
||||
|
||||
The LLM calls this to explicitly store a fact. Scope is automatically
|
||||
determined by whether a Persona is active:
|
||||
|
||||
- No Persona → `user` scope, `owner_id = user_id`
|
||||
- Persona active → `persona_user` scope, `owner_id = persona_id`, `user_id = user_id`
|
||||
|
||||
Parameters: `key` (required), `value` (required), `confidence` (optional, default 1.0)
|
||||
|
||||
### 5.2 `memory_recall`
|
||||
|
||||
The LLM calls this to search stored memories. Results merge across
|
||||
all applicable scopes.
|
||||
|
||||
Parameters: `query` (optional), `max_results` (optional, default 20)
|
||||
|
||||
### 5.3 Registration
|
||||
|
||||
Late registration pattern (same as notes, KB search, conversation search):
|
||||
|
||||
```go
|
||||
// main.go
|
||||
tools.RegisterMemoryTools(stores)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Memory Injection
|
||||
|
||||
At completion time, `loadConversation()` injects relevant memories
|
||||
into the system prompt — same position as KB hints and compaction
|
||||
summaries:
|
||||
|
||||
```
|
||||
[admin system prompt]
|
||||
[persona/channel system prompt]
|
||||
[KB hint]
|
||||
[memory injection] ← NEW
|
||||
[compaction summary OR message history]
|
||||
```
|
||||
|
||||
`BuildMemoryHint()` calls `Recall()` with an empty query (returns
|
||||
top memories by confidence/recency), formats as bullet points, and
|
||||
caps at ~1500 tokens (~6000 chars) to preserve context budget.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Phases
|
||||
|
||||
### Phase 1: Foundation & Tools ✅ (this delivery)
|
||||
- Migrations (Postgres + SQLite)
|
||||
- Memory model struct
|
||||
- MemoryStore interface + both implementations
|
||||
- memory_save + memory_recall tools
|
||||
- Memory injection in loadConversation
|
||||
- 9 new files, ~5 edits to existing files
|
||||
|
||||
### Phase 2: Automatic Extraction & Embeddings
|
||||
- Background job: post-conversation analysis extracts facts
|
||||
- Configurable extraction prompt per Persona
|
||||
- Extracted memories start as `pending_review`
|
||||
- Embedding generation for semantic recall
|
||||
- Recall upgraded to use vector similarity + keyword hybrid
|
||||
|
||||
### Phase 3: Review Pipeline & User Controls
|
||||
- Admin review queue for pending memories
|
||||
- Persona memory review for team admins
|
||||
- User Settings → Memory: view, edit, delete
|
||||
- Per-Persona memory toggle
|
||||
- Retention policies
|
||||
- Frontend memory management UI
|
||||
|
||||
---
|
||||
|
||||
## 8. Persona Memory Saves (Future: Phase 2)
|
||||
|
||||
Phase 1 only supports `user` and `persona_user` scopes via the tool
|
||||
(the LLM saves about the user, scoped appropriately). Phase 2 adds
|
||||
the ability for admins/team leads to configure Persona-scope memory
|
||||
extraction — the Persona "learns" from conversations:
|
||||
|
||||
```
|
||||
Persona system prompt addition (Phase 2):
|
||||
"After each conversation, identify key facts, common questions,
|
||||
and useful patterns. Save these as persona-scope memories for
|
||||
future reference by all users."
|
||||
```
|
||||
|
||||
This runs as a background job, not during the live conversation,
|
||||
to avoid latency impact.
|
||||
|
||||
---
|
||||
|
||||
## 9. Privacy & Security
|
||||
|
||||
- User memories never cross team boundaries
|
||||
- Persona memories respect group access controls
|
||||
- persona_user memories are only visible to the specific user + admins
|
||||
- Users can view/delete their own memories (Phase 3 UI)
|
||||
- Admin can disable memory system globally or per-team
|
||||
- No cross-persona memory leakage — scope isolation is enforced at the query level
|
||||
@@ -1,244 +0,0 @@
|
||||
# Design — v0.18.1 Side Panel Architecture
|
||||
|
||||
## Problem
|
||||
|
||||
The side panel is a single `<aside>` with two mutually exclusive tabs
|
||||
(Preview and Notes) toggled by `display: none`. Opening Notes kills the
|
||||
preview and vice versa. As more panel content arrives (KB browser,
|
||||
search results, memory viewer), the single-slot model doesn't scale.
|
||||
The current implementation also loses per-panel state (scroll position,
|
||||
active note, preview content) on tab switches.
|
||||
|
||||
This is also prerequisite plumbing for v0.23.0 multi-participant
|
||||
channels, where multiple panels need simultaneous visibility.
|
||||
|
||||
## Current State
|
||||
|
||||
**HTML:** Single `<aside id="sidePanel">` containing:
|
||||
- Resize handle (`sidePanelResize`)
|
||||
- Header with tab bar (Preview | Notes) + action buttons (clear, fullscreen, close)
|
||||
- Body with two `.side-panel-page` divs toggled by display
|
||||
|
||||
**JS (ui-format.js lines 295–365):**
|
||||
- `openSidePanel(tab)` — adds `.open` class, calls `switchSidePanelTab`
|
||||
- `closeSidePanel()` — removes `.open` and `.fullscreen`, clears inline width
|
||||
- `switchSidePanelTab(tab)` — toggles active class on tab buttons, toggles display on page divs
|
||||
- `_initSidePanelResize()` — mousedown/move/up drag handler for width resize
|
||||
|
||||
**Callers (4 total):**
|
||||
1. `toggleHTMLPreview()` → `openSidePanel('preview')` (ui-format.js)
|
||||
2. `openNotes()` → `openSidePanel('notes')` / `closeSidePanel()` (notes.js)
|
||||
3. Escape handler → `closeSidePanel()` (app.js)
|
||||
4. Inline onclick handlers in index.html (tab buttons, close, fullscreen, clear)
|
||||
|
||||
**CSS (styles.css lines 642–737):**
|
||||
- `.side-panel` base: `width: 0; min-width: 0; overflow: hidden`, transition on width
|
||||
- `.side-panel.open`: `width: 480px; min-width: 480px`
|
||||
- `.side-panel.fullscreen`: fixed positioning, 100% width
|
||||
- Mobile (`@media max-width: 768px`): open = fixed overlay at 100vw
|
||||
|
||||
## Design
|
||||
|
||||
### Panel Registry
|
||||
|
||||
A lightweight registry in a new `panels.js` file. Each panel is a named
|
||||
entry with its own state and DOM container. The registry owns all
|
||||
open/close/focus logic — individual panels never touch the `<aside>`
|
||||
directly.
|
||||
|
||||
```js
|
||||
const PanelRegistry = {
|
||||
_panels: {}, // { name: { element, onOpen, onClose, onFocus, state } }
|
||||
_active: null, // currently visible panel name (single-view mode)
|
||||
_secondary: null, // second visible panel (dual-view mode)
|
||||
_dualMode: false,
|
||||
|
||||
register(name, opts) { ... },
|
||||
open(name) { ... },
|
||||
close(name) { ... },
|
||||
toggle(name) { ... },
|
||||
closeAll() { ... },
|
||||
isOpen(name) { ... },
|
||||
active() { ... },
|
||||
};
|
||||
```
|
||||
|
||||
**Panel registration contract:**
|
||||
|
||||
```js
|
||||
PanelRegistry.register('notes', {
|
||||
element: document.getElementById('sidePanelNotes'),
|
||||
onOpen() { loadNotesList(); loadNoteFolders(); },
|
||||
onClose() { /* optional cleanup */ },
|
||||
onFocus() { /* optional re-activate */ },
|
||||
state: {}, // panel-specific state bag (scroll pos, etc.)
|
||||
});
|
||||
```
|
||||
|
||||
Panels register themselves during initialization — Notes registers in
|
||||
`notes.js`, Preview registers in `ui-format.js`. This keeps domain
|
||||
logic co-located with its panel.
|
||||
|
||||
### DOM Structure
|
||||
|
||||
Replace the hardcoded tab bar with a registry-driven header. The
|
||||
`<aside>` structure stays identical in shape (no layout break), but
|
||||
tabs render dynamically from registered panels:
|
||||
|
||||
```html
|
||||
<aside class="side-panel" id="sidePanel">
|
||||
<div class="side-panel-resize" id="sidePanelResize"></div>
|
||||
<div class="side-panel-header">
|
||||
<div class="side-panel-tabs" id="sidePanelTabs">
|
||||
<!-- rendered by PanelRegistry from registered panels -->
|
||||
</div>
|
||||
<div class="side-panel-actions" id="sidePanelActions">
|
||||
<!-- per-panel action buttons injected by active panel -->
|
||||
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" ...>
|
||||
<button class="side-panel-close" ...>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-panel-body" id="sidePanelBody">
|
||||
<!-- all .side-panel-page divs, shown/hidden by registry -->
|
||||
</div>
|
||||
</aside>
|
||||
```
|
||||
|
||||
The tab bar is re-rendered on `register()` and on `open()`/`close()`.
|
||||
Each panel can declare `actions` — an array of button configs that
|
||||
appear in the header when that panel is active (replacing the current
|
||||
hardcoded clear button for preview).
|
||||
|
||||
### State Preservation
|
||||
|
||||
Each panel gets a `state` bag on its registry entry. Before hiding a
|
||||
panel, the registry calls a `saveState()` hook (if provided) which can
|
||||
snapshot scroll position, selection, etc. On re-show, `restoreState()`
|
||||
re-applies. This is opt-in — panels that don't need it don't implement
|
||||
the hooks.
|
||||
|
||||
Notes state: `{ scrollTop, editingNoteId, viewMode: 'list'|'editor'|'graph' }`
|
||||
Preview state: `{ srcdoc, hasContent }`
|
||||
|
||||
### Dual-View Mode (Phase 2)
|
||||
|
||||
When enabled, the panel body splits into two panes with an internal
|
||||
divider. `_active` shows on the left (or top on mobile), `_secondary`
|
||||
on the right (or bottom). The outer resize handle still controls total
|
||||
panel width; an inner divider controls the split ratio.
|
||||
|
||||
Dual mode activates via:
|
||||
- `Ctrl/Cmd+Shift+\` keyboard shortcut
|
||||
- Dragging a tab to the other half (stretch goal)
|
||||
|
||||
### Per-Panel Action Buttons
|
||||
|
||||
Currently Preview has a "Clear" button and both panels share Fullscreen
|
||||
and Close. The new model:
|
||||
|
||||
- **Global actions** (always visible): Fullscreen, Close
|
||||
- **Panel actions** (per-panel, in active panel's header slot):
|
||||
- Preview: Clear
|
||||
- Notes: (none currently, but room for "New Note" shortcut)
|
||||
- Future panels declare their own
|
||||
|
||||
Panels declare actions at registration time:
|
||||
|
||||
```js
|
||||
PanelRegistry.register('preview', {
|
||||
element: document.getElementById('sidePanelPreview'),
|
||||
actions: [
|
||||
{ id: 'sidePanelClearBtn', icon: '...svg...', title: 'Clear preview',
|
||||
onClick: clearPreview, visible: () => hasPreviewContent() },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Preview Enhancements (Phase 3)
|
||||
|
||||
- **Live-update during streaming:** The chat streaming handler already
|
||||
accumulates HTML code blocks. On each chunk that completes a fenced
|
||||
code block tagged `html`, if the preview panel is open, update the
|
||||
iframe's `srcdoc`. Debounce at 500ms to avoid thrashing.
|
||||
- **Extension block preview:** Mermaid, KaTeX, etc. render inline in
|
||||
chat. Add a "Pop out" button on extension-rendered blocks that opens
|
||||
the rendered output in the preview panel (full-size, zoomable).
|
||||
|
||||
### Mobile (Phase 4)
|
||||
|
||||
- **Swipe navigation:** Touch event handlers on `.side-panel-body` —
|
||||
swipe left/right cycles through open panels. Uses CSS `transform:
|
||||
translateX()` with a 150px threshold for commit.
|
||||
- **Bottom sheet mode:** On viewports < 768px, the secondary panel in
|
||||
dual mode renders as a bottom sheet (40% height) instead of side-by-
|
||||
side.
|
||||
- **Priority collapse:** When space is tight and dual mode can't fit
|
||||
(< 600px combined), collapse to single-panel with most-recently-
|
||||
opened winning. The displaced panel becomes accessible via tab bar.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl/Cmd+\` | Cycle active panel (or open panel container if closed) |
|
||||
| `Ctrl/Cmd+Shift+\` | Toggle dual-view mode |
|
||||
| `Escape` | Close panel container (existing behavior, unchanged) |
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1 — Panel Registry + State Management
|
||||
|
||||
Extract panel logic from `ui-format.js` into `panels.js`. Implement
|
||||
`PanelRegistry` with register/open/close/toggle/closeAll. Migrate
|
||||
Notes and Preview to register as panels. Dynamic tab bar rendering.
|
||||
Per-panel action buttons. State preservation hooks.
|
||||
|
||||
**Files changed:**
|
||||
- New: `src/js/panels.js`
|
||||
- Modified: `src/js/ui-format.js` (remove panel functions, add preview registration)
|
||||
- Modified: `src/js/notes.js` (replace `openSidePanel`/`closeSidePanel` calls with `PanelRegistry.open`/`close`)
|
||||
- Modified: `src/js/app.js` (update Escape handler, add keyboard shortcuts, replace `_initSidePanelResize` call)
|
||||
- Modified: `src/index.html` (remove hardcoded tab buttons, add `panels.js` script tag, make tab bar dynamic)
|
||||
- No CSS changes (reuse existing `.side-panel-tab` styles)
|
||||
|
||||
**Backward compatibility:** All existing panel behavior works identically.
|
||||
`openSidePanel()` and `closeSidePanel()` become thin wrappers around
|
||||
`PanelRegistry` during transition, then get removed after all callers
|
||||
migrate.
|
||||
|
||||
### Phase 2 — Dual-View Layout
|
||||
|
||||
CSS grid split inside `.side-panel-body`. Internal divider with drag
|
||||
resize. Keyboard shortcut for dual toggle. Tab drag to split (stretch).
|
||||
|
||||
**Files changed:**
|
||||
- Modified: `src/js/panels.js` (dual-view logic)
|
||||
- Modified: `src/css/styles.css` (grid layout for dual panes)
|
||||
|
||||
### Phase 3 — Preview Enhancements
|
||||
|
||||
Live-update preview during streaming. Extension block "pop out" button.
|
||||
|
||||
**Files changed:**
|
||||
- Modified: `src/js/ui-format.js` (pop-out button on extension blocks)
|
||||
- Modified: `src/js/chat.js` (streaming hook for live preview update)
|
||||
- Modified: `src/js/panels.js` (debounced preview update API)
|
||||
|
||||
### Phase 4 — Mobile
|
||||
|
||||
Swipe navigation, bottom sheet mode, priority collapse.
|
||||
|
||||
**Files changed:**
|
||||
- Modified: `src/js/panels.js` (touch handlers, priority logic)
|
||||
- Modified: `src/css/styles.css` (bottom sheet layout, responsive breakpoints)
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] `openSidePanel(tab)` → `PanelRegistry.open(tab)`
|
||||
- [ ] `closeSidePanel()` → `PanelRegistry.closeAll()`
|
||||
- [ ] `switchSidePanelTab(tab)` → `PanelRegistry.open(tab)` (open implies focus)
|
||||
- [ ] `toggleSidePanelFullscreen()` → stays global (operates on container, not individual panels)
|
||||
- [ ] `_initSidePanelResize()` → moves to `panels.js` (operates on container)
|
||||
- [ ] Escape handler: `PanelRegistry.closeAll()`
|
||||
- [ ] `openNotes()` toggle logic: `PanelRegistry.toggle('notes')`
|
||||
- [ ] `toggleHTMLPreview()`: `PanelRegistry.open('preview')` + update iframe content
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +0,0 @@
|
||||
# v0.19.1 — Active Projects, Project System Prompt, Project Detail Panel
|
||||
|
||||
**Status:** Implementation
|
||||
**Depends on:** v0.19.0 Projects/Workspaces
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Three tightly-scoped improvements that make projects genuinely usable
|
||||
for daily workflow rather than just organizational containers:
|
||||
|
||||
1. **Active Project** — one-click project activation so new chats
|
||||
auto-assign without drag-drop.
|
||||
2. **Project System Prompt** — per-project instructions injected into
|
||||
every chat within the project at completion time.
|
||||
3. **Project Detail Panel** — side panel UI for managing a project's
|
||||
system prompt, KB bindings, and notes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Active Project
|
||||
|
||||
### State
|
||||
|
||||
| Location | Key | Type |
|
||||
|----------|-----|------|
|
||||
| `App.activeProjectId` | runtime | `string \| null` |
|
||||
| `localStorage` | `cs-active-project` | `string \| null` |
|
||||
|
||||
### Behavior
|
||||
|
||||
- Click project header's **pin icon** (or "Set as active" in ⋯ menu)
|
||||
to activate.
|
||||
- Active project gets a visual indicator: left accent border + subtle
|
||||
background tint using the project's color.
|
||||
- Creating a new chat (via `sendMessage` when `!App.currentChatId`)
|
||||
auto-calls `addChannelToProject(activeProjectId, newChannelId)` and
|
||||
sets `chat.projectId` client-side.
|
||||
- Only one project can be active at a time. Clicking the pin on an
|
||||
already-active project deactivates it.
|
||||
- Active state persists across page reloads via `localStorage`.
|
||||
- If the active project is deleted, `activeProjectId` is cleared.
|
||||
|
||||
### Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/js/app.js` | Add `activeProjectId: null` to App, restore from localStorage on init |
|
||||
| `src/js/projects-ui.js` | `setActiveProject(id)`, `clearActiveProject()`, menu items, pin icon |
|
||||
| `src/js/chat.js` | Auto-assign in `sendMessage` after channel creation |
|
||||
| `src/js/ui-core.js` | `.active` class on project group in `renderChatList` |
|
||||
| `src/css/styles.css` | `.project-group.active` styling |
|
||||
|
||||
---
|
||||
|
||||
## 2. Project System Prompt
|
||||
|
||||
### Injection Order
|
||||
|
||||
```
|
||||
1. Admin system prompt (global policy — no opt-out)
|
||||
2. Persona OR channel prompt (role/character definition)
|
||||
3. ▸ PROJECT system prompt (project-specific context) ← NEW
|
||||
4. KB hint (available knowledge bases)
|
||||
5. Memory hint (user facts from v0.18.0)
|
||||
6. Summary / conversation (message history)
|
||||
```
|
||||
|
||||
The project prompt sits between the role definition and the KB hint.
|
||||
This lets a persona define "you are a coding assistant" while the
|
||||
project adds "this project is the React dashboard for client X."
|
||||
|
||||
### Backend Flow (completion.go)
|
||||
|
||||
In `loadConversation`, after the persona/channel system prompt block:
|
||||
|
||||
```go
|
||||
// ── Project system prompt (v0.19.1) ──
|
||||
if h.stores.Projects != nil {
|
||||
projID, _ := h.stores.Projects.GetProjectIDForChannel(ctx, channelID)
|
||||
if projID != "" {
|
||||
proj, err := h.stores.Projects.GetByID(ctx, projID)
|
||||
if err == nil {
|
||||
if sp, ok := proj.Settings["system_prompt"].(string); ok && sp != "" {
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: sp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Uses existing `projects.settings` JSONB column. No migration needed.
|
||||
|
||||
```json
|
||||
{
|
||||
"system_prompt": "You are helping build the Chat Switchboard project..."
|
||||
}
|
||||
```
|
||||
|
||||
### Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `server/handlers/completion.go` | Inject project system prompt in `loadConversation` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Project Detail Panel
|
||||
|
||||
### Registration
|
||||
|
||||
Follows the same `PanelRegistry.register()` pattern as notes and
|
||||
preview panels. Registered during app init via `_registerProjectPanel()`.
|
||||
|
||||
### Panel Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Project: <name> [✕] │ ← header (editable name)
|
||||
├─────────────────────────────┤
|
||||
│ System Prompt │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ textarea (auto-resize) │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ [Save] │
|
||||
├─────────────────────────────┤
|
||||
│ Knowledge Bases [+] │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ • KB Alpha [✕] │ │
|
||||
│ │ • KB Beta [✕] │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ (empty: "No KBs bound") │
|
||||
├─────────────────────────────┤
|
||||
│ Notes │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ • Meeting notes [✕] │ │
|
||||
│ │ • Design spec [✕] │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ (empty: "No notes") │
|
||||
├─────────────────────────────┤
|
||||
│ Color [●] │
|
||||
│ Archived [ ] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Interactions
|
||||
|
||||
- **System prompt:** Textarea with debounced save (1s) or explicit Save
|
||||
button. Writes to `PUT /projects/:id` with
|
||||
`settings: { system_prompt: "..." }`.
|
||||
- **KB add:** [+] opens a dropdown of available KBs (from
|
||||
`GET /knowledge-bases`), filtered to exclude already-bound ones.
|
||||
Calls `POST /projects/:id/knowledge-bases`.
|
||||
- **KB remove:** [✕] calls `DELETE /projects/:id/knowledge-bases/:kbId`.
|
||||
- **Notes:** Read-only list with [✕] to unbind. Notes are auto-associated
|
||||
from channel creation (v0.19.0).
|
||||
- **Color:** Reuses the invisible color picker pattern from
|
||||
`setProjectColor()`.
|
||||
- **Open trigger:** Click project name in sidebar header, or
|
||||
"Project details" in ⋯ menu.
|
||||
|
||||
### Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/index.html` | Add `#sidePanelProject` div inside `#sidePanelBody` |
|
||||
| `src/js/projects-ui.js` | `openProjectPanel(id)`, `_registerProjectPanel()`, panel rendering, KB/note management |
|
||||
| `src/js/app.js` | Call `_registerProjectPanel()` in init |
|
||||
| `src/css/styles.css` | `.project-panel-*` styles |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
| File | Phase | Type |
|
||||
|------|-------|------|
|
||||
| `server/handlers/completion.go` | 2 | Modified |
|
||||
| `src/js/app.js` | 1,3 | Modified |
|
||||
| `src/js/projects-ui.js` | 1,3 | Modified |
|
||||
| `src/js/chat.js` | 1 | Modified |
|
||||
| `src/js/ui-core.js` | 1 | Modified |
|
||||
| `src/css/styles.css` | 1,3 | Modified |
|
||||
| `src/index.html` | 3 | Modified |
|
||||
| `VERSION` | — | Modified (0.19.0 → 0.19.1) |
|
||||
| `CHANGELOG.md` | — | Modified |
|
||||
|
||||
No new files. No migrations. Backend change is a single injection
|
||||
point in completion.go.
|
||||
@@ -1,116 +0,0 @@
|
||||
# v0.19.2 — Project Persona Default, Archive Toggle, Channel Reorder
|
||||
|
||||
**Status:** Implementation
|
||||
**Depends on:** v0.19.1
|
||||
|
||||
---
|
||||
|
||||
## 1. Project-level Persona Default
|
||||
|
||||
### Concept
|
||||
|
||||
Bind a persona to a project so all chats inherit model, system prompt,
|
||||
and parameters without selecting it each time. The persona acts as a
|
||||
fallback when no explicit preset is chosen per-message.
|
||||
|
||||
### Resolution Chain (completion.go)
|
||||
|
||||
```
|
||||
1. Explicit req.PresetID → highest priority
|
||||
2. Project persona default → fallback when no explicit preset ← NEW
|
||||
3. No persona → bare model, no persona context
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
`projects.settings` JSONB:
|
||||
```json
|
||||
{ "persona_id": "uuid-here", "system_prompt": "..." }
|
||||
```
|
||||
|
||||
### Backend Changes
|
||||
|
||||
In `completion.go`, after the explicit preset block, before
|
||||
`resolveConfig`:
|
||||
|
||||
```go
|
||||
// ── Project persona fallback (v0.19.2) ──
|
||||
if req.PresetID == "" && h.stores.Projects != nil {
|
||||
projID, _ := h.stores.Projects.GetProjectIDForChannel(ctx, channelID)
|
||||
if projID != "" {
|
||||
proj, _ := h.stores.Projects.GetByID(ctx, projID)
|
||||
if proj != nil {
|
||||
if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" {
|
||||
preset := ResolvePreset(h.stores, pid, userID)
|
||||
if preset != nil {
|
||||
personaID = preset.ID
|
||||
if req.Model == "" { req.Model = preset.BaseModelID }
|
||||
// ... same defaults as explicit preset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
Project detail panel: persona picker dropdown below system prompt.
|
||||
Lists available personas, saves to `settings.persona_id`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Project Archive Toggle
|
||||
|
||||
### Behavior
|
||||
|
||||
- Toggle in project detail panel (checkbox)
|
||||
- Archived projects hidden from sidebar by default
|
||||
- "Show archived" toggle at bottom of sidebar (or in panel)
|
||||
- Uses existing `is_archived` column — no migration needed
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/js/projects-ui.js` — archive toggle in panel, sidebar filter
|
||||
- `src/js/ui-core.js` — filter archived in renderChatList
|
||||
- `src/css/styles.css` — dimmed style for archived
|
||||
|
||||
---
|
||||
|
||||
## 3. Channel Reorder Within Project
|
||||
|
||||
### Behavior
|
||||
|
||||
- Dragging a chat within the same project group reorders it
|
||||
- Drop between chats inserts at that position
|
||||
- Calls `PUT /projects/:id/channels/reorder` with new order
|
||||
- Position persisted server-side, respected on next load
|
||||
|
||||
### Implementation
|
||||
|
||||
- `onProjectDrop` detects same-project reorder vs cross-project move
|
||||
- Compute new channel order from DOM after drop
|
||||
- Channels within project sorted by position from
|
||||
`project_channels.position` (loaded alongside projects)
|
||||
|
||||
### Files Changed
|
||||
|
||||
- `src/js/projects-ui.js` — reorder logic
|
||||
- `src/js/ui-core.js` — sort projChats by position
|
||||
- `src/js/app.js` — store project channel positions
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `server/handlers/completion.go` | Project persona fallback |
|
||||
| `src/js/projects-ui.js` | Persona picker, archive toggle, reorder |
|
||||
| `src/js/ui-core.js` | Archive filter, position sort |
|
||||
| `src/js/app.js` | Channel positions state |
|
||||
| `src/css/styles.css` | Archive styles |
|
||||
| `VERSION` | 0.19.1 → 0.19.2 |
|
||||
| `CHANGELOG.md` | New entry |
|
||||
|
||||
No migrations. No new files.
|
||||
512
docs/ICD-API.md
512
docs/ICD-API.md
@@ -1,7 +1,7 @@
|
||||
# ICD-API — Chat Switchboard Backend API Contract
|
||||
|
||||
**Version:** 0.22.7
|
||||
**Updated:** 2026-03-03
|
||||
**Version:** 0.23.0
|
||||
**Updated:** 2026-03-04
|
||||
**Audience:** Frontend developers, integrators, API consumers
|
||||
|
||||
> **Organization principle:** Each section is one domain — the complete story.
|
||||
@@ -31,6 +31,7 @@
|
||||
15. [Teams & Access Control](#15-teams--access-control)
|
||||
16. [Platform Administration](#16-platform-administration)
|
||||
17. [Export & Utility](#17-export--utility)
|
||||
17b. [Files & Storage](#17b-files--storage)
|
||||
18. [WebSocket Protocol](#18-websocket-protocol)
|
||||
19. [Appendix: Enums](#19-appendix-enums)
|
||||
|
||||
@@ -118,14 +119,6 @@ both Postgres and SQLite backends.
|
||||
ISO 8601 with timezone: `"2025-06-15T14:30:00Z"`. Stored as
|
||||
`TIMESTAMPTZ` (Postgres) or `TEXT` (SQLite).
|
||||
|
||||
### Naming Note
|
||||
|
||||
The domain concept is **Persona** (identity + system prompt + model +
|
||||
KB bindings + grant scoping). API routes currently use `/personas` for
|
||||
historical reasons. A pre-1.0 rename to `/personas` is planned. This
|
||||
document uses "Persona" for the concept and shows the current route
|
||||
paths as-is.
|
||||
|
||||
---
|
||||
|
||||
## 2. Auth
|
||||
@@ -209,8 +202,9 @@ Revokes the refresh token. Returns `{ "message": "logged out" }`.
|
||||
|
||||
The **channel** is the universal conversation container. Messages,
|
||||
tool activity, attachments, and KB bindings all hang off a channel.
|
||||
Today channels are single-user direct chats; the architecture
|
||||
anticipates multi-participant channels for v0.23.0.
|
||||
Channels support multiple participants — users, personas, and
|
||||
sessions — making them the foundation for collaborative and
|
||||
multi-model workflows.
|
||||
|
||||
### 3.1 Channel CRUD
|
||||
|
||||
@@ -225,9 +219,8 @@ Returns pagination envelope. Each channel:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"title": "My Chat",
|
||||
"type": "direct",
|
||||
"type": "direct|group|workflow",
|
||||
"description": "",
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"provider_config_id": "uuid|null",
|
||||
@@ -235,11 +228,20 @@ Returns pagination envelope. Each channel:
|
||||
"project_id": "uuid|null",
|
||||
"folder": "string|null",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"participant_count": 1,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Channel types:**
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `direct` | Single-user chat (default, backward compatible) |
|
||||
| `group` | Multi-user/multi-model collaborative channel |
|
||||
| `workflow` | Staged channel driven by workflow definitions |
|
||||
|
||||
**Create channel:**
|
||||
|
||||
```
|
||||
@@ -259,9 +261,10 @@ POST /channels
|
||||
}
|
||||
```
|
||||
|
||||
On create, if `model` and `provider_config_id` are provided, the
|
||||
channel model roster (`channel_models`) is auto-populated with an
|
||||
initial entry.
|
||||
On create, the authenticated user is automatically added as a
|
||||
participant with `role: "owner"`. If `model` and `provider_config_id`
|
||||
are provided, the channel model roster (`channel_models`) is
|
||||
auto-populated with an initial entry.
|
||||
|
||||
**Get channel:**
|
||||
|
||||
@@ -315,6 +318,8 @@ following the cursor at each fork. This is what the UI renders.
|
||||
"content": "...",
|
||||
"model": "claude-sonnet-4-20250514|null",
|
||||
"provider_config_id": "uuid|null",
|
||||
"participant_id": "uuid|null",
|
||||
"participant_type": "user|persona|session|null",
|
||||
"thinking": "...|null",
|
||||
"tool_calls": [...],
|
||||
"tool_results": [...],
|
||||
@@ -328,6 +333,11 @@ following the cursor at each fork. This is what the UI renders.
|
||||
}
|
||||
```
|
||||
|
||||
`participant_id` and `participant_type` identify who authored the
|
||||
message. For `user` messages, this is the authenticated user. For
|
||||
`assistant` messages, this is the persona (if set) or null for raw
|
||||
model responses. Existing messages without participants return null.
|
||||
|
||||
**List all messages** — includes all branches, flat:
|
||||
|
||||
```
|
||||
@@ -432,15 +442,23 @@ POST /chat/completions
|
||||
}
|
||||
```
|
||||
|
||||
`channel_id` is required. `chat_id` is accepted as a deprecated alias
|
||||
(maps to `channel_id`; frontend no longer sends it — removal planned).
|
||||
`channel_id` is required.
|
||||
|
||||
`extra_body` is a freeform JSON object merged into the provider request
|
||||
(provider-specific parameters like `temperature`, `reasoning`, etc.).
|
||||
|
||||
**Persona resolution:** If `persona_id` is set, the handler loads the
|
||||
persona's system prompt, model, provider_config_id, and KB bindings.
|
||||
Per-message `model`/`provider_config_id` override the persona's defaults.
|
||||
**Participant scoping:** The requesting user must be a participant in
|
||||
the channel. Messages are attributed to the authenticated user (or
|
||||
session). In `group` channels, all participants see all messages in
|
||||
real time via WebSocket.
|
||||
|
||||
**Persona resolution:** The primary completion path. If `persona_id`
|
||||
is set (explicitly or via `@mention` resolution from message content),
|
||||
the handler loads the persona's system prompt, model, provider config,
|
||||
and KB bindings. Per-message `model`/`provider_config_id` override the
|
||||
persona's defaults. In multi-persona channels, `@mention` in the
|
||||
message content is parsed to resolve the target persona from the
|
||||
channel's participant list.
|
||||
|
||||
**Routing resolution:** After config resolution, the routing policy
|
||||
evaluator (`evaluateRouting()`) may redirect to a different provider
|
||||
@@ -502,10 +520,16 @@ GET /tools
|
||||
Returns `{ "tools": [...] }` where each tool has `name`, `description`,
|
||||
`parameters` (JSON Schema), and `category`.
|
||||
|
||||
### 3.4 Multi-Model Roster
|
||||
### 3.4 Multi-Model Roster (Raw Access)
|
||||
|
||||
Channels can have multiple AI models. The `channel_models` table tracks
|
||||
the roster. `@mention` in message content routes to specific models.
|
||||
The primary way to add AI to a channel is by adding a **persona as a
|
||||
participant** (§3.7). Adding a persona automatically populates the
|
||||
channel model roster with the persona's underlying model.
|
||||
|
||||
For the rare case where a user needs raw model access without a
|
||||
persona wrapper, the `channel_models` table provides direct model
|
||||
roster management. This is the 1% escape hatch — most users should
|
||||
never need it.
|
||||
|
||||
```
|
||||
GET /channels/:id/models → { "models": [...] }
|
||||
@@ -524,10 +548,20 @@ Each roster entry:
|
||||
"provider_config_id": "uuid",
|
||||
"display_name": "Claude",
|
||||
"is_default": true,
|
||||
"persona_id": "uuid|null",
|
||||
"created_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
`persona_id`: set when this roster entry was auto-created by adding a
|
||||
persona participant. Null for manually-added raw models.
|
||||
|
||||
**`@mention` resolution:** When a user types `@` in a channel, the
|
||||
autocomplete shows persona participants first (by display name), then
|
||||
raw model roster entries. The target of a `@mention` is always
|
||||
resolved to a specific `provider_config_id` + `model` pair — never
|
||||
an ambiguous bare model name.
|
||||
|
||||
### 3.5 Channel Utilities
|
||||
|
||||
**Summarize channel** (compaction):
|
||||
@@ -548,65 +582,236 @@ POST /channels/:id/generate-title
|
||||
Uses the utility model to auto-generate a title from the first
|
||||
exchange. Returns `{ "title": "Generated Title" }`.
|
||||
|
||||
### 3.6 Attachments
|
||||
### 3.6 Files
|
||||
|
||||
File attachments are channel-scoped. Upload stores the file in blob
|
||||
storage; a background extraction pipeline processes documents (PDF,
|
||||
DOCX, XLSX, etc.) into searchable text.
|
||||
All binary content associated with channels — user uploads, tool-
|
||||
generated artifacts, system files — lives in the unified `files`
|
||||
table backed by the ObjectStore (see §X). The old `attachments`
|
||||
table and routes are removed.
|
||||
|
||||
**Upload:**
|
||||
**Upload (user):**
|
||||
|
||||
```
|
||||
POST /channels/:id/attachments
|
||||
POST /channels/:id/files
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Field: `file`. Returns the attachment object.
|
||||
Field: `file`. Sets `origin: "user_upload"`. Returns the file object.
|
||||
|
||||
**Create (tool output):**
|
||||
|
||||
```
|
||||
POST /files
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_id": "uuid",
|
||||
"message_id": "uuid",
|
||||
"filename": "generated_image.png",
|
||||
"content_type": "image/png",
|
||||
"display_hint": "inline",
|
||||
"metadata": { "tool_name": "image_generation" }
|
||||
}
|
||||
```
|
||||
|
||||
Body: multipart `file` field or base64 `data` field.
|
||||
Sets `origin: "tool_output"`. Returns file object.
|
||||
|
||||
**List by channel:**
|
||||
|
||||
```
|
||||
GET /channels/:id/attachments
|
||||
GET /channels/:id/files?origin=user_upload
|
||||
```
|
||||
|
||||
Returns `{ "attachments": [...] }`.
|
||||
Returns `{ "files": [...] }`. Filterable by `origin`, `content_type`.
|
||||
|
||||
**List by message:**
|
||||
|
||||
```
|
||||
GET /messages/:id/files → { "files": [...] }
|
||||
```
|
||||
|
||||
How the frontend discovers generated artifacts to render inline.
|
||||
|
||||
**List by user (file manager):**
|
||||
|
||||
```
|
||||
GET /files?page=1&per_page=50 → { "files": [...], "total": N }
|
||||
```
|
||||
|
||||
All files owned by the authenticated user, paginated.
|
||||
|
||||
**Get metadata:**
|
||||
|
||||
```
|
||||
GET /attachments/:id
|
||||
GET /files/:id
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"message_id": "uuid|null",
|
||||
"user_id": "uuid",
|
||||
"origin": "user_upload|tool_output|system",
|
||||
"filename": "report.pdf",
|
||||
"content_type": "application/pdf",
|
||||
"size": 1048576,
|
||||
"storage_key": "attachments/chan-id/att-id_report.pdf",
|
||||
"extracted_text_key": "...|null",
|
||||
"size_bytes": 1048576,
|
||||
"display_hint": "inline|download|thumbnail",
|
||||
"extracted_text": "...|null",
|
||||
"metadata": {},
|
||||
"created_at": "..."
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Download:**
|
||||
|
||||
```
|
||||
GET /attachments/:id/download
|
||||
GET /files/:id/download
|
||||
```
|
||||
|
||||
Returns the file with appropriate `Content-Type` and
|
||||
`Content-Disposition` headers.
|
||||
|
||||
**Thumbnail:**
|
||||
|
||||
```
|
||||
GET /files/:id/thumbnail
|
||||
```
|
||||
|
||||
**Delete:**
|
||||
|
||||
```
|
||||
DELETE /attachments/:id
|
||||
DELETE /files/:id
|
||||
```
|
||||
|
||||
Deletes metadata + blob. Channel deletion cascades via FK +
|
||||
`DeletePrefix`.
|
||||
|
||||
### 3.7 Channel Participants
|
||||
|
||||
The participant system is how users and AI interact in a channel.
|
||||
**Personas are the primary way to add AI** — adding a persona as a
|
||||
participant brings its full identity (name, avatar, system prompt,
|
||||
model, provider config, KB bindings) into the channel. This is the
|
||||
99% path. Raw model access without a persona is available via the
|
||||
model roster (§3.4) for power users.
|
||||
|
||||
Every channel has at least one participant (the owner). `direct`
|
||||
channels have exactly one user participant and optionally one or more
|
||||
persona participants. `group` and `workflow` channels support multiple
|
||||
participants of mixed types.
|
||||
|
||||
**List participants:**
|
||||
|
||||
```
|
||||
GET /channels/:id/participants
|
||||
```
|
||||
|
||||
Returns `{ "participants": [...] }`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"channel_id": "uuid",
|
||||
"participant_type": "user|persona|session",
|
||||
"participant_id": "uuid",
|
||||
"role": "owner|member|observer",
|
||||
"display_name": "Jane Doe",
|
||||
"avatar_url": "...|null",
|
||||
"joined_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Participant types:**
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `user` | Authenticated user. `participant_id` = `users.id` |
|
||||
| `persona` | AI persona added to channel. `participant_id` = `personas.id`. Brings model, system prompt, KB bindings |
|
||||
| `session` | Anonymous/session participant (workflow intake). `participant_id` = session token |
|
||||
|
||||
**Participant roles:**
|
||||
|
||||
| Role | Capabilities |
|
||||
|------|-------------|
|
||||
| `owner` | Full control: add/remove participants, delete channel, all member capabilities |
|
||||
| `member` | Send messages, trigger completions, view history |
|
||||
| `observer` | Read-only: view messages, no send |
|
||||
|
||||
**Add participant:**
|
||||
|
||||
```
|
||||
POST /channels/:id/participants
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"participant_type": "user|persona",
|
||||
"participant_id": "uuid",
|
||||
"role": "member"
|
||||
}
|
||||
```
|
||||
|
||||
Requires `owner` role in the channel. Adding a `persona` participant:
|
||||
- Makes the persona available as an `@mention` target
|
||||
- Adds the persona's model to the channel model roster automatically
|
||||
- Scopes the persona's KB bindings into the channel's KB resolution chain
|
||||
- The persona's avatar and display name appear in the participant list
|
||||
|
||||
**Update participant role:**
|
||||
|
||||
```
|
||||
PATCH /channels/:id/participants/:participantId
|
||||
```
|
||||
|
||||
```json
|
||||
{ "role": "observer" }
|
||||
```
|
||||
|
||||
**Remove participant:**
|
||||
|
||||
```
|
||||
DELETE /channels/:id/participants/:participantId
|
||||
```
|
||||
|
||||
Cannot remove the last owner. Removing a `persona` participant also
|
||||
removes its auto-created model roster entry.
|
||||
|
||||
**Completion routing:** When `@PersonaName` appears in message
|
||||
content, the completion handler resolves to that persona's model,
|
||||
provider config, and system prompt. When no `@mention` is present in
|
||||
a multi-persona channel, the channel's default persona (first added)
|
||||
handles the response. The `persona_id` field in the completion
|
||||
request (§3.3) can also be set explicitly to override `@mention`
|
||||
resolution.
|
||||
|
||||
**Backward compatibility:** Existing `direct` channels that predate
|
||||
the participant system are auto-migrated on first access — a single
|
||||
`user` participant with `role: "owner"` is created from the channel's
|
||||
legacy `user_id` column.
|
||||
|
||||
### 3.8 Presence
|
||||
|
||||
Real-time presence for channel participants. Delivered via WebSocket
|
||||
to all subscribers of the channel room.
|
||||
|
||||
**Who's here:**
|
||||
|
||||
```
|
||||
GET /channels/:id/presence
|
||||
```
|
||||
|
||||
Returns `{ "participants": [{ "participant_id", "display_name", "status", "last_seen" }] }`.
|
||||
|
||||
`status`: `online`, `idle`, `offline`.
|
||||
|
||||
**Typing indicators** are sent via WebSocket (see §18.4). The typing
|
||||
event payload includes `participant_id` and `participant_type` so the
|
||||
UI can distinguish between human and AI typing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Personas
|
||||
@@ -616,6 +821,16 @@ prompt + model + provider config + KB bindings + grant scoping. Users
|
||||
interact with Personas, not raw provider configs. Admins curate which
|
||||
Personas are available; team admins create team-scoped Personas.
|
||||
|
||||
**Model inheritance:** A Persona inherits all properties of its
|
||||
underlying model. This includes context window size, max output
|
||||
tokens, supported capabilities (thinking, tools, vision, streaming),
|
||||
input/output pricing, and provider status. When the frontend displays
|
||||
a Persona, it resolves the underlying model's capabilities and shows
|
||||
the same pills/badges (e.g. thinking, tools, 200k context) that the
|
||||
raw model would show. The Persona adds identity (name, avatar, system
|
||||
prompt, KB bindings) on top of the model's capabilities — it never
|
||||
masks or reduces them.
|
||||
|
||||
Three scopes, three route namespaces, one domain:
|
||||
|
||||
| Scope | Routes | Who manages |
|
||||
@@ -637,31 +852,46 @@ All three return the same Persona object shape:
|
||||
"scope": "personal|team|global",
|
||||
"owner_id": "uuid|null",
|
||||
"is_default": false,
|
||||
"is_active": true,
|
||||
"avatar_url": "/api/v1/personas/uuid/avatar?v=123",
|
||||
"inherited": {
|
||||
"context_window": 200000,
|
||||
"max_output_tokens": 8192,
|
||||
"supports_vision": true,
|
||||
"supports_tools": true,
|
||||
"supports_thinking": true,
|
||||
"supports_streaming": true,
|
||||
"input_price_per_m": 3.00,
|
||||
"output_price_per_m": 15.00,
|
||||
"provider_status": "healthy|degraded|down|null"
|
||||
},
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
The `inherited` block is populated by the backend from the resolved
|
||||
model capabilities at response time — it is not stored on the persona.
|
||||
If the underlying model's capabilities change (e.g. provider upgrades
|
||||
context window), the persona inherits the new values automatically.
|
||||
The frontend uses `inherited` to render capability pills and context
|
||||
size badges identical to the raw model.
|
||||
|
||||
### 4.1 Personal Personas
|
||||
|
||||
Gated by `allow_user_personas` policy.
|
||||
|
||||
```
|
||||
GET /personas → { "personas": [...], "personas": [...] }
|
||||
GET /personas → { "personas": [...] }
|
||||
POST /personas ← { "name", "description", "system_prompt", "model", ... }
|
||||
PUT /personas/:id ← partial update
|
||||
DELETE /personas/:id
|
||||
```
|
||||
|
||||
**Note:** List response currently sends both `"personas"` and `"personas"`
|
||||
keys (identical arrays) for backward compatibility. The `"personas"` key
|
||||
is what the frontend reads. Dual keys will be collapsed pre-1.0.
|
||||
|
||||
### 4.2 Team Personas
|
||||
|
||||
```
|
||||
GET /teams/:teamId/personas → { "personas": [...], "personas": [...] }
|
||||
GET /teams/:teamId/personas → { "personas": [...] }
|
||||
POST /teams/:teamId/personas ← same shape
|
||||
PUT /teams/:teamId/personas/:id
|
||||
DELETE /teams/:teamId/personas/:id
|
||||
@@ -670,7 +900,7 @@ DELETE /teams/:teamId/personas/:id
|
||||
### 4.3 Admin (Global) Personas
|
||||
|
||||
```
|
||||
GET /admin/personas → { "personas": [...], "personas": [...] }
|
||||
GET /admin/personas → { "personas": [...] }
|
||||
POST /admin/personas ← same shape
|
||||
PUT /admin/personas/:id
|
||||
DELETE /admin/personas/:id
|
||||
@@ -1512,9 +1742,6 @@ The primary endpoint for populating model selectors:
|
||||
GET /models/enabled
|
||||
```
|
||||
|
||||
> **Note:** `GET /models` exists as an alias. The alias is unused by
|
||||
> the frontend and will be removed pre-1.0.
|
||||
|
||||
Returns `{ "models": [UserModel objects] }`:
|
||||
|
||||
```json
|
||||
@@ -1536,23 +1763,59 @@ Returns `{ "models": [UserModel objects] }`:
|
||||
"output_price_per_m": 15.00,
|
||||
"provider_status": "healthy|degraded|down|null",
|
||||
"scope": "global|team|personal",
|
||||
"source": "catalog|heuristic"
|
||||
"source": "catalog|heuristic",
|
||||
"is_persona": false,
|
||||
"persona_id": "uuid|null",
|
||||
"persona_scope": "global|team|personal|null",
|
||||
"persona_avatar": "url|null",
|
||||
"persona_team_name": "string|null"
|
||||
}
|
||||
```
|
||||
|
||||
The capabilities on each model are resolved through the three-tier
|
||||
chain: catalog DB → heuristic inference → admin overrides (see §10.5).
|
||||
|
||||
When `is_persona` is `true`, the capability fields (`context_window`,
|
||||
`max_output_tokens`, `supports_*`, pricing, `provider_status`) are
|
||||
inherited from the persona's underlying model. The frontend renders
|
||||
the same capability pills and badges for a persona as for its raw
|
||||
model — the persona adds identity, not capability restrictions.
|
||||
|
||||
### 11.2 User Model Preferences
|
||||
|
||||
Users can hide models they don't want to see.
|
||||
Users can hide models they don't want to see and set per-model
|
||||
defaults. Preferences are keyed on the **composite identity**
|
||||
`provider_config_id:model_id` — the same model from different
|
||||
providers can have independent visibility.
|
||||
|
||||
```
|
||||
GET /models/preferences → { "preferences": { "model-id": { "hidden": true } } }
|
||||
PUT /models/preferences ← { "model_id": "...", "hidden": true }
|
||||
POST /models/preferences/bulk ← { "preferences": { "id1": { "hidden": true }, "id2": { ... } } }
|
||||
GET /models/preferences → { "preferences": [PreferenceEntry objects] }
|
||||
PUT /models/preferences ← { "model_id": "...", "provider_config_id": "uuid", "hidden": true }
|
||||
POST /models/preferences/bulk ← { "entries": [{ "model_id": "...", "provider_config_id": "uuid", "hidden": true }] }
|
||||
```
|
||||
|
||||
PreferenceEntry:
|
||||
|
||||
```json
|
||||
{
|
||||
"model_id": "grok-4.1-fast",
|
||||
"provider_config_id": "uuid",
|
||||
"hidden": true,
|
||||
"preferred_temperature": 0.7,
|
||||
"preferred_max_tokens": 4096,
|
||||
"sort_order": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Identity rule:** `provider_config_id` is required on write. The same
|
||||
bare `model_id` from two different provider configs (e.g. global Venice
|
||||
vs personal BYOK Venice) are independent preference entries. The
|
||||
frontend composite ID format is `{provider_config_id}:{model_id}`.
|
||||
|
||||
**Persona preferences:** Personas are not in this table. A persona's
|
||||
visibility is controlled by the grant system (§15.3) and the
|
||||
`is_active` flag, not by model preferences.
|
||||
|
||||
---
|
||||
|
||||
## 12. Notifications
|
||||
@@ -2022,6 +2285,121 @@ Docker images).
|
||||
|
||||
---
|
||||
|
||||
## 17b. Files & Storage
|
||||
|
||||
All binary content in Chat Switchboard flows through a single
|
||||
**ObjectStore** abstraction backed by PVC (filesystem) or S3. All
|
||||
file metadata lives in one `files` table. The old `attachments`
|
||||
table is dropped.
|
||||
|
||||
### 17b.1 Storage Backend
|
||||
|
||||
| Operation | Description |
|
||||
|-----------|-------------|
|
||||
| `Put(key, reader, size, contentType)` | Store a blob |
|
||||
| `Get(key)` → reader, size, contentType | Retrieve a blob |
|
||||
| `Delete(key)` | Remove a blob |
|
||||
| `DeletePrefix(prefix)` | Bulk remove (channel deletion) |
|
||||
| `Exists(key)` | Check without reading |
|
||||
| `Healthy()` | Backend health check |
|
||||
| `Stats()` | Aggregate metrics |
|
||||
|
||||
Backends: `pvc` (`STORAGE_PATH=/data/storage`) or `s3`
|
||||
(`S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`).
|
||||
|
||||
### 17b.2 Key Namespacing
|
||||
|
||||
| Prefix | Pattern | What |
|
||||
|--------|---------|------|
|
||||
| `attachments/` | `attachments/{channel_id}/{file_id}_{filename}` | User uploads + AI outputs (legacy prefix retained for existing blobs) |
|
||||
| `kb/` | `kb/{kb_id}/{doc_id}_{filename}` | Knowledge base documents |
|
||||
| `exports/` | `exports/{user_id}/{export_id}_{filename}` | Chat/data exports |
|
||||
| `workspace/` | `workspace/{workspace_id}/{path}` | Workspace files |
|
||||
|
||||
### 17b.3 File Object
|
||||
|
||||
```sql
|
||||
CREATE TABLE files (
|
||||
id UUID PRIMARY KEY,
|
||||
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
|
||||
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||
origin TEXT NOT NULL DEFAULT 'user_upload'
|
||||
CHECK (origin IN ('user_upload','tool_output','system')),
|
||||
filename TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
storage_key TEXT NOT NULL,
|
||||
display_hint TEXT NOT NULL DEFAULT 'download'
|
||||
CHECK (display_hint IN ('inline','download','thumbnail')),
|
||||
extracted_text TEXT,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
**Origin:**
|
||||
|
||||
| Value | Who creates | Examples |
|
||||
|-------|-------------|---------|
|
||||
| `user_upload` | User via upload UI | PDF, image, doc attached to message |
|
||||
| `tool_output` | Tool execution during completion | DALL-E image, video gen, code artifact |
|
||||
| `system` | Platform | Export, avatar stored as file |
|
||||
|
||||
**Display hint:**
|
||||
|
||||
| Value | Frontend behavior |
|
||||
|-------|-------------------|
|
||||
| `inline` | Render in message stream (images, video, audio, HTML) |
|
||||
| `download` | Filename + size + download button |
|
||||
| `thumbnail` | Thumbnail preview, click to expand |
|
||||
|
||||
### 17b.4 Tool Output Flow
|
||||
|
||||
1. Tool executes (image gen, video render, etc.)
|
||||
2. Tool writes blob via `ObjectStore.Put`
|
||||
3. Tool creates file record: `POST /files` with `origin: "tool_output"`, `message_id` = current assistant message
|
||||
4. Tool result includes `file_id` reference
|
||||
5. WebSocket emits `file.created` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "file.created",
|
||||
"channel_id": "uuid",
|
||||
"message_id": "uuid",
|
||||
"file": { ...file object... }
|
||||
}
|
||||
```
|
||||
|
||||
6. Frontend renders inline based on `display_hint` + `content_type`
|
||||
|
||||
The event fires during streaming so the user sees the artifact
|
||||
before the completion finishes.
|
||||
|
||||
### 17b.5 Content Type → Display Hint Defaults
|
||||
|
||||
| Content Type | Default Hint | Rendering |
|
||||
|-------------|-------------|-----------|
|
||||
| `image/*` | `inline` | `<img>` in message, lightbox on click |
|
||||
| `video/*` | `inline` | `<video>` player |
|
||||
| `audio/*` | `inline` | `<audio>` player |
|
||||
| `text/html` | `inline` | Sandboxed iframe |
|
||||
| `application/pdf` | `thumbnail` | Thumb + PDF viewer on click |
|
||||
| `text/*`, `application/json` | `inline` | Syntax-highlighted code block |
|
||||
| Everything else | `download` | Filename + download button |
|
||||
|
||||
Tools can override the default hint explicitly.
|
||||
|
||||
### 17b.6 File Manager (Future Surface)
|
||||
|
||||
A user-scoped file browser backed by `GET /files?page=1&per_page=50`.
|
||||
Displays all files owned by the user across all channels, grouped by
|
||||
channel or date. Supports download and delete. Deleting a file that
|
||||
is referenced by a message replaces the inline render with a
|
||||
"file deleted" placeholder.
|
||||
|
||||
## 18. WebSocket Protocol
|
||||
|
||||
Real-time bidirectional event bus. Single connection per client.
|
||||
@@ -2069,8 +2447,12 @@ Clients subscribe to rooms for scoped event delivery:
|
||||
| `message.updated` | → client | Message content changed |
|
||||
| `message.deleted` | → client | Message removed |
|
||||
| `cursor.updated` | → client | Branch cursor moved |
|
||||
| `typing.start` | ↔ both | User/AI started typing |
|
||||
| `typing.start` | ↔ both | Participant started typing (payload includes `participant_id`, `participant_type`) |
|
||||
| `typing.stop` | ↔ both | Typing ended |
|
||||
| `participant.joined` | → client | Participant added to channel |
|
||||
| `participant.left` | → client | Participant removed from channel |
|
||||
| `participant.updated` | → client | Participant role changed |
|
||||
| `presence.update` | → client | Participant online/idle/offline status change |
|
||||
| `model.changed` | → client | Channel model roster changed |
|
||||
| `persona.updated` | → client | Persona metadata changed |
|
||||
| `kb.updated` | → client | Knowledge base changed |
|
||||
@@ -2088,6 +2470,8 @@ Clients subscribe to rooms for scoped event delivery:
|
||||
| `extension.updated` | → client | Extension config changed |
|
||||
| `settings.updated` | → client | Global settings changed |
|
||||
| `user.updated` | → client | User profile changed |
|
||||
| `file.created` | → client | File uploaded or generated (§17b.4) |
|
||||
| `file.deleted` | → client | File removed |
|
||||
|
||||
Direction: `→ client` = server-to-client only, `← client` = client-to-server
|
||||
only, `↔ both` = bidirectional.
|
||||
@@ -2098,7 +2482,19 @@ only, `↔ both` = bidirectional.
|
||||
|
||||
### Channel Types
|
||||
|
||||
`direct` (current default), `group` (v0.23.0), `workflow` (v0.25.0)
|
||||
`direct` (single-user), `group` (multi-user/multi-model), `workflow` (staged)
|
||||
|
||||
### Participant Types
|
||||
|
||||
`user`, `persona`, `session`
|
||||
|
||||
### Participant Roles
|
||||
|
||||
`owner`, `member`, `observer`
|
||||
|
||||
### Presence Statuses
|
||||
|
||||
`online`, `idle`, `offline`
|
||||
|
||||
### Message Roles
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# ICD Audit: API Contract vs ROADMAP / ARCHITECTURE
|
||||
|
||||
**Date:** 2026-03-03 | **Baseline:** v0.22.7
|
||||
**Date:** 2026-03-04 | **Baseline:** v0.23.0
|
||||
**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.
|
||||
**Status:** §2 (naming) fully resolved — preset→persona rename complete across all JS, templates, CSS, Go handlers, and ICD. §3 (cleanup) resolved. §4.1 (chat_id alias) resolved. §4.2 (legacy list messages) open. ICD updated to target state including multi-participant channels.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user