Changeset 0.23.1 (#154)
This commit is contained in:
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
46
server/database/migrations/016_v023_multiuser.sql
Normal file
46
server/database/migrations/016_v023_multiuser.sql
Normal 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';
|
||||
23
server/database/migrations/sqlite/016_v023_multiuser.sql
Normal file
23
server/database/migrations/sqlite/016_v023_multiuser.sql
Normal 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);
|
||||
@@ -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.
|
||||
|
||||
130
server/events/pg_broadcast.go
Normal file
130
server/events/pg_broadcast.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
146
server/handlers/folders.go
Normal 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})
|
||||
}
|
||||
69
server/handlers/presence.go
Normal file
69
server/handlers/presence.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user