Changeset 0.23.1 (#154)

This commit is contained in:
2026-03-06 13:26:25 +00:00
parent 2fc620e1ac
commit 4c6555cb06
38 changed files with 1536 additions and 4031 deletions

View File

@@ -19,7 +19,12 @@ RUN go mod download
# Copy source and tidy (resolves any go.sum gaps)
COPY server/ .
RUN go mod tidy
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${APP_VERSION}" -o /bin/switchboard .
# Copy VERSION from repo root — used by ldflags and copied to runtime image.
# If APP_VERSION build-arg was supplied by CI, it takes precedence via ldflags.
# If not, $(cat VERSION) reads the file directly so the binary always has a real version.
COPY VERSION ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/switchboard .
# Runtime stage
FROM debian:bookworm-slim
@@ -31,6 +36,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
COPY --from=builder /app/database/migrations /app/database/migrations
# VERSION file at /VERSION — version.go init() reads it as fallback for local/dev runs
COPY --from=builder /app/VERSION /VERSION
# Builtin extensions (seeded into DB on startup)
COPY extensions/builtin/ /app/extensions/builtin/

View File

@@ -17,6 +17,14 @@ import (
// DB is the application-wide database connection pool.
var DB *sql.DB
// rawDSN holds the Postgres connection string for LISTEN/NOTIFY consumers.
// Empty when running SQLite.
var rawDSN string
// DSN returns the raw Postgres connection string, or "" for SQLite.
// Used by the event broadcast layer to open a dedicated listener connection.
func DSN() string { return rawDSN }
// Connect opens a connection pool to the configured database.
// Driver is selected by DB_DRIVER env var: "postgres" (default) or "sqlite".
// For SQLite the DATABASE_URL is treated as a file path; ":memory:" is supported.
@@ -45,6 +53,7 @@ func connectPostgres(dsn string) error {
// Log the database name for operational clarity (never log credentials).
dbName = extractDBName(dsn)
rawDSN = dsn
log.Printf("Connecting to Postgres database %q ...", dbName)
var err error

View File

@@ -0,0 +1,46 @@
-- ==========================================
-- Chat Switchboard — 006 Multi-User: DMs + Channels
-- ==========================================
-- Extends channels with 'dm' and 'channel' types, ai_mode, and topic.
-- Adds presence tracking table.
-- ICD §3 (Channels & Conversations) — v0.23.1
-- ==========================================
-- ── Channel type extension ───────────────────────────────────────────
-- Add 'dm' (human-to-human, AI silent unless @mentioned)
-- Add 'channel' (named persistent space, configurable ai_mode)
-- Existing 'direct', 'group', 'workflow' are unchanged.
ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_type_check;
ALTER TABLE channels ADD CONSTRAINT channels_type_check
CHECK (type IN ('direct', 'dm', 'group', 'channel', 'workflow'));
-- ── ai_mode ──────────────────────────────────────────────────────────
-- auto : AI responds to every message (existing behavior for 'direct')
-- mention_only : AI silent unless @mentioned (default for 'dm')
-- off : AI disabled entirely
ALTER TABLE channels
ADD COLUMN IF NOT EXISTS ai_mode VARCHAR(20) NOT NULL DEFAULT 'auto'
CHECK (ai_mode IN ('auto', 'mention_only', 'off'));
-- Back-fill: DM channels should be mention_only
-- (No existing rows should have type='dm' yet, but be safe)
UPDATE channels SET ai_mode = 'mention_only' WHERE type = 'dm' AND ai_mode = 'auto';
-- ── topic ─────────────────────────────────────────────────────────────
ALTER TABLE channels
ADD COLUMN IF NOT EXISTS topic TEXT;
-- ── Presence (in-memory heartbeat, DB stores last_seen for persistence) ──
-- Not queried at high frequency — updated by heartbeat (30s), read on load.
CREATE TABLE IF NOT EXISTS user_presence (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(20) NOT NULL DEFAULT 'online'
CHECK (status IN ('online', 'away', 'offline'))
);
CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC);
COMMENT ON TABLE user_presence IS 'Heartbeat-driven presence. Rows upserted every 30s by active clients.';
COMMENT ON COLUMN channels.ai_mode IS 'auto=respond always, mention_only=@mention required, off=AI disabled';
COMMENT ON COLUMN channels.topic IS 'Short channel description shown in header';

View File

@@ -0,0 +1,23 @@
-- ==========================================
-- Chat Switchboard — 006 Multi-User: DMs + Channels (SQLite)
-- ==========================================
-- SQLite does not support ALTER CONSTRAINT or DROP CONSTRAINT.
-- The type check is enforced by application logic for SQLite.
-- ==========================================
-- ── ai_mode ──────────────────────────────────────────────────────────
-- SQLite ignores CHECK constraints on existing rows; we add the column
-- and rely on app-layer validation for the enum values.
ALTER TABLE channels ADD COLUMN ai_mode TEXT NOT NULL DEFAULT 'auto';
-- ── topic ─────────────────────────────────────────────────────────────
ALTER TABLE channels ADD COLUMN topic TEXT;
-- ── Presence ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS user_presence (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_seen DATETIME NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'online'
);
CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC);

View File

@@ -10,9 +10,10 @@ import (
// Handlers subscribe to patterns (exact or trailing wildcard).
// Publishing fans out to all matching subscribers.
type Bus struct {
mu sync.RWMutex
subs map[string][]*subscription
seq uint64 // subscription ID counter
mu sync.RWMutex
subs map[string][]*subscription
seq uint64 // subscription ID counter
broadcastHook func(Event) // called after Publish for cross-pod fan-out; nil-safe
}
type subscription struct {
@@ -28,6 +29,15 @@ func NewBus() *Bus {
}
}
// SetBroadcastHook registers a function called after every Publish.
// Used by the Postgres LISTEN/NOTIFY adapter to fan out across pods.
// The hook is NOT called by publishLocal (avoids re-broadcast loops).
func (b *Bus) SetBroadcastHook(fn func(Event)) {
b.mu.Lock()
b.broadcastHook = fn
b.mu.Unlock()
}
// Subscribe registers a handler for a label pattern.
// Returns an unsubscribe function.
//
@@ -60,7 +70,22 @@ func (b *Bus) Subscribe(pattern string, handler Handler) func() {
// Publish dispatches an event to all matching subscribers.
// Handlers are called synchronously in subscription order.
// Use PublishAsync for non-blocking dispatch.
// After local dispatch, calls the broadcastHook if set (Postgres fan-out).
func (b *Bus) Publish(event Event) {
b.publishLocal(event)
b.mu.RLock()
hook := b.broadcastHook
b.mu.RUnlock()
if hook != nil {
hook(event)
}
}
// publishLocal dispatches to local subscribers only, without triggering
// the broadcastHook. Used by the Postgres listener to re-publish remote
// events without causing an infinite re-broadcast loop.
func (b *Bus) publishLocal(event Event) {
b.mu.RLock()
var matched []Handler
for pattern, subs := range b.subs {
@@ -79,6 +104,7 @@ func (b *Bus) Publish(event Event) {
// PublishAsync dispatches an event to all matching subscribers
// in separate goroutines. Useful for I/O-heavy handlers.
// Also calls the broadcastHook asynchronously if set.
func (b *Bus) PublishAsync(event Event) {
b.mu.RLock()
var matched []Handler
@@ -89,11 +115,15 @@ func (b *Bus) PublishAsync(event Event) {
}
}
}
hook := b.broadcastHook
b.mu.RUnlock()
for _, h := range matched {
go h(event)
}
if hook != nil {
go hook(event)
}
}
// match checks if a concrete label matches a subscription pattern.

View File

@@ -0,0 +1,130 @@
package events
// pg_broadcast.go — Postgres LISTEN/NOTIFY adapter for multi-pod fan-out.
//
// Problem: the in-process Bus is per-pod. With N backend replicas, an event
// published on pod-0 (e.g. a chat message completion) is never seen by clients
// connected to pod-1.
//
// Solution: for every event that should reach clients (DirToClient | DirBoth),
// publish via pg_notify so every pod's listener goroutine receives it and
// re-publishes into its local Bus → Hub → WebSocket clients.
//
// Key invariant: re-published (remote) events use publishLocal, which skips the
// broadcastHook, preventing infinite re-broadcast loops.
//
// No-op when running SQLite (single process, in-process Bus is sufficient).
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/lib/pq"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
const pgNotifyChannel = "switchboard_events"
// maxNotifyBytes is the practical PG NOTIFY payload limit (8192 byte hard limit,
// leave headroom for encoding overhead).
const maxNotifyBytes = 7900
// StartPGBroadcast wires the Bus to Postgres LISTEN/NOTIFY.
// Call once after bus and database are both initialized.
// No-op for SQLite deployments.
func StartPGBroadcast(bus *Bus) {
if !database.IsPostgres() {
log.Println("[pg_broadcast] SQLite mode — skipping cross-pod broadcast")
return
}
// Start the listener goroutine (auto-reconnects on failure).
go broadcastListenLoop(bus)
// Hook into the Bus: after every local Publish, fan out to other pods.
bus.SetBroadcastHook(func(e Event) {
// Only events that clients care about need to cross pod boundaries.
dir := RouteFor(e.Label)
if dir == DirLocal {
return
}
// tool.result.* arrives from a client WS on a specific pod — that pod
// handles WaitFor() locally. No need (or benefit) to broadcast.
if dir == DirFromClient {
return
}
data, err := json.Marshal(e)
if err != nil {
return
}
if len(data) > maxNotifyBytes {
// Event payload too large for NOTIFY — this should never happen
// in practice (we notify IDs/metadata, not message bodies).
log.Printf("[pg_broadcast] event %q payload %d bytes exceeds limit — skipping broadcast", e.Label, len(data))
return
}
if _, err := database.DB.Exec("SELECT pg_notify($1, $2)", pgNotifyChannel, string(data)); err != nil {
log.Printf("[pg_broadcast] pg_notify failed: %v", err)
}
})
log.Println("[pg_broadcast] cross-pod broadcast enabled via Postgres LISTEN/NOTIFY")
}
// broadcastListenLoop runs forever, reconnecting on error.
func broadcastListenLoop(bus *Bus) {
dsn := database.DSN()
for {
if err := runBroadcastListener(bus, dsn); err != nil {
log.Printf("[pg_broadcast] listener error: %v — reconnecting in 5s", err)
}
time.Sleep(5 * time.Second)
}
}
// runBroadcastListener opens a dedicated pq listener connection and feeds
// received notifications into the local Bus via publishLocal.
// Returns an error when the connection is lost (caller retries).
func runBroadcastListener(bus *Bus, dsn string) error {
reportErr := func(ev pq.ListenerEventType, err error) {
if err != nil {
log.Printf("[pg_broadcast] pq listener event=%d err=%v", ev, err)
}
}
l := pq.NewListener(dsn, 5*time.Second, time.Minute, reportErr)
if err := l.Listen(pgNotifyChannel); err != nil {
return fmt.Errorf("LISTEN %s: %w", pgNotifyChannel, err)
}
defer l.Close()
log.Printf("[pg_broadcast] listener ready on channel %q", pgNotifyChannel)
for {
select {
case n, ok := <-l.Notify:
if !ok {
return fmt.Errorf("notify channel closed")
}
if n == nil {
// Keepalive ping from pq — ignore.
continue
}
var e Event
if err := json.Unmarshal([]byte(n.Extra), &e); err != nil {
log.Printf("[pg_broadcast] malformed notification: %v", err)
continue
}
// publishLocal bypasses the broadcastHook — no re-broadcast loop.
bus.publishLocal(e)
}
}
}

View File

@@ -140,6 +140,11 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
archived := c.DefaultQuery("archived", "false")
folder := c.Query("folder")
channelType := c.DefaultQuery("type", "") // empty = all types
channelTypes := c.QueryArray("types") // v0.23.1: multi-value, e.g. ?types=dm&types=channel
// Comma-joined convenience: ?types=dm,channel
if len(channelTypes) == 0 && c.Query("types") != "" {
channelTypes = strings.Split(c.Query("types"), ",")
}
search := strings.TrimSpace(c.Query("search"))
projectFilter := c.Query("project_id") // "uuid" or "none"
@@ -148,7 +153,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
countArgs := []interface{}{userID, archived == "true"}
argN := 3
if channelType != "" {
if len(channelTypes) > 0 {
placeholders := make([]string, len(channelTypes))
for i, t := range channelTypes {
placeholders[i] = "$" + strconv.Itoa(argN)
countArgs = append(countArgs, strings.TrimSpace(t))
argN++
}
countQuery += " AND type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
countQuery += ` AND type = $` + strconv.Itoa(argN)
countArgs = append(countArgs, channelType)
argN++
@@ -193,7 +206,15 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
args := []interface{}{userID, archived == "true"}
argN = 3
if channelType != "" {
if len(channelTypes) > 0 {
placeholders := make([]string, len(channelTypes))
for i, t := range channelTypes {
placeholders[i] = "$" + strconv.Itoa(argN)
args = append(args, strings.TrimSpace(t))
argN++
}
query += " AND c.type IN (" + strings.Join(placeholders, ",") + ")"
} else if channelType != "" {
query += ` AND c.type = $` + strconv.Itoa(argN)
args = append(args, channelType)
argN++

View File

@@ -209,6 +209,27 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
}
// ── ai_mode guard (v0.23.1) ────────────────────────────────────────────────
// Check channel ai_mode before doing any work. For DM channels with
// mention_only mode and no @mention in the content, persist the message
// but skip the completion entirely.
{
var aiMode string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1
`), channelID).Scan(&aiMode)
if aiMode == "off" {
c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"})
return
}
if aiMode == "mention_only" && extractFirstMention(req.Content) == "" {
// Deliver message without triggering completion
c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true})
return
}
}
// ── Persona unwrap: persona overrides defaults, explicit request fields win ──
var personaSystemPrompt string
var personaID string // tracks active persona for KB scoping
@@ -386,10 +407,26 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Resolve effective workspace: channel.workspace_id > project.workspace_id
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
// ── @mention routing (v0.23.0) ──────────────
// Resolve @model-id or @persona-handle directly against the enabled
// model catalog and personas table. Works in ANY chat — no roster needed.
if mentionModel, mentionConfig, mentionPersona := h.resolveMention(c.Request.Context(), userID, req.Content); mentionModel != "" {
// ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
// Resolve @persona-handle, @model-id, or @username.
// - User mention → skip completion, notify recipient (v0.23.1)
// - AI mention → override model/persona for this turn (v0.23.0)
mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content)
if mentionedUserID != "" {
// Human @mention — message already persisted; notify, skip AI.
if hub, ok := c.Get("events_hub"); ok {
if h2, ok2 := hub.(interface {
NotifyUserMention(toUser, channel, fromUser string)
}); ok2 {
h2.NotifyUserMention(mentionedUserID, channelID, userID)
}
}
c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID})
return
}
if mentionModel != "" {
req.Model = mentionModel
if mentionConfig != "" {
req.ProviderConfigID = mentionConfig
@@ -1127,15 +1164,20 @@ func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPerso
//
// Resolution order:
// 1. Persona handle (exact match, then prefix)
// 2. Model ID in enabled catalog (exact match, then prefix)
// 2. User handle/username (exact, then prefix) — v0.23.1
// When a user is @mentioned in a DM/channel, skip completion and deliver
// a notification instead. Returns non-empty mentionedUserID in that case.
// 3. Model ID in enabled catalog (exact match, then prefix)
//
// Returns (modelID, providerConfigID, *Persona) — all empty if no @mention found.
// Returns (modelID, providerConfigID, *Persona, mentionedUserID).
// Exactly one of (modelID, mentionedUserID) will be non-empty on a successful
// match, or all empty if no @mention found/resolved.
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona) {
func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona, string) {
// Extract first @token
token := extractFirstMention(content)
if token == "" {
return "", "", nil
return "", "", nil, ""
}
// Normalize: lowercase, hyphens
@@ -1155,7 +1197,7 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
return p.BaseModelID, cfgID, p, ""
}
}
@@ -1177,13 +1219,42 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
cfgID = *p.ProviderConfigID
}
log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID)
return p.BaseModelID, cfgID, p
return p.BaseModelID, cfgID, p, ""
}
}
}
}
// 3. Try model_id in enabled catalog (exact)
// 3. Try username (exact match) — v0.23.1
// Only resolves to a user if the caller is not the same user (no self-mention)
var mentionedUserID string
err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT id FROM users
WHERE LOWER(username) = $1 AND id != $2 AND is_approved = true
LIMIT 1
`), normalized, userID).Scan(&mentionedUserID)
if err == nil && mentionedUserID != "" {
log.Printf("[mention] @%s → user %s", token, mentionedUserID)
return "", "", nil, mentionedUserID
}
// 4. Try username (prefix — unambiguous only) — v0.23.1
var userCount int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(*) FROM users
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
`), normalized+"%", userID).Scan(&userCount)
if userCount == 1 {
database.DB.QueryRowContext(ctx, database.Q(`
SELECT id FROM users
WHERE LOWER(username) LIKE $1 AND id != $2 AND is_approved = true
LIMIT 1
`), normalized+"%", userID).Scan(&mentionedUserID)
if mentionedUserID != "" {
log.Printf("[mention] @%s → user prefix %s", token, mentionedUserID)
return "", "", nil, mentionedUserID
}
}
var modelID, provConfigID string
err = database.DB.QueryRowContext(ctx, database.Q(`
SELECT mc.model_id, mc.provider_config_id
@@ -1198,10 +1269,10 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
`), normalized).Scan(&modelID, &provConfigID)
if err == nil && modelID != "" {
log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
return modelID, provConfigID, nil, ""
}
// 4. Try model_id prefix (unambiguous)
// 5. Try model_id prefix (unambiguous)
var prefixCount int
database.DB.QueryRowContext(ctx, database.Q(`
SELECT COUNT(DISTINCT mc.model_id)
@@ -1224,11 +1295,11 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content
`), normalized+"%").Scan(&modelID, &provConfigID)
if modelID != "" {
log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID)
return modelID, provConfigID, nil
return modelID, provConfigID, nil, ""
}
}
return "", "", nil
return "", "", nil, ""
}
// extractFirstMention finds the first @token in content.

View File

@@ -49,7 +49,7 @@ func (h *CompletionHandler) chainIfMentioned(
log.Printf("[chain] Found @%s in response from persona %s (depth=%d)", token, currentPersonaID[:min(8, len(currentPersonaID))], depth)
// Use the same resolveMention() that handles user @mentions
mentionModel, mentionConfig, mentionPersona := h.resolveMention(ctx, userID, responseContent)
mentionModel, mentionConfig, mentionPersona, _ := h.resolveMention(ctx, userID, responseContent)
if mentionModel == "" {
log.Printf("[chain] @%s did not resolve to any model", token)
return

146
server/handlers/folders.go Normal file
View File

@@ -0,0 +1,146 @@
package handlers
// folders.go — Chat folder CRUD (v0.23.1)
//
// Folders are user-scoped groupings for personal (direct) chats.
// Routes:
// GET /api/v1/folders
// POST /api/v1/folders
// PUT /api/v1/folders/:id
// DELETE /api/v1/folders/:id
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
type FolderHandler struct{}
func NewFolderHandler() *FolderHandler { return &FolderHandler{} }
type folderRow struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID *string `json:"parent_id,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (h *FolderHandler) List(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
SELECT id, name, parent_id, sort_order, created_at, updated_at
FROM folders
WHERE user_id = $1
ORDER BY sort_order, name
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
defer rows.Close()
var folders []folderRow
for rows.Next() {
var f folderRow
if err := rows.Scan(&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt); err != nil {
continue
}
folders = append(folders, f)
}
if folders == nil {
folders = []folderRow{}
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
}
func (h *FolderHandler) Create(c *gin.Context) {
userID := getUserID(c)
var req struct {
Name string `json:"name" binding:"required"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
var f folderRow
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
INSERT INTO folders (user_id, name, sort_order)
VALUES ($1, $2, $3)
RETURNING id, name, parent_id, sort_order, created_at, updated_at
`), userID, req.Name, req.SortOrder).Scan(
&f.ID, &f.Name, &f.ParentID, &f.SortOrder, &f.CreatedAt, &f.UpdatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create folder"})
return
}
c.JSON(http.StatusCreated, gin.H{"folder": f})
}
func (h *FolderHandler) Update(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
var req struct {
Name string `json:"name"`
SortOrder *int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
req.Name = strings.TrimSpace(req.Name)
}
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE folders
SET name = COALESCE(NULLIF($3, ''), name),
sort_order = COALESCE($4, sort_order)
WHERE id = $1 AND user_id = $2
`), folderID, userID, req.Name, req.SortOrder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update folder"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *FolderHandler) Delete(c *gin.Context) {
userID := getUserID(c)
folderID := c.Param("id")
// Unassign chats before deleting folder
_, _ = database.DB.ExecContext(c.Request.Context(), database.Q(`
UPDATE channels SET folder_id = NULL WHERE folder_id = $1 AND user_id = $2
`), folderID, userID)
res, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
DELETE FROM folders WHERE id = $1 AND user_id = $2
`), folderID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete folder"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "folder not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,69 @@
package handlers
// presence.go — Heartbeat upsert and status query (v0.23.1)
//
// Clients POST /api/v1/presence/heartbeat every 30s while active.
// GET /api/v1/presence?users=id1,id2 returns current status.
// Online = last_seen within 90s.
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
const presenceOnlineThreshold = 90 * time.Second
// PresenceHeartbeat upserts the calling user's last_seen timestamp.
func PresenceHeartbeat(c *gin.Context) {
userID := getUserID(c)
_, err := database.DB.ExecContext(c.Request.Context(), database.Q(`
INSERT INTO user_presence (user_id, last_seen, status)
VALUES ($1, NOW(), 'online')
ON CONFLICT (user_id) DO UPDATE
SET last_seen = NOW(), status = 'online'
`), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "presence update failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// PresenceQuery returns online/offline status for a list of user IDs.
// Query param: ?users=uuid1,uuid2,...
func PresenceQuery(c *gin.Context) {
raw := c.Query("users")
if raw == "" {
c.JSON(http.StatusOK, gin.H{"presence": map[string]string{}})
return
}
ids := strings.Split(raw, ",")
if len(ids) > 100 {
ids = ids[:100]
}
threshold := time.Now().Add(-presenceOnlineThreshold)
result := make(map[string]string, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
var lastSeen time.Time
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT last_seen FROM user_presence WHERE user_id = $1
`), id).Scan(&lastSeen)
if err != nil || lastSeen.Before(threshold) {
result[id] = "offline"
} else {
result[id] = "online"
}
}
c.JSON(http.StatusOK, gin.H{"presence": result})
}

View File

@@ -198,6 +198,10 @@ func main() {
// ── EventBus (created early — needed by role resolver) ──
bus := events.NewBus()
// Wire Postgres LISTEN/NOTIFY for cross-pod event fan-out (multi-replica).
// No-op when running SQLite — in-process Bus is sufficient for single-pod.
events.StartPGBroadcast(bus)
// ── Workspace FS (v0.21.0) ──────────────
// Provides file operations for workspace storage primitive.
// Nil-safe: handler checks wfs != nil before operating.
@@ -373,6 +377,17 @@ func main() {
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Chat Folders (v0.23.1)
folders := handlers.NewFolderHandler()
protected.GET("/folders", folders.List)
protected.POST("/folders", folders.Create)
protected.PUT("/folders/:id", folders.Update)
protected.DELETE("/folders/:id", folders.Delete)
// Presence (v0.23.1)
protected.POST("/presence/heartbeat", handlers.PresenceHeartbeat)
protected.GET("/presence", handlers.PresenceQuery)
// Channel models (v0.20.0 — multi-model @mention routing)
chModelH := handlers.NewChannelModelHandler(stores)
protected.GET("/channels/:id/models", chModelH.List)

View File

@@ -88,35 +88,11 @@ window.addEventListener('unhandledrejection', function(e) {
<span id="brandWordmark" class="brand-text" style="display:none;"></span>
</div>
{{/* New Chat (split button) */}}
<div class="split-btn">
<button id="newChatBtn" class="split-btn-main sb-btn" title="New chat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="sb-label">New Chat</span>
</button>
<button id="newChatDropBtn" class="split-btn-drop" title="More options">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div id="newChatDropdown" class="split-dropdown">
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newChat();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<span>New Chat</span>
</button>
<button class="split-dropdown-item" onclick="closeNewChatMenu(); if(typeof createProject==='function') createProject();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
<span>New Project</span>
</button>
<button class="split-dropdown-item" onclick="closeNewChatMenu(); newGroupChat();">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Group Chat</span>
</button>
<button class="split-dropdown-item disabled" title="Coming soon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>New Channel</span>
<span class="dd-hint">soon</span>
</button>
</div>
</div>
{{/* New Chat button */}}
<button id="newChatBtn" class="sb-btn" onclick="newChat()" title="New chat">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="sb-label">New Chat</span>
</button>
</div>
{{/* Search (outside sidebar-top, below brand section) */}}
@@ -132,22 +108,72 @@ window.addEventListener('unhandledrejection', function(e) {
</button>
{{/* Sidebar Tabs (Chats / Files) */}}
{{/* Sidebar Tabs (kept hidden — only Files tab used by EditorMode) */}}
<div id="sidebarTabs" class="sidebar-tabs" style="display:none;">
<button class="sidebar-tab active" data-tab="chats">Chats</button>
<button id="sidebarFilesTab" class="sidebar-tab" data-tab="files" style="display:none;">Files</button>
</div>
{{/* Chat list */}}
<div id="chatHistory" class="sidebar-chats sidebar-tab-panel" data-tab-panel="chats">
{{/* Populated by UI.renderChatList() */}}
{{/* Three-section nav: Projects / Channels / Chats */}}
<div id="sidebarNav" class="sidebar-nav sidebar-tab-panel" data-tab-panel="chats">
{{/* Projects section */}}
<div class="sb-section" id="sbSectionProjects">
<div class="sb-section-header" onclick="UI.toggleSidebarSection('projects')">
<span class="sb-section-arrow" id="sbArrowProjects"></span>
<span class="sb-section-label">Projects</span>
<button class="sb-section-add" title="New project"
onclick="event.stopPropagation();if(typeof createProject==='function')createProject();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="sb-section-body" id="sbBodyProjects">
{{/* Populated by UI.renderProjectsSection() */}}
</div>
</div>
{{/* Channels section (DMs + named channels) */}}
<div class="sb-section" id="sbSectionChannels">
<div class="sb-section-header" onclick="UI.toggleSidebarSection('channels')">
<span class="sb-section-arrow" id="sbArrowChannels"></span>
<span class="sb-section-label">Channels</span>
<button class="sb-section-add" title="New channel"
onclick="event.stopPropagation();newChannelOrDM();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div class="sb-section-body" id="sbBodyChannels">
{{/* Populated by UI.renderChannelsSection() */}}
</div>
</div>
{{/* Chats section (personal AI chats + folders) */}}
<div class="sb-section" id="sbSectionChats">
<div class="sb-section-header" onclick="UI.toggleSidebarSection('chats')">
<span class="sb-section-arrow" id="sbArrowChats"></span>
<span class="sb-section-label">Chats</span>
<div class="sb-section-add-group" onclick="event.stopPropagation();">
<button class="sb-section-add" title="New chat" onclick="newChat();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="sb-section-add" title="New folder" onclick="newFolder();">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button>
</div>
</div>
<div class="sb-section-body" id="sbBodyChats">
{{/* Populated by UI.renderChatList() */}}
</div>
</div>
</div>
{{/* Legacy chatHistory id kept for JS compatibility during transition */}}
<div id="chatHistory" style="display:none;"></div>
{{/* Files panel (editor mode) */}}
<div class="sidebar-tab-panel" data-tab-panel="files" style="display:none;"></div>
{{/* User / bottom */}}
<div class="sidebar-bottom">
{{/* Notes shortcut */}}
<button id="notesBtn" class="sb-btn sidebar-notes-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
<span class="sb-label">Notes</span>