12 Commits
Author SHA1 Message Date
Algis DumbrisandClaude Opus 4.6 fef2705d08 fix: URL auto-linking truncated — extract URLs before HTML escaping
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
The regex matched against HTML-escaped text where & entity chars
broke URL patterns. Now URLs are extracted and replaced with placeholders
before escapeHtml runs, then restored after all other inline processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 08:08:18 +02:00
Algis DumbrisandClaude Opus 4.6 faad47f0cf fix: rebuild embedded dist with all UI changes integrated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:54:00 +02:00
Algis DumbrisandClaude Opus 4.6 47ba1e9740 feat: rework Conversations page into search-only with Slack-style filters
Replace the Conversations page with a dedicated search page featuring:
- Large, prominent search input with autofocus
- Collapsible filter panel with time range presets (24h, week, month,
  3 months, custom date range), channel filter (comma-separated,
  - prefix to exclude), and agent filter (same syntax)
- Search-results-only display with helpful empty state when no search
  has been performed
- Remove duplicate "Conversations" heading, rename to "Search"
- Update sidebar nav label and icon to match

Backend changes:
- Add channel, agent, after, before query parameters to
  GET /api/messages/search endpoint
- Add Channels, ExcludeChannels, Agents, ExcludeAgents fields to
  SearchOptions with SQL filter generation in store.go
- Support include/exclude semantics via - prefix for both channel
  and agent filters

API client updated to pass new filter parameters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:47:22 +02:00
Algis Dumbris 6d817867f2 Merge branch 'worktree-agent-adc6e051'
# Conflicts:
#	internal/web/dist/index.html
2026-03-15 19:47:12 +02:00
Algis Dumbris 877900af98 Merge branch 'worktree-agent-a46e8e73' 2026-03-15 19:47:04 +02:00
Algis DumbrisandClaude Opus 4.6 640838d5a6 feat: MessageBody component with markdown, links, @mentions, #channels
Replace plain-text message rendering with a rich MessageBody component
that supports bold, italic, inline code, fenced code blocks, lists,
headers, auto-linked URLs, @mention pills (linking to /dm/{name}), and
#channel pills (linking to /channels/{name}). Input is HTML-sanitized
before processing to prevent XSS. Updated all 5 rendering locations:
channels, DMs, conversations, MessageList, and ThreadPanel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:43:00 +02:00
Algis DumbrisandClaude Opus 4.6 6abd45eeff fix: remove API Keys management section from Settings page
API keys are managed via admin CLI, not the web UI. Remove the
Management section from Settings, delete the api-keys route, and
clean up the Header page-title mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:42:36 +02:00
Algis DumbrisandClaude Opus 4.6 e94dcb84fd fix: consolidate search — remove sidebar search box, enlarge header search
Remove the duplicate "Search messages" button from the sidebar and make
the header search input bigger (w-80, text-sm, larger padding/icon) with
"Search messages..." placeholder matching the removed sidebar text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:42:00 +02:00
Algis DumbrisandClaude Opus 4.6 96d9fbd246 fix: unify favicon and OAuth logo with constellation icon
Replace generic cube favicon and OAuth layered-planes logo with the
same constellation icon used in the sidebar and login page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:07:57 +02:00
Algis DumbrisandClaude Opus 4.6 5938fcd555 fix: bugs #1-3-5 from #bugs-synapbus + live SSE notifications
- Auto-join public channels on first send (bug #1)
- Channel broadcasts no longer create duplicate DM copies; inbox DMs
  only sent for @mentions (bug #2)
- Embedding pipeline auto-enqueues new messages via MessageListener
  callback instead of requiring pod restart (bug #3)
- Admin socket defaults to /tmp in containers to avoid PVC filesystem
  incompatibility with Unix sockets (bug #5)
- SSE events now fire for MCP-sent messages (not just REST API),
  enabling live notification badges without page reload
- Fixed frontend SSE field name mismatch (channel_name → channel)
- Fixed SSE client not connecting after login redirect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:56:30 +02:00
Algis DumbrisandClaude Opus 4.6 e4b0439e6e merge: 006-admin-cli-docker-fixes — alpine base, channels create/join CLI, absolute socket path
Release / Build darwin/amd64 (push) Canceled after 0s
Release / Build linux/amd64 (push) Canceled after 0s
Release / Build darwin/arm64 (push) Canceled after 0s
Release / Build linux/arm64 (push) Canceled after 0s
Release / Generate Homebrew Formula (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Release / Docker Image (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:09:12 +02:00
Algis DumbrisandClaude Opus 4.6 9dd27c4b9b feat: admin CLI & Docker fixes — alpine base, channels create/join, absolute socket path
Switch Docker runtime from scratch to alpine:3.19 so kubectl exec works
for admin CLI operations. Add `synapbus channels create` and
`synapbus channels join` CLI commands with corresponding admin socket
handlers. Change default socket path to /data/synapbus.sock (absolute).
Also add Helm envFrom support and NodePort configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:54:13 +02:00
42 changed files with 1900 additions and 629 deletions
+2
View File
@@ -98,6 +98,8 @@ make lint # Run linters
- modernc.org/sqlite (pure Go), migration 009_webhooks.sql (003-webhooks-k8s-runner)
- Go 1.25+ (per go.mod) + mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors) (004-embeddings-retention-inbox)
- SQLite (modernc.org/sqlite, pure Go) — single DB file in `--data` directory (004-embeddings-retention-inbox)
- Go 1.25+ (per go.mod) + spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP) (006-admin-cli-docker-fixes)
- modernc.org/sqlite (pure Go, zero CGO) (006-admin-cli-docker-fixes)
## Recent Changes
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
+2 -3
View File
@@ -18,9 +18,8 @@ COPY --from=web-builder /app/web/build internal/web/dist/
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /synapbus ./cmd/synapbus/
# Stage 3: Runtime
FROM scratch
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=go-builder /usr/share/zoneinfo /usr/share/zoneinfo
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata && touch /.dockerenv
COPY --from=go-builder /synapbus /synapbus
EXPOSE 8080
VOLUME ["/data"]
+96
View File
@@ -0,0 +1,96 @@
# Autonomous Implementation Summary
**Feature**: Admin CLI & Docker Fixes
**Branch**: `006-admin-cli-docker-fixes`
**Date**: 2026-03-15
**Status**: COMPLETE — 8 of 8 tasks implemented, all tests pass, binary builds
## What Was Built
### 1. Alpine Docker Base Image (T06)
**Problem**: `scratch` base image has no shell — `kubectl exec` into the pod can't run admin CLI commands.
**Solution**: Changed `FROM scratch` to `FROM alpine:3.19` in the runtime stage. Alpine provides `/bin/sh` and a working process environment. TLS certs and timezone data are now installed via `apk` instead of copied from the builder stage.
**File Modified**: `Dockerfile`
### 2. `synapbus channels create` CLI Command (T02, T04)
**Problem**: No CLI command to create channels — had to use REST API with session cookies.
**Solution**: Added `channels.create` admin socket handler and `synapbus channels create` cobra command:
- `--name` (required): Channel name
- `--description` (optional): Channel description
- Creates channel via the channel service with `created_by: "system"`, type `"standard"`
- Returns channel details as JSON
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
### 3. `synapbus channels join` CLI Command (T03, T05)
**Problem**: No CLI command to add agents to channels.
**Solution**: Added `channels.join` admin socket handler and `synapbus channels join` cobra command:
- `--channel` (required): Channel name to join
- `--agent` (required): Agent name to add
- Looks up channel by name, calls `JoinChannel` (idempotent)
- Reports `"joined"` or `"already_member"` status
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
### 4. Absolute Default Socket Path (T01)
**Problem**: Default `./data/synapbus.sock` is confusing in containers where CWD varies.
**Solution**: Changed default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in both the `--socket` flag definition and the `SYNAPBUS_SOCKET` env var comparison.
**File Modified**: `cmd/synapbus/admin.go`
## Tests Added (T07)
| Test | Description |
|------|-------------|
| `TestChannelsCreateCommandRegistered` | Verifies `channels create` subcommand exists |
| `TestChannelsCreateRequiredFlags` | Verifies `--name` is required, `--description` is optional |
| `TestChannelsJoinCommandRegistered` | Verifies `channels join` subcommand exists |
| `TestChannelsJoinRequiredFlags` | Verifies `--channel` and `--agent` are both required |
| `TestDefaultSocketPath` | Verifies default is `/data/synapbus.sock` |
## Verification Results (T08)
| Check | Result |
|-------|--------|
| `go build ./...` | PASS |
| `go test ./...` | ALL PASS (24 packages, 0 failures) |
| Zero CGO | Confirmed (CGO_ENABLED=0 in Dockerfile) |
| No regressions | All 14 existing CLI tests still pass |
## Files Changed
| File | Changes |
|------|---------|
| `Dockerfile` | `FROM scratch` → `FROM alpine:3.19` + `apk add --no-cache ca-certificates tzdata` |
| `cmd/synapbus/admin.go` | Default socket `/data/synapbus.sock`, `channels create` + `channels join` commands |
| `cmd/synapbus/admin_test.go` | 5 new tests for commands, flags, and default socket |
| `internal/admin/socket.go` | `channels.create` + `channels.join` handlers, `channels` import |
## CLI Commands Added
| Command | Description |
|---------|-------------|
| `synapbus channels create --name X [--description Y]` | Create a new channel |
| `synapbus channels join --channel X --agent Y` | Add an agent to a channel |
## Usage Examples
```bash
# In Kubernetes (now works with alpine base)
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed --description "News feed"
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent research-mcpproxy
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
# Local development
synapbus --socket ./data/synapbus.sock channels create --name test-channel
synapbus --socket ./data/synapbus.sock channels join --channel test-channel --agent my-agent
```
+50 -3
View File
@@ -17,7 +17,7 @@ var adminSocket string
// adminRequest sends a command over the Unix socket and returns the parsed response.
func adminRequest(command string, args interface{}) (map[string]interface{}, error) {
socket := adminSocket
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "./data/synapbus.sock" {
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "/tmp/synapbus.sock" {
socket = s
}
@@ -561,7 +561,54 @@ func addAdminCommands(rootCmd *cobra.Command) {
channelsShowCmd.Flags().StringVar(&channelsShowName, "name", "", "Channel name")
channelsShowCmd.MarkFlagRequired("name")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd)
var (
channelsCreateName string
channelsCreateDesc string
)
channelsCreateCmd := &cobra.Command{
Use: "create",
Short: "Create a new channel",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("channels.create", map[string]string{
"name": channelsCreateName,
"description": channelsCreateDesc,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsCreateCmd.Flags().StringVar(&channelsCreateName, "name", "", "Channel name")
channelsCreateCmd.Flags().StringVar(&channelsCreateDesc, "description", "", "Channel description")
channelsCreateCmd.MarkFlagRequired("name")
var (
channelsJoinChannel string
channelsJoinAgent string
)
channelsJoinCmd := &cobra.Command{
Use: "join",
Short: "Add an agent to a channel",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := adminRequest("channels.join", map[string]string{
"channel": channelsJoinChannel,
"agent": channelsJoinAgent,
})
if err != nil {
return err
}
printJSON(resp["data"])
return nil
},
}
channelsJoinCmd.Flags().StringVar(&channelsJoinChannel, "channel", "", "Channel name")
channelsJoinCmd.Flags().StringVar(&channelsJoinAgent, "agent", "", "Agent name")
channelsJoinCmd.MarkFlagRequired("channel")
channelsJoinCmd.MarkFlagRequired("agent")
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd, channelsCreateCmd, channelsJoinCmd)
// ----- conversations commands -----
conversationsCmd := &cobra.Command{
@@ -919,7 +966,7 @@ func addAdminCommands(rootCmd *cobra.Command) {
attachmentsCmd.AddCommand(attachmentsGCCmd)
// ----- add persistent flag and commands to root -----
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "./data/synapbus.sock", "Path to admin Unix socket")
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/tmp/synapbus.sock", "Path to admin Unix socket")
rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd, embeddingsCmd, dbCmd, retentionCmd, webhookCmd, k8sCmd, attachmentsCmd)
}
+79
View File
@@ -196,6 +196,85 @@ func TestK8sRegisterOptionalFlags(t *testing.T) {
}
}
func TestChannelsCreateCommandRegistered(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "create")
if cmd == nil {
t.Fatal("channels create command not found")
}
}
func TestChannelsCreateRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "create")
if cmd == nil {
t.Fatal("channels create command not found")
}
// --name is required
f := cmd.Flag("name")
if f == nil {
t.Fatal("flag --name not found on channels create")
}
ann := f.Annotations
if ann == nil {
t.Fatal("flag --name should be required")
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Fatal("flag --name should be required")
}
// --description is optional
df := cmd.Flag("description")
if df == nil {
t.Fatal("flag --description not found on channels create")
}
}
func TestChannelsJoinCommandRegistered(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "join")
if cmd == nil {
t.Fatal("channels join command not found")
}
}
func TestChannelsJoinRequiredFlags(t *testing.T) {
root := buildTestRoot()
cmd := findSubcommand(root, "channels", "join")
if cmd == nil {
t.Fatal("channels join command not found")
}
requiredFlags := []string{"channel", "agent"}
for _, flag := range requiredFlags {
f := cmd.Flag(flag)
if f == nil {
t.Errorf("flag --%s not found on channels join", flag)
continue
}
ann := f.Annotations
if ann == nil {
t.Errorf("flag --%s should be required", flag)
continue
}
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
t.Errorf("flag --%s should be required", flag)
}
}
}
func TestDefaultSocketPath(t *testing.T) {
root := buildTestRoot()
f := root.PersistentFlags().Lookup("socket")
if f == nil {
t.Fatal("--socket persistent flag not found")
}
if f.DefValue != "/tmp/synapbus.sock" {
t.Errorf("default socket path = %q, want %q", f.DefValue, "/tmp/synapbus.sock")
}
}
func TestExistingCommandsStillPresent(t *testing.T) {
root := buildTestRoot()
+17 -2
View File
@@ -161,7 +161,13 @@ func runServe(cmd *cobra.Command, args []string) error {
messageRetention = mr
}
if adminSocketPath == "" {
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
// Default to /tmp in containers — PVC-backed filesystems (NFS, Ceph,
// EBS CSI) often don't support Unix domain sockets.
if _, err := os.Stat("/.dockerenv"); err == nil {
adminSocketPath = "/tmp/synapbus.sock"
} else {
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
}
}
// Configure slog with JSON handler writing to stderr (stdout is for console output)
@@ -389,6 +395,9 @@ func runServe(cmd *cobra.Command, args []string) error {
embPipeline = search.NewPipeline(embProvider, embStore, vectorIndex, searchCfg)
embPipeline.Start(ctx)
// Wire pipeline into messaging so new messages auto-enqueue
msgService.SetEmbeddingNotifier(embPipeline)
// Create search service with semantic support
searchService = search.NewService(db.DB, embProvider, vectorIndex, msgService)
slog.Info("semantic search enabled",
@@ -529,8 +538,13 @@ func runServe(cmd *cobra.Command, args []string) error {
r.Mount("/mcp", mcpSrv.Handler())
})
// Create SSE hub for real-time events
// Create SSE hub and broadcaster for real-time events
sseHub := api.NewSSEHub()
sseBroadcaster := api.NewSSEBroadcaster(sseHub, agentService, channelService)
// Register broadcaster as a message listener so SSE events fire
// for messages sent via MCP (agents) as well as the REST API.
msgService.AddMessageListener(sseBroadcaster)
// Mount API routes (traces, export, stats, metrics, attachments, messages, agents, channels, SSE)
sessionMiddleware := api.SessionToOwnerMiddleware(userStore, sessionStore)
@@ -544,6 +558,7 @@ func runServe(cmd *cobra.Command, args []string) error {
APIKeyService: apiKeyService,
DeadLetterStore: deadLetterStore,
SSEHub: sseHub,
Broadcaster: sseBroadcaster,
SessionMiddleware: sessionMiddleware,
})
r.Mount("/", apiRouter)
@@ -39,6 +39,10 @@ spec:
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- with .Values.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
@@ -56,6 +60,8 @@ spec:
volumeMounts:
- name: data
mountPath: /data
- name: run
mountPath: /tmp
volumes:
- name: data
{{- if .Values.persistence.enabled }}
@@ -64,6 +70,10 @@ spec:
{{- else }}
emptyDir: {}
{{- end }}
- name: run
emptyDir:
medium: Memory
sizeLimit: 1Mi
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
@@ -11,5 +11,8 @@ spec:
targetPort: http
protocol: TCP
name: http
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
selector:
{{- include "synapbus.selectorLabels" . | nindent 4 }}
+77
View File
@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/k8s"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/trace"
@@ -162,6 +163,10 @@ func (s *AdminServer) dispatch(req Request) Response {
return s.handleChannelsList(ctx)
case "channels.show":
return s.handleChannelsShow(ctx, req.Args)
case "channels.create":
return s.handleChannelsCreate(ctx, req.Args)
case "channels.join":
return s.handleChannelsJoin(ctx, req.Args)
// --- conversations ---
case "conversations.list":
@@ -887,6 +892,78 @@ func (s *AdminServer) handleChannelsShow(ctx context.Context, args json.RawMessa
}}
}
func (s *AdminServer) handleChannelsCreate(ctx context.Context, args json.RawMessage) Response {
var p struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Name == "" {
return Response{OK: false, Error: "name is required"}
}
ch, err := s.services.Channels.CreateChannel(ctx, channels.CreateChannelRequest{
Name: p.Name,
Description: p.Description,
Type: "standard",
CreatedBy: "system",
})
if err != nil {
return Response{OK: false, Error: err.Error()}
}
return Response{OK: true, Data: map[string]interface{}{
"id": ch.ID,
"name": ch.Name,
"description": ch.Description,
"type": ch.Type,
"is_private": ch.IsPrivate,
"created_by": ch.CreatedBy,
"created_at": ch.CreatedAt.Format(time.RFC3339),
}}
}
func (s *AdminServer) handleChannelsJoin(ctx context.Context, args json.RawMessage) Response {
var p struct {
Channel string `json:"channel"`
Agent string `json:"agent"`
}
if err := json.Unmarshal(args, &p); err != nil {
return Response{OK: false, Error: "invalid args: " + err.Error()}
}
if p.Channel == "" {
return Response{OK: false, Error: "channel is required"}
}
if p.Agent == "" {
return Response{OK: false, Error: "agent is required"}
}
ch, err := s.services.Channels.GetChannelByName(ctx, p.Channel)
if err != nil {
return Response{OK: false, Error: fmt.Sprintf("channel not found: %s", p.Channel)}
}
// Check if already a member for status reporting
isMember, _ := s.services.Channels.IsMember(ctx, ch.ID, p.Agent)
if err := s.services.Channels.JoinChannel(ctx, ch.ID, p.Agent); err != nil {
return Response{OK: false, Error: err.Error()}
}
status := "joined"
if isMember {
status = "already_member"
}
return Response{OK: true, Data: map[string]interface{}{
"channel": p.Channel,
"agent": p.Agent,
"status": status,
}}
}
// ---------- conversations handlers ----------
func (s *AdminServer) handleConversationsList(ctx context.Context, args json.RawMessage) Response {
+22
View File
@@ -6,6 +6,7 @@ import (
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
)
// NewMessageEvent is broadcast when a new message is sent.
@@ -107,3 +108,24 @@ func (b *SSEBroadcaster) BroadcastChannelMessage(ctx context.Context, channelID
}
}
}
// OnMessageSent implements messaging.MessageListener so the SSEBroadcaster
// can be wired directly into the messaging service. This ensures SSE events
// fire for messages sent via MCP (agents) as well as the REST API.
func (b *SSEBroadcaster) OnMessageSent(ctx context.Context, msg *messaging.Message) {
event := NewMessageEvent{
MessageID: msg.ID,
FromAgent: msg.FromAgent,
ToAgent: msg.ToAgent,
}
if msg.ChannelID != nil {
ch, err := b.channelService.GetChannel(ctx, *msg.ChannelID)
if err == nil {
event.Channel = ch.Name
}
b.BroadcastChannelMessage(ctx, *msg.ChannelID, event)
} else {
b.BroadcastDM(ctx, event)
}
}
+55 -18
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -315,23 +316,8 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
return
}
// Broadcast real-time event to connected SSE clients
if h.broadcaster != nil {
event := NewMessageEvent{
MessageID: msg.ID,
FromAgent: msg.FromAgent,
ToAgent: msg.ToAgent,
}
if msg.ChannelID != nil && h.broadcaster.channelService != nil {
ch, chErr := h.broadcaster.channelService.GetChannel(r.Context(), *msg.ChannelID)
if chErr == nil {
event.Channel = ch.Name
}
h.broadcaster.BroadcastChannelMessage(r.Context(), *msg.ChannelID, event)
} else {
h.broadcaster.BroadcastDM(r.Context(), event)
}
}
// SSE broadcast is handled by the MessageListener on the messaging
// service, so it fires for both REST and MCP message paths.
writeJSON(w, http.StatusCreated, msg)
}
@@ -414,9 +400,60 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
limit = 20
}
// Parse advanced filter parameters
channelFilter := r.URL.Query().Get("channel")
agentFilter := r.URL.Query().Get("agent")
afterFilter := r.URL.Query().Get("after")
beforeFilter := r.URL.Query().Get("before")
var allMessages []*messaging.Message
for _, agent := range ownedAgents {
opts := messaging.SearchOptions{Limit: limit}
opts := messaging.SearchOptions{
Limit: limit,
After: afterFilter,
Before: beforeFilter,
}
// Apply channel filters (comma-separated, prefix with - to exclude)
if channelFilter != "" {
channels := strings.Split(channelFilter, ",")
var includeChannels []string
var excludeChannels []string
for _, ch := range channels {
ch = strings.TrimSpace(ch)
if ch == "" {
continue
}
if strings.HasPrefix(ch, "-") {
excludeChannels = append(excludeChannels, strings.TrimPrefix(ch, "-"))
} else {
includeChannels = append(includeChannels, ch)
}
}
opts.Channels = includeChannels
opts.ExcludeChannels = excludeChannels
}
// Apply agent filters (comma-separated, prefix with - to exclude)
if agentFilter != "" {
agentNames := strings.Split(agentFilter, ",")
var includeAgents []string
var excludeAgents []string
for _, a := range agentNames {
a = strings.TrimSpace(a)
if a == "" {
continue
}
if strings.HasPrefix(a, "-") {
excludeAgents = append(excludeAgents, strings.TrimPrefix(a, "-"))
} else {
includeAgents = append(includeAgents, a)
}
}
opts.Agents = includeAgents
opts.ExcludeAgents = excludeAgents
}
result, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
if err != nil {
continue
+5 -3
View File
@@ -31,6 +31,7 @@ type RouterConfig struct {
K8sService *k8s.K8sService
K8sStore k8s.K8sStore
SSEHub *SSEHub
Broadcaster *SSEBroadcaster
SessionMiddleware func(http.Handler) http.Handler
}
@@ -88,9 +89,10 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
notificationsHandler := NewNotificationsHandler(cfg.MsgService, cfg.AgentService, cfg.ChannelService)
// Wire up SSE broadcaster for real-time events
if cfg.SSEHub != nil {
broadcaster := NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService)
messagesHandler.SetBroadcaster(broadcaster)
if cfg.Broadcaster != nil {
messagesHandler.SetBroadcaster(cfg.Broadcaster)
} else if cfg.SSEHub != nil {
messagesHandler.SetBroadcaster(NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService))
}
r.Group(func(r chi.Router) {
+21 -6
View File
@@ -816,13 +816,28 @@ const authorizeTemplateHTML = `<!DOCTYPE html>
<body>
<div class="card">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.8">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#36c5f0"/>
<path d="M2 17l10 5 10-5" stroke="#7c3aed"/>
<path d="M2 12l10 5 10-5" stroke="url(#g)"/>
<defs><linearGradient id="g" x1="2" y1="12" x2="22" y2="17" gradientUnits="userSpaceOnUse">
<stop stop-color="#36c5f0"/><stop offset="1" stop-color="#7c3aed"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs><linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#7c3aed"/><stop offset="1" stop-color="#06b6d4"/>
</linearGradient></defs>
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<g transform="translate(4,4)">
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
</g>
</svg>
<h1>SynapBus</h1>
<p>Agent Authorization</p>
+11 -11
View File
@@ -471,13 +471,18 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
return nil, err
}
// Verify sender is a member
// Verify sender is a member; auto-join public channels on first send.
isMember, err := s.store.IsMember(ctx, channelID, fromAgent)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if !isMember {
return nil, ErrNotChannelMember
if ch.IsPrivate {
return nil, ErrNotChannelMember
}
if err := s.JoinChannel(ctx, channelID, fromAgent); err != nil {
return nil, fmt.Errorf("auto-join public channel: %w", err)
}
}
// Get members for mentions and inbox notifications
@@ -526,10 +531,10 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
return nil, fmt.Errorf("create channel message: %w", err)
}
// 2. Deliver inbox notifications to other members.
// 2. Deliver inbox notifications only to @mentioned members.
recipientCount := 0
for _, m := range members {
if m.AgentName == fromAgent {
if m.AgentName == fromAgent || !mentionedMembers[m.AgentName] {
continue
}
@@ -537,12 +542,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
"channel_id": channelID,
"channel_name": ch.Name,
"channel_message_id": channelMsg.ID,
}
if len(mentionedAgentsList) > 0 {
inboxMetaObj["mentioned_agents"] = mentionedAgentsList
}
if mentionedMembers[m.AgentName] {
inboxMetaObj["mention"] = true
"mention": true,
}
inboxMetaBytes, _ := json.Marshal(inboxMetaObj)
@@ -552,7 +552,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
Metadata: string(inboxMetaBytes),
})
if err != nil {
s.logger.Error("failed to send channel notification",
s.logger.Error("failed to send mention notification",
"channel_id", channelID,
"from", fromAgent,
"to", m.AgentName,
+28 -25
View File
@@ -565,26 +565,21 @@ func TestService_BroadcastMessage(t *testing.T) {
}
})
t.Run("broadcast delivers inbox notifications", func(t *testing.T) {
t.Run("broadcast without mentions sends no DMs", func(t *testing.T) {
svc.JoinChannel(ctx, ch.ID, "agent-b")
svc.JoinChannel(ctx, ch.ID, "agent-c")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "multi-member test", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "")
if err != nil {
t.Fatalf("BroadcastMessage: %v", err)
}
// agent-b should have an inbox notification
// agent-b should NOT have an inbox notification (no @mention)
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
found := false
for _, m := range inboxResult.Messages {
if m.Body == "multi-member test" {
found = true
break
if m.Body == "no-dm-test" {
t.Error("agent-b should not receive inbox DM for non-mention broadcast")
}
}
if !found {
t.Error("agent-b did not receive inbox notification")
}
})
t.Run("sender does not receive own message", func(t *testing.T) {
@@ -597,12 +592,29 @@ func TestService_BroadcastMessage(t *testing.T) {
}
})
t.Run("non-member cannot broadcast", func(t *testing.T) {
// agent-c is a member but let's test someone who isn't
t.Run("non-member auto-joins public channel on broadcast", func(t *testing.T) {
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "unauthorized", 5, "")
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "auto-joined", 5, "")
if err != nil {
t.Fatalf("expected auto-join for public channel, got %v", err)
}
isMember, _ := svc.IsMember(ctx, ch.ID, "outsider")
if !isMember {
t.Error("outsider should be a member after auto-join")
}
})
t.Run("non-member cannot broadcast to private channel", func(t *testing.T) {
privCh, err := svc.CreateChannel(ctx, CreateChannelRequest{
Name: "private-test", Type: TypeStandard, IsPrivate: true, CreatedBy: "agent-a",
})
if err != nil {
t.Fatalf("create private channel: %v", err)
}
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider2")
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "")
if !errors.Is(err, ErrNotChannelMember) {
t.Errorf("expected ErrNotChannelMember, got %v", err)
t.Errorf("expected ErrNotChannelMember for private channel, got %v", err)
}
})
@@ -642,20 +654,11 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
t.Error("agent-b did not receive inbox notification")
}
// agent-c was NOT mentioned — inbox notification should NOT have mention:true
// agent-c was NOT mentioned — should NOT receive an inbox DM at all
inboxResult, _ = svc.msgService.ReadInbox(ctx, "agent-c", messaging.ReadOptions{IncludeRead: true})
for _, m := range inboxResult.Messages {
if m.Body == "hey @agent-b check this" {
var meta map[string]any
json.Unmarshal(m.Metadata, &meta)
if meta["mention"] == true {
t.Error("agent-c should NOT have mention flag")
}
// But should still have mentioned_agents list
if _, ok := meta["mentioned_agents"]; !ok {
t.Error("agent-c metadata should have mentioned_agents list")
}
break
t.Error("agent-c should not receive inbox DM when not @mentioned")
}
}
})
+14 -10
View File
@@ -25,14 +25,18 @@ type ReadOptions struct {
// SearchOptions configures message search behavior.
type SearchOptions struct {
FromAgent string `json:"from_agent,omitempty"`
ToAgent string `json:"to_agent,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
MinPriority int `json:"min_priority,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
Channel string `json:"channel,omitempty"`
FromAgent string `json:"from_agent,omitempty"`
ToAgent string `json:"to_agent,omitempty"`
ChannelID *int64 `json:"channel_id,omitempty"`
MinPriority int `json:"min_priority,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
After string `json:"after,omitempty"`
Before string `json:"before,omitempty"`
Channel string `json:"channel,omitempty"`
Channels []string `json:"channels,omitempty"` // include messages in these channels
ExcludeChannels []string `json:"exclude_channels,omitempty"` // exclude messages in these channels
Agents []string `json:"agents,omitempty"` // include messages from/to these agents
ExcludeAgents []string `json:"exclude_agents,omitempty"` // exclude messages from/to these agents
}
+34
View File
@@ -12,11 +12,25 @@ import (
"github.com/synapbus/synapbus/internal/trace"
)
// EmbeddingNotifier is called when messages are created or deleted so the
// embedding pipeline can enqueue them without a direct import dependency.
type EmbeddingNotifier interface {
OnMessageCreated(ctx context.Context, messageID int64, body string)
}
// MessageListener is notified after every message is persisted.
// Implementations must not block — use goroutines for slow work.
type MessageListener interface {
OnMessageSent(ctx context.Context, msg *Message)
}
// MessagingService provides business logic for messaging operations.
type MessagingService struct {
store MessageStore
tracer *trace.Tracer
dispatcher dispatcher.EventDispatcher
embeddings EmbeddingNotifier
listeners []MessageListener
logger *slog.Logger
}
@@ -34,6 +48,16 @@ func (s *MessagingService) SetDispatcher(d dispatcher.EventDispatcher) {
s.dispatcher = d
}
// SetEmbeddingNotifier sets the embedding pipeline callback for new messages.
func (s *MessagingService) SetEmbeddingNotifier(n EmbeddingNotifier) {
s.embeddings = n
}
// AddMessageListener registers a listener that is notified after message creation.
func (s *MessagingService) AddMessageListener(l MessageListener) {
s.listeners = append(s.listeners, l)
}
// SendMessage creates a message, auto-creating conversations as needed.
func (s *MessagingService) SendMessage(ctx context.Context, from, to, body string, opts SendOptions) (*Message, error) {
// Validate inputs
@@ -121,6 +145,16 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
return nil, fmt.Errorf("insert message: %w", err)
}
// Enqueue for embedding (async, best-effort)
if s.embeddings != nil {
s.embeddings.OnMessageCreated(ctx, msg.ID, msg.Body)
}
// Notify listeners (SSE, etc.)
for _, l := range s.listeners {
l.OnMessageSent(ctx, msg)
}
s.logger.Info("message sent",
"from", from,
"to", to,
+51
View File
@@ -492,6 +492,57 @@ func (s *SQLiteMessageStore) buildSearchConditions(agentName, query string, opts
args = append(args, opts.Channel)
}
if len(opts.Channels) > 0 {
placeholders := make([]string, len(opts.Channels))
for i, ch := range opts.Channels {
placeholders[i] = "?"
args = append(args, ch)
}
conditions = append(conditions, fmt.Sprintf("m.channel_id IN (SELECT id FROM channels WHERE LOWER(name) IN (%s))", strings.Join(placeholders, ",")))
}
if len(opts.ExcludeChannels) > 0 {
placeholders := make([]string, len(opts.ExcludeChannels))
for i, ch := range opts.ExcludeChannels {
placeholders[i] = "?"
args = append(args, ch)
}
conditions = append(conditions, fmt.Sprintf("(m.channel_id IS NULL OR m.channel_id NOT IN (SELECT id FROM channels WHERE LOWER(name) IN (%s)))", strings.Join(placeholders, ",")))
}
if len(opts.Agents) > 0 {
placeholders := make([]string, len(opts.Agents))
for i, a := range opts.Agents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
// Clone placeholders for the second IN clause
placeholders2 := make([]string, len(opts.Agents))
for i, a := range opts.Agents {
placeholders2[i] = "?"
args = append(args, a)
}
inClause2 := strings.Join(placeholders2, ",")
conditions = append(conditions, fmt.Sprintf("(m.from_agent IN (%s) OR m.to_agent IN (%s))", inClause, inClause2))
}
if len(opts.ExcludeAgents) > 0 {
placeholders := make([]string, len(opts.ExcludeAgents))
for i, a := range opts.ExcludeAgents {
placeholders[i] = "?"
args = append(args, a)
}
inClause := strings.Join(placeholders, ",")
placeholders2 := make([]string, len(opts.ExcludeAgents))
for i, a := range opts.ExcludeAgents {
placeholders2[i] = "?"
args = append(args, a)
}
inClause2 := strings.Join(placeholders2, ",")
conditions = append(conditions, fmt.Sprintf("m.from_agent NOT IN (%s) AND (m.to_agent = '' OR m.to_agent NOT IN (%s))", inClause, inClause2))
}
if opts.After != "" {
if t, err := time.Parse(time.RFC3339, opts.After); err == nil {
conditions = append(conditions, "m.created_at >= ?")
+11 -11
View File
@@ -8,29 +8,29 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="/_app/immutable/entry/start.DpHKCwmv.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BRBotovi.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/SAcaBy3_.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DL-Ee-iM.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BCvik_Lu.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BdrVqzRy.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.B_lhmyMs.js" rel="modulepreload">
<link href="/_app/immutable/entry/start.c_zi0RIt.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CM33bEJp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DcCVF7Hp.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dxl4bgMI.js" rel="modulepreload">
<link href="/_app/immutable/chunks/ZMlvm3v3.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DGCkYcAF.js" rel="modulepreload">
<link href="/_app/immutable/chunks/BoudhnWu.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.BNUVkI2e.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_vhg0t8 = {
__sveltekit_ioo7b2 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.DpHKCwmv.js"),
import("/_app/immutable/entry/app.B_lhmyMs.js")
import("/_app/immutable/entry/start.c_zi0RIt.js"),
import("/_app/immutable/entry/app.BNUVkI2e.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Admin CLI & Docker Fixes
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-15
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is ready for `/speckit.plan`.
- FR-001 mentions "alpine:3.19" which is an implementation detail, but this is the explicit user request and core to the fix, so it's acceptable.
@@ -0,0 +1,138 @@
# Admin Socket Contract: Channel Commands
**Feature**: 006-admin-cli-docker-fixes
**Date**: 2026-03-15
## Protocol
Unix domain socket at `/data/synapbus.sock` (default). JSON-RPC style, newline-delimited.
## New Commands
### `channels.create`
**Request**:
```json
{
"command": "channels.create",
"args": {
"name": "news-feed",
"description": "News feed channel"
}
}
```
- `name` (string, required): Channel name. Must pass `ValidateChannelName` rules.
- `description` (string, optional): Channel description. Defaults to empty.
**Success Response**:
```json
{
"ok": true,
"data": {
"id": 42,
"name": "news-feed",
"description": "News feed channel",
"type": "standard",
"is_private": false,
"created_by": "system",
"created_at": "2026-03-15T10:00:00Z"
}
}
```
**Error Response** (duplicate name):
```json
{
"ok": false,
"error": "channel already exists"
}
```
**Error Response** (invalid name):
```json
{
"ok": false,
"error": "invalid channel name: ..."
}
```
---
### `channels.join`
**Request**:
```json
{
"command": "channels.join",
"args": {
"channel": "news-feed",
"agent": "my-agent"
}
}
```
- `channel` (string, required): Channel name to join.
- `agent` (string, required): Agent name to add as member.
**Success Response**:
```json
{
"ok": true,
"data": {
"channel": "news-feed",
"agent": "my-agent",
"status": "joined"
}
}
```
**Success Response** (already member, idempotent):
```json
{
"ok": true,
"data": {
"channel": "news-feed",
"agent": "my-agent",
"status": "already_member"
}
}
```
**Error Response** (channel not found):
```json
{
"ok": false,
"error": "channel not found: news-feed"
}
```
## CLI Commands
### `synapbus channels create`
```
Usage:
synapbus channels create [flags]
Flags:
--name string Channel name (required)
--description string Channel description
Global Flags:
--socket string Path to admin Unix socket (default "/data/synapbus.sock")
```
### `synapbus channels join`
```
Usage:
synapbus channels join [flags]
Flags:
--channel string Channel name (required)
--agent string Agent name (required)
Global Flags:
--socket string Path to admin Unix socket (default "/data/synapbus.sock")
```
@@ -0,0 +1,42 @@
# Data Model: Admin CLI & Docker Fixes
**Feature**: 006-admin-cli-docker-fixes
**Date**: 2026-03-15
## No Schema Changes
This feature does not introduce any new database tables, columns, or migrations. All operations use existing entities:
### Existing Entities Used
#### Channel (existing)
- `id` (int64): Auto-increment primary key
- `name` (string): Unique, normalized channel name
- `description` (string): Optional description
- `type` (string): "standard", "blackboard", "auction"
- `is_private` (bool): Whether channel requires invite
- `is_system` (bool): Whether channel is system-managed
- `created_by` (string): Agent name of creator
- `created_at` (timestamp): Creation time
#### Membership (existing)
- `channel_id` (int64): FK to channels
- `agent_name` (string): Name of member agent
- `role` (string): "owner", "member"
- `joined_at` (timestamp): When the agent joined
### Data Flow
```
CLI Command Admin Socket Handler Channel Service
─────────────────────────────────────────────────────────────────────────────
channels create --name X → channels.create {name, desc} → CreateChannel(req)
channels join --ch X --ag Y → channels.join {channel, agent} → GetChannelByName(X)
→ JoinChannel(id, Y)
```
### Validation Rules (existing, no changes)
- Channel names: lowercase, alphanumeric + hyphens, 1-64 chars, no leading/trailing hyphens
- Agent names: must exist in the agent registry
- Channel join: idempotent (re-joining a channel the agent is already in is a no-op)
- Private channels: require a pending invite before join
+74
View File
@@ -0,0 +1,74 @@
# Implementation Plan: Admin CLI & Docker Fixes
**Branch**: `006-admin-cli-docker-fixes` | **Date**: 2026-03-15 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/006-admin-cli-docker-fixes/spec.md`
## Summary
Fix four operational issues blocking reliable admin CLI usage in containerized SynapBus: switch Docker base image from `scratch` to `alpine:3.19` so admin socket CLI works via `kubectl exec`, add `synapbus channels create` and `synapbus channels join` CLI commands backed by new admin socket handlers, and change the default socket path from relative `./data/synapbus.sock` to absolute `/data/synapbus.sock`.
## Technical Context
**Language/Version**: Go 1.25+ (per go.mod)
**Primary Dependencies**: spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP)
**Storage**: modernc.org/sqlite (pure Go, zero CGO)
**Testing**: `go test ./...` (table-driven tests)
**Target Platform**: linux/amd64, darwin/arm64 (Docker + local dev)
**Project Type**: CLI / web-service (single binary)
**Performance Goals**: Admin socket commands complete in < 1 second
**Constraints**: Zero CGO, single binary, pure Go
**Scale/Scope**: Single instance, admin-only operations
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Local-First, Single Binary | PASS | No new external dependencies. Alpine base image only adds shell availability. |
| II. MCP-Native | PASS | Changes are admin CLI only, no MCP interface changes. |
| III. Pure Go, Zero CGO | PASS | No new Go dependencies. Dockerfile still builds with `CGO_ENABLED=0`. |
| IV. Multi-Tenant with Ownership | PASS | Admin socket is localhost-only, trusted operator context. |
| V. Embedded OAuth 2.1 | N/A | No auth changes. |
| VI. Semantic-Ready Storage | N/A | No storage schema changes. |
| VII. Swarm Intelligence | N/A | No swarm pattern changes. |
| VIII. Observable by Default | PASS | Channel create/join are traced via existing channel service. |
| IX. Progressive Complexity | PASS | New CLI commands add no complexity for basic usage. |
| X. Web UI | N/A | No UI changes. |
**Gate Result**: PASS — no violations.
## Project Structure
### Documentation (this feature)
```text
specs/006-admin-cli-docker-fixes/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output (admin socket protocol)
└── tasks.md # Phase 2 output (via /speckit.tasks)
```
### Source Code (repository root)
```text
# Files modified:
Dockerfile # scratch → alpine:3.19
cmd/synapbus/admin.go # Add channels create/join commands, fix default socket path
internal/admin/socket.go # Add channels.create and channels.join handlers
cmd/synapbus/admin_test.go # Tests for new CLI commands
# Files unchanged but referenced:
internal/channels/service.go # CreateChannel, JoinChannel (already exist)
internal/channels/store.go # GetChannelByName (already exists)
internal/admin/server.go # Services struct (already has Channels field)
```
**Structure Decision**: This feature modifies 3 existing files and adds no new files. All changes fit within the existing project structure.
## Complexity Tracking
No constitution violations to justify.
@@ -0,0 +1,61 @@
# Quickstart: Admin CLI & Docker Fixes
**Feature**: 006-admin-cli-docker-fixes
## Build & Deploy
```bash
# Build Docker image (now uses alpine base)
docker build -t synapbus:dev .
# Run locally
./synapbus serve --port 8080 --data ./data
```
## New CLI Commands
### Create a channel
```bash
# With description
synapbus channels create --name news-feed --description "News feed channel"
# Without description
synapbus channels create --name alerts
```
### Join an agent to a channel
```bash
synapbus channels join --channel news-feed --agent research-mcpproxy
```
### In Kubernetes
```bash
# Now works because alpine base image provides /bin/sh
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent my-agent
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
```
## Socket Path
The default socket path is now `/data/synapbus.sock` (absolute). Override with:
```bash
# Environment variable
export SYNAPBUS_SOCKET=/custom/path/synapbus.sock
# CLI flag
synapbus --socket /custom/path/synapbus.sock channels list
```
## Verification
```bash
# Verify Docker image base
docker run --rm synapbus:dev sh -c "cat /etc/os-release"
# Should show Alpine Linux
# Verify socket path default
synapbus --help | grep socket
# Should show: --socket string Path to admin Unix socket (default "/data/synapbus.sock")
```
@@ -0,0 +1,49 @@
# Research: Admin CLI & Docker Fixes
**Feature**: 006-admin-cli-docker-fixes
**Date**: 2026-03-15
## R1: Alpine vs Scratch Docker Base Image
**Decision**: Use `alpine:3.19` as the runtime base image.
**Rationale**: The `scratch` image has no shell, no `/bin/sh`, no filesystem utilities. This means `kubectl exec` cannot spawn any process other than the entrypoint binary itself. Since admin CLI commands need to connect to the Unix socket created by the running server process, exec'd processes need a working environment. Alpine adds ~7MB but provides `/bin/sh`, basic filesystem operations, and a working process environment.
**Alternatives considered**:
- `distroless/static` (Google): No shell, same problem as scratch.
- `busybox`: Works but no package manager. Alpine is the standard minimal base.
- `debian-slim`: ~80MB, unnecessarily large.
## R2: Admin Socket Protocol for Channel Operations
**Decision**: Add `channels.create` and `channels.join` commands to the existing admin socket dispatch table, following the exact pattern of existing commands (e.g., `agent.create`, `webhook.register`).
**Rationale**: The admin socket already has a well-established request/response pattern: JSON-RPC style `{command, args}` → `{ok, data, error}`. The channel service already exposes `CreateChannel` and `JoinChannel` methods. The admin server already holds a reference to the channel service via `Services.Channels`. No new wiring needed.
**Alternatives considered**:
- HTTP admin API endpoint: Would require API key protection (user explicitly rejected this approach).
- Direct database manipulation via CLI: Bypasses service layer validation, unsafe.
## R3: Default Socket Path
**Decision**: Change default from `./data/synapbus.sock` to `/data/synapbus.sock` (absolute).
**Rationale**: In containers, the working directory is `/` and the data volume is mounted at `/data`. The relative path `./data/synapbus.sock` resolves to `/data/synapbus.sock` from `/`, but this is confusing and fragile. An absolute default matches the Dockerfile's `--data /data` argument and the Helm chart's `volumeMount` at `/data`.
The `SYNAPBUS_SOCKET` environment variable and `--socket` flag still allow overriding for development (e.g., `--socket ./data/synapbus.sock` for local dev).
**Alternatives considered**:
- Keep relative path: Works in containers but confusing for users.
- Use `$SYNAPBUS_DATA_DIR/synapbus.sock` as default: Over-engineered; the socket path flag already exists.
## R4: `channels.create` Admin Handler Design
**Decision**: The `channels.create` handler accepts `{name, description}` args, calls `channelService.CreateChannel` with `created_by: "system"`, and returns the created channel as JSON.
**Rationale**: Admin socket commands are implicitly trusted (localhost-only, process-level access). Using `"system"` as the creator matches the pattern used for the default `#general` channel. The `description` field is optional (defaults to empty string).
## R5: `channels.join` Admin Handler Design
**Decision**: The `channels.join` handler accepts `{channel, agent}` args, looks up the channel by name via `GetChannelByName`, then calls `JoinChannel(channelID, agentName)`. Returns success message.
**Rationale**: The CLI uses channel names (not IDs) because operators work with names. The service's `JoinChannel` already handles idempotency (re-joining is a no-op) and private channel invite checks.
+118
View File
@@ -0,0 +1,118 @@
# Feature Specification: Admin CLI & Docker Fixes
**Feature Branch**: `006-admin-cli-docker-fixes`
**Created**: 2026-03-15
**Status**: Draft
**Input**: User description: "Fix admin socket accessibility in Docker, add channels create/join CLI commands, fix default socket path"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Admin CLI Works in Kubernetes Pods (Priority: P1)
An operator needs to run admin CLI commands inside a Kubernetes pod (e.g., `kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list`). With the current `scratch` base image, there is no shell and the admin socket is unreachable via exec'd processes. Switching to `alpine` allows `kubectl exec` with a shell and gives the admin CLI a working environment.
**Why this priority**: This is a blocker — without this fix, all admin CLI operations fail after pod restart in production.
**Independent Test**: Build Docker image with alpine base, deploy to a test pod, run `kubectl exec ... -- /synapbus channels list` and verify it returns results.
**Acceptance Scenarios**:
1. **Given** a SynapBus pod running with the alpine-based image, **When** an operator runs `kubectl exec deploy/synapbus -- /synapbus channels list`, **Then** the command executes and returns channel data over the admin socket.
2. **Given** a SynapBus pod running with the alpine-based image, **When** an operator runs `kubectl exec deploy/synapbus -- sh`, **Then** they get an interactive shell.
---
### User Story 2 - Create Channels via CLI (Priority: P1)
An operator needs to create channels without using the Web UI or REST API with session cookies. The `synapbus channels create` command should create a channel via the admin socket.
**Why this priority**: Required for automated provisioning scripts and headless setups.
**Independent Test**: Start SynapBus server, run `synapbus channels create --name test-channel --description "A test channel"`, then verify with `synapbus channels list`.
**Acceptance Scenarios**:
1. **Given** a running SynapBus server, **When** an operator runs `synapbus channels create --name news-feed --description "News feed channel"`, **Then** the channel is created and a success response with channel details is printed.
2. **Given** a running SynapBus server, **When** an operator runs `synapbus channels create --name news-feed` without `--description`, **Then** the channel is created with an empty description.
3. **Given** a channel named "news-feed" already exists, **When** an operator runs `synapbus channels create --name news-feed`, **Then** an appropriate error message is displayed.
---
### User Story 3 - Join Agents to Channels via CLI (Priority: P1)
An operator needs to add agents to channels via the admin CLI so agents can post messages. The `synapbus channels join` command should add an agent to a channel's membership.
**Why this priority**: Agents cannot post to channels they haven't joined; this is required for initial agent setup and automation.
**Independent Test**: Create a channel and an agent, run `synapbus channels join --channel test-channel --agent my-agent`, then verify with `synapbus channels show --name test-channel`.
**Acceptance Scenarios**:
1. **Given** a channel "test-channel" and agent "my-agent" exist, **When** an operator runs `synapbus channels join --channel test-channel --agent my-agent`, **Then** the agent is added as a member and a success response is printed.
2. **Given** an agent is already a member of "test-channel", **When** an operator runs `synapbus channels join --channel test-channel --agent my-agent`, **Then** the operation succeeds idempotently (no error).
3. **Given** channel "nonexistent" does not exist, **When** an operator runs `synapbus channels join --channel nonexistent --agent my-agent`, **Then** an error message indicates the channel was not found.
---
### User Story 4 - Absolute Default Socket Path (Priority: P2)
The default socket path for admin CLI commands is currently `./data/synapbus.sock` (relative). In containers where CWD varies, this is confusing. The default should be `/data/synapbus.sock` (absolute) to match the container layout.
**Why this priority**: Quality-of-life improvement; the current relative path works but is confusing.
**Independent Test**: Run `synapbus --help` and verify the default socket path shows `/data/synapbus.sock`.
**Acceptance Scenarios**:
1. **Given** the `--socket` flag is not provided, **When** the CLI resolves the admin socket path, **Then** it defaults to `/data/synapbus.sock`.
2. **Given** the `SYNAPBUS_SOCKET` environment variable is set, **When** the CLI resolves the admin socket path, **Then** it uses the environment variable value.
3. **Given** the `--socket` flag is provided with a custom path, **When** the CLI resolves the admin socket path, **Then** it uses the custom path.
---
### Edge Cases
- What happens when channel name contains invalid characters? The existing `ValidateChannelName` rules apply, and the CLI reports the validation error.
- What happens when the admin socket is not reachable? The CLI prints a connection error with "is synapbus serve running?" hint.
- What happens when an agent name doesn't exist during channel join? The operation fails with a clear error message from the channel service.
- What happens when the `--name` flag is missing on `channels create`? Cobra enforces the required flag and prints usage.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The Docker image MUST use `alpine:3.19` as the runtime base image instead of `scratch`.
- **FR-002**: The system MUST provide a `synapbus channels create` CLI command with `--name` (required) and `--description` (optional) flags.
- **FR-003**: The `channels create` command MUST send a `channels.create` request over the admin socket and display the result.
- **FR-004**: The admin socket server MUST handle `channels.create` commands by creating a channel via the channel service.
- **FR-005**: The system MUST provide a `synapbus channels join` CLI command with `--channel` (required) and `--agent` (required) flags.
- **FR-006**: The `channels join` command MUST send a `channels.join` request over the admin socket and display the result.
- **FR-007**: The admin socket server MUST handle `channels.join` commands by looking up the channel by name and adding the agent as a member.
- **FR-008**: The default value of the `--socket` persistent flag MUST be `/data/synapbus.sock` (absolute path).
- **FR-009**: The `SYNAPBUS_SOCKET` environment variable MUST override the default socket path when the flag is not explicitly set.
- **FR-010**: The Docker image MUST remain minimal — only the binary, TLS certs, and timezone data should be included from the build stage.
### Key Entities
- **Channel**: Named communication space with type, description, privacy flag, and member list.
- **Agent**: Named entity (AI or human) that can be a member of channels.
- **Admin Socket**: Unix domain socket at a known path, used by CLI commands to communicate with the running server.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Operators can execute all admin CLI commands inside a Kubernetes pod via `kubectl exec` without errors.
- **SC-002**: `synapbus channels create --name <name>` successfully creates a channel and returns channel details within 1 second.
- **SC-003**: `synapbus channels join --channel <name> --agent <name>` successfully adds an agent to a channel within 1 second.
- **SC-004**: The default socket path displayed in help text is `/data/synapbus.sock`.
- **SC-005**: The Docker image size remains under 50MB (alpine adds minimal overhead vs scratch).
## Assumptions
- Alpine 3.19 is acceptable as the runtime base image (adds ~7MB over scratch).
- The `channels.create` admin command uses `"system"` as the `created_by` field since admin socket operations are implicitly trusted.
- The `channels.join` admin command adds the agent with the `"member"` role (not owner).
- Channel type defaults to `"standard"` if not specified.
- No `--private` or `--type` flags are needed for the initial `channels create` command — they can be added later.
- The Helm chart deployment.yaml does not need changes since it already passes `--data /data`.
+44
View File
@@ -0,0 +1,44 @@
# Tasks: Admin CLI & Docker Fixes
**Feature**: 006-admin-cli-docker-fixes
**Created**: 2026-03-15
**Plan**: [plan.md](plan.md)
## Phase 1: Setup
- [x] **T01**: Change default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in `cmd/synapbus/admin.go` (line 922) and update the `SYNAPBUS_SOCKET` env var check comparison string.
- Files: `cmd/synapbus/admin.go`
## Phase 2: Core — Admin Socket Handlers
- [x] **T02**: Add `channels.create` handler to `internal/admin/socket.go` dispatch table and implement `handleChannelsCreate` method.
- Files: `internal/admin/socket.go`
- Depends on: T01
- [x] **T03**: Add `channels.join` handler to `internal/admin/socket.go` dispatch table and implement `handleChannelsJoin` method.
- Files: `internal/admin/socket.go`
- Depends on: T01
## Phase 3: Core — CLI Commands
- [x] **T04**: Add `synapbus channels create` cobra command with `--name` (required) and `--description` (optional) flags in `cmd/synapbus/admin.go`.
- Files: `cmd/synapbus/admin.go`
- Depends on: T02
- [x] **T05**: Add `synapbus channels join` cobra command with `--channel` (required) and `--agent` (required) flags in `cmd/synapbus/admin.go`.
- Files: `cmd/synapbus/admin.go`
- Depends on: T03
## Phase 4: Docker
- [x] **T06**: Change Dockerfile runtime stage from `FROM scratch` to `FROM alpine:3.19` and add `RUN apk add --no-cache ca-certificates tzdata` (removing COPY of certs/tzdata from builder).
- Files: `Dockerfile`
## Phase 5: Tests & Validation
- [x] **T07**: Add unit tests for `channels.create` and `channels.join` admin socket handlers.
- Files: `cmd/synapbus/admin_test.go`
- Depends on: T04, T05
- [x] **T08**: Run `make build` and `make test` to verify all changes compile and pass.
- Depends on: T01-T07
+8 -8
View File
@@ -480,17 +480,17 @@ func TestE2E_ChannelMessaging(t *testing.T) {
t.Errorf("send status = %v, want sent", sendResult["status"])
}
// Bob reads his inbox and should see the channel message.
inbox := bobClient.CallTool("execute", map[string]any{
"code": `call("read_inbox", { include_read: true })`,
// Bob reads channel messages (DMs only sent for @mentions).
chMsgs := bobClient.CallTool("execute", map[string]any{
"code": `call("get_channel_messages", { channel_name: "project-x", limit: 10 })`,
})
inboxData := unwrapCallResult(t, inbox)
count := inboxData["count"].(float64)
chData := unwrapCallResult(t, chMsgs)
count := chData["count"].(float64)
if count < 1 {
t.Fatalf("Bob's inbox count = %v, want >= 1", count)
t.Fatalf("channel message count = %v, want >= 1", count)
}
messages := inboxData["messages"].([]any)
messages := chData["messages"].([]any)
found := false
for _, m := range messages {
msg := m.(map[string]any)
@@ -500,7 +500,7 @@ func TestE2E_ChannelMessaging(t *testing.T) {
}
}
if !found {
t.Error("Bob did not receive the channel message")
t.Error("channel message not found via get_channel_messages")
}
}
+6 -2
View File
@@ -65,9 +65,13 @@ export const messages = {
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
search: (q: string, limit?: number) => {
search: (q: string, opts?: { limit?: number; channel?: string; agent?: string; after?: string; before?: string }) => {
const qs = new URLSearchParams({ q });
if (limit) qs.set('limit', String(limit));
if (opts?.limit) qs.set('limit', String(opts.limit));
if (opts?.channel) qs.set('channel', opts.channel);
if (opts?.agent) qs.set('agent', opts.agent);
if (opts?.after) qs.set('after', opts.after);
if (opts?.before) qs.set('before', opts.before);
return request<{ messages: any[]; query: string; total: number }>('GET', `/api/messages/search?${qs}`);
}
};
+5 -6
View File
@@ -16,13 +16,12 @@
const path = $page.url.pathname;
if (path === '/') return 'Dashboard';
if (path.startsWith('/conversations/')) return 'Thread';
if (path === '/conversations') return 'Conversations';
if (path === '/conversations') return 'Search';
if (path.startsWith('/channels/')) return '#' + ($page.params.name ?? '');
if (path === '/channels') return 'Channels';
if (path.startsWith('/dm/')) return ($page.params.name ?? 'DM');
if (path.startsWith('/agents/')) return ($page.params.name ?? 'Agent');
if (path === '/agents') return 'Agents';
if (path === '/settings/api-keys') return 'API Keys';
if (path === '/settings') return 'Settings';
return 'SynapBus';
});
@@ -34,15 +33,15 @@
<div class="flex-1"></div>
<!-- Search -->
<form class="max-w-xs w-full" onsubmit={handleSearch}>
<form class="w-80" onsubmit={handleSearch}>
<div class="relative">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search..."
class="w-full pl-8 pr-3 py-1.5 text-xs bg-bg-tertiary border border-border rounded text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
placeholder="Search messages..."
class="w-full pl-9 pr-3 py-2 text-sm bg-bg-tertiary border border-border rounded-md text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
bind:value={searchQuery}
/>
</div>
+272
View File
@@ -0,0 +1,272 @@
<script lang="ts">
let { body, truncate }: { body: string; truncate?: number } = $props();
/**
* Lightweight markdown renderer with @mentions, #channels, and URL auto-linking.
* No external dependencies. Input is sanitized (HTML stripped) before processing.
*/
// Strip all HTML tags from raw input to prevent XSS
function stripHtml(text: string): string {
return text.replace(/<[^>]*>/g, '');
}
// Escape HTML special characters in text content
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Process inline markdown: bold, italic, code, URLs, @mentions, #channels
function processInline(text: string): string {
// Inline code first (so content inside is not further processed)
let result = '';
const codeRegex = /`([^`]+)`/g;
let lastIndex = 0;
let match;
while ((match = codeRegex.exec(text)) !== null) {
result += processInlineNonCode(text.slice(lastIndex, match.index));
result += `<code class="msg-inline-code">${escapeHtml(match[1])}</code>`;
lastIndex = codeRegex.lastIndex;
}
result += processInlineNonCode(text.slice(lastIndex));
return result;
}
// Process inline elements that are NOT inside code spans
function processInlineNonCode(text: string): string {
// Extract URLs BEFORE HTML escaping to avoid breaking them
const urlPlaceholders: string[] = [];
let s = text.replace(
/https?:\/\/[^\s<>()[\]"'`]+/g,
(url) => {
// Strip trailing punctuation that's likely not part of the URL
const cleaned = url.replace(/[.,;:!?)]+$/, '');
const trailing = url.slice(cleaned.length);
const idx = urlPlaceholders.length;
urlPlaceholders.push(
`<a href="${escapeHtml(cleaned)}" target="_blank" rel="noopener" class="msg-link">${escapeHtml(cleaned)}</a>${escapeHtml(trailing)}`
);
return `\x01URL${idx}\x01`;
}
);
// Now HTML-escape the rest
s = escapeHtml(s);
// Bold: **text**
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic: *text* (but not inside **)
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// @mentions
s = s.replace(
/@([\w][\w.-]*)/g,
'<a href="/dm/$1" class="msg-mention">@$1</a>'
);
// #channels
s = s.replace(
/#([\w][\w.-]*)/g,
'<a href="/channels/$1" class="msg-channel">$&</a>'
);
// Restore URL placeholders
s = s.replace(/\x01URL(\d+)\x01/g, (_, idx) => urlPlaceholders[parseInt(idx)]);
return s;
}
// Parse a full message body into HTML
function renderMarkdown(raw: string): string {
const sanitized = stripHtml(raw);
// Handle truncation on the sanitized text
let text = sanitized;
if (truncate && text.length > truncate) {
text = text.slice(0, truncate) + '...';
}
// Fenced code blocks: ```...```
const blocks: string[] = [];
const codeBlockRegex = /```(?:\w*)\n?([\s\S]*?)```/g;
let processed = text.replace(codeBlockRegex, (_match, code) => {
const idx = blocks.length;
blocks.push(`<pre class="msg-code-block"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
return `\x00BLOCK${idx}\x00`;
});
// Split into paragraphs by blank lines
const paragraphs = processed.split(/\n{2,}/);
const htmlParts: string[] = [];
for (const para of paragraphs) {
// Check for block placeholder
if (/^\x00BLOCK\d+\x00$/.test(para.trim())) {
const idx = parseInt(para.trim().replace(/\x00BLOCK(\d+)\x00/, '$1'));
htmlParts.push(blocks[idx]);
continue;
}
const lines = para.split('\n');
// Check if this paragraph is a list
const isUnordered = lines.every(l => /^\s*[-*]\s/.test(l) || l.trim() === '');
const isOrdered = lines.every(l => /^\s*\d+\.\s/.test(l) || l.trim() === '');
if (isUnordered && lines.some(l => l.trim() !== '')) {
const items = lines
.filter(l => /^\s*[-*]\s/.test(l))
.map(l => `<li>${processInline(l.replace(/^\s*[-*]\s+/, ''))}</li>`)
.join('');
htmlParts.push(`<ul class="msg-list">${items}</ul>`);
} else if (isOrdered && lines.some(l => l.trim() !== '')) {
const items = lines
.filter(l => /^\s*\d+\.\s/.test(l))
.map(l => `<li>${processInline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`)
.join('');
htmlParts.push(`<ol class="msg-list msg-list-ordered">${items}</ol>`);
} else {
// Check for headers
const headerMatch = para.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
htmlParts.push(`<h${level} class="msg-heading msg-h${level}">${processInline(headerMatch[2])}</h${level}>`);
} else {
// Regular paragraph - preserve line breaks within
const inlineHtml = lines
.map(l => {
// Replace block placeholders inline
if (/\x00BLOCK\d+\x00/.test(l)) {
return l.replace(/\x00BLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]);
}
return processInline(l);
})
.join('<br>');
htmlParts.push(`<p>${inlineHtml}</p>`);
}
}
}
return htmlParts.join('');
}
let rendered = $derived(renderMarkdown(body));
</script>
<div class="msg-body">
{@html rendered}
</div>
<style>
.msg-body {
/* Inherit parent text styling */
}
.msg-body :global(p) {
margin: 0;
}
.msg-body :global(p + p) {
margin-top: 0.5em;
}
.msg-body :global(strong) {
font-weight: 700;
}
.msg-body :global(em) {
font-style: italic;
}
.msg-body :global(.msg-inline-code) {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
padding: 0.15em 0.35em;
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--accent-red);
}
.msg-body :global(.msg-code-block) {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8em;
padding: 0.75em 1em;
border-radius: 6px;
background-color: var(--bg-primary);
border: 1px solid var(--border);
overflow-x: auto;
margin: 0.5em 0;
white-space: pre;
line-height: 1.5;
}
.msg-body :global(.msg-code-block code) {
background: none;
padding: 0;
color: var(--text-primary);
}
.msg-body :global(.msg-link) {
color: var(--text-link);
text-decoration: none;
}
.msg-body :global(.msg-link:hover) {
text-decoration: underline;
}
.msg-body :global(.msg-mention) {
display: inline;
padding: 0.1em 0.4em;
border-radius: 4px;
background-color: rgba(124, 58, 237, 0.15);
color: var(--accent-purple);
font-weight: 600;
font-size: 0.92em;
text-decoration: none;
}
.msg-body :global(.msg-mention:hover) {
background-color: rgba(124, 58, 237, 0.25);
text-decoration: none;
}
.msg-body :global(.msg-channel) {
display: inline;
padding: 0.1em 0.4em;
border-radius: 4px;
background-color: rgba(54, 197, 240, 0.15);
color: var(--accent-blue);
font-weight: 600;
font-size: 0.92em;
text-decoration: none;
}
.msg-body :global(.msg-channel:hover) {
background-color: rgba(54, 197, 240, 0.25);
text-decoration: none;
}
.msg-body :global(.msg-list) {
margin: 0.25em 0;
padding-left: 1.5em;
list-style-type: disc;
}
.msg-body :global(.msg-list-ordered) {
list-style-type: decimal;
}
.msg-body :global(.msg-list li) {
margin: 0.1em 0;
}
.msg-body :global(.msg-heading) {
font-family: 'Instrument Sans', sans-serif;
font-weight: 700;
margin: 0.4em 0 0.2em;
}
.msg-body :global(.msg-h1) {
font-size: 1.3em;
}
.msg-body :global(.msg-h2) {
font-size: 1.15em;
}
.msg-body :global(.msg-h3) {
font-size: 1.05em;
}
.msg-body :global(.msg-h4),
.msg-body :global(.msg-h5),
.msg-body :global(.msg-h6) {
font-size: 1em;
}
</style>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { openThread } from '$lib/stores/thread';
import MessageBody from '$lib/components/MessageBody.svelte';
type Message = {
id: number;
@@ -105,7 +106,7 @@
<span class="badge bg-accent-yellow/20 text-accent-yellow">P{msg.priority}</span>
{/if}
</div>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}</p>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={300} /></div>
<!-- Thread link -->
{#if showConversationLink && msg.conversation_id}
+3 -16
View File
@@ -91,19 +91,6 @@
</button>
</div>
<!-- Search bar -->
<div class="px-3 py-2.5 flex-shrink-0">
<button
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-bg-tertiary rounded text-text-secondary text-xs hover:bg-bg-input transition-colors"
onclick={() => goto('/conversations')}
>
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search messages
</button>
</div>
<!-- Scrollable nav -->
<nav class="flex-1 overflow-y-auto px-2 pb-3">
<!-- Dashboard -->
@@ -117,15 +104,15 @@
Dashboard
</a>
<!-- Conversations -->
<!-- Search -->
<a
href="/conversations"
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Conversations
Search
</a>
<!-- Channels section -->
+2 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { activeThread, closeThread } from '$lib/stores/thread';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
import MessageBody from '$lib/components/MessageBody.svelte';
let conversation = $state<any>(null);
let threadMessages = $state<any[]>([]);
@@ -162,7 +163,7 @@
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
{/if}
</div>
<p class="text-xs text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</p>
<div class="text-xs text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
</div>
</div>
</div>
+7 -3
View File
@@ -21,8 +21,8 @@
return client.onEvent((event) => {
if (event.type === 'new_message') {
const d = event.data;
if (d.channel_name) {
notifications.incrementUnread('channel', d.channel_name);
if (d.channel) {
notifications.incrementUnread('channel', d.channel);
} else if (d.from_agent) {
notifications.incrementUnread('dm', d.from_agent);
}
@@ -50,7 +50,11 @@
});
$effect(() => {
if ($user && sseClient && !sseClient.connected) {
if ($user && !sseClient) {
sseClient = new SSEClient();
sseClient.connect();
sseUnsubscribe = setupNotifications(sseClient);
} else if ($user && sseClient && !sseClient.connected) {
sseClient.connect();
}
});
+2 -1
View File
@@ -3,6 +3,7 @@
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
import { notifications } from '$lib/stores/notifications';
import MessageBody from '$lib/components/MessageBody.svelte';
let channel = $state<any>(null);
let members = $state<any[]>([]);
@@ -268,7 +269,7 @@
{/if}
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
</div>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
{#if msg.reply_count > 0}
<button
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
+415 -75
View File
@@ -1,110 +1,450 @@
<script lang="ts">
import { page } from '$app/stores';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
import { goto } from '$app/navigation';
import { messages as messagesApi, channels as channelsApi } from '$lib/api/client';
let convList = $state<any[]>([]);
let searchQuery = $state('');
let searchResults = $state<any[] | null>(null);
let loadingData = $state(true);
let searchTotal = $state(0);
let loading = $state(false);
let hasSearched = $state(false);
// Filter state
let timeRange = $state('any');
let customAfter = $state('');
let customBefore = $state('');
let channelFilter = $state('');
let agentFilter = $state('');
let showFilters = $state(false);
// Channel list for suggestions
let channelList = $state<string[]>([]);
let _initialized = $state(false);
$effect(() => {
if (!_initialized) {
_initialized = true;
(async () => {
const q = $page.url.searchParams.get('q');
if (q) {
try {
const res = await messagesApi.search(q);
searchResults = res.messages;
} catch {
searchResults = [];
}
}
try {
const res = await convsApi.list();
convList = res.conversations;
} catch {
// handled
} finally {
loadingData = false;
}
})();
// Load channel list for suggestions
channelsApi.list().then((res) => {
channelList = res.channels.map((c: any) => c.name);
}).catch(() => {});
// Check for initial query from URL
const q = $page.url.searchParams.get('q');
if (q) {
searchQuery = q;
performSearch();
}
}
});
function getTimeFilters(): { after?: string; before?: string } {
const now = new Date();
switch (timeRange) {
case '24h': {
const d = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return { after: d.toISOString() };
}
case 'week': {
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return { after: d.toISOString() };
}
case 'month': {
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
return { after: d.toISOString() };
}
case '3months': {
const d = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
return { after: d.toISOString() };
}
case 'custom': {
const result: { after?: string; before?: string } = {};
if (customAfter) result.after = new Date(customAfter).toISOString();
if (customBefore) result.before = new Date(customBefore).toISOString();
return result;
}
default:
return {};
}
}
async function performSearch() {
const q = searchQuery.trim();
if (!q) return;
loading = true;
hasSearched = true;
// Update URL without navigation
const url = new URL(window.location.href);
url.searchParams.set('q', q);
window.history.replaceState({}, '', url.toString());
try {
const timeFilters = getTimeFilters();
const res = await messagesApi.search(q, {
limit: 50,
channel: channelFilter.trim() || undefined,
agent: agentFilter.trim() || undefined,
after: timeFilters.after,
before: timeFilters.before
});
searchResults = res.messages;
searchTotal = res.total;
} catch {
searchResults = [];
searchTotal = 0;
} finally {
loading = false;
}
}
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
performSearch();
}
function clearSearch() {
searchQuery = '';
searchResults = null;
searchTotal = 0;
hasSearched = false;
timeRange = 'any';
customAfter = '';
customBefore = '';
channelFilter = '';
agentFilter = '';
const url = new URL(window.location.href);
url.searchParams.delete('q');
window.history.replaceState({}, '', url.toString());
}
function formatTimestamp(ts: string): string {
const d = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
}
function getMessageContext(msg: any): string {
if (msg.channel_id) {
return 'channel';
}
if (msg.to_agent) {
return `DM with ${msg.to_agent}`;
}
return '';
}
function getMessageLink(msg: any): string {
if (msg.channel_id) {
// We don't have channel name directly, link to conversation
return `/conversations/${msg.conversation_id}`;
}
if (msg.to_agent) {
return `/dm/${msg.from_agent === 'system' ? msg.to_agent : msg.to_agent}`;
}
return `/conversations/${msg.conversation_id}`;
}
let activeFilterCount = $derived(
(timeRange !== 'any' ? 1 : 0) +
(channelFilter.trim() ? 1 : 0) +
(agentFilter.trim() ? 1 : 0)
);
</script>
<div class="p-5 max-w-5xl">
<h1 class="text-xl font-bold text-text-primary font-display mb-5">Conversations</h1>
<div class="p-5 max-w-4xl mx-auto">
<!-- Search box -->
<form onsubmit={handleSubmit} class="mb-4">
<div class="relative">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search messages..."
class="w-full pl-12 pr-24 py-3.5 text-base bg-bg-tertiary border border-border rounded-lg text-text-primary placeholder-text-secondary focus:border-accent-blue focus:ring-1 focus:ring-accent-blue outline-none transition-colors"
bind:value={searchQuery}
autofocus
/>
{#if searchQuery || hasSearched}
<button
type="button"
onclick={clearSearch}
class="absolute right-20 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text-primary transition-colors p-1"
title="Clear search"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
<button
type="submit"
class="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 text-sm font-medium rounded bg-accent-blue text-white hover:brightness-110 transition-all disabled:opacity-50"
disabled={!searchQuery.trim() || loading}
>
Search
</button>
</div>
</form>
{#if searchResults !== null}
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">
Search Results ({searchResults.length})
</h2>
</div>
{#if searchResults.length === 0}
<div class="p-8 text-center text-text-secondary text-sm">
No messages found for '{$page.url.searchParams.get('q')}'
</div>
{:else}
<div class="divide-y divide-border">
{#each searchResults as msg}
<a href="/conversations/{msg.conversation_id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
<span class="text-xs text-text-secondary">{new Date(msg.created_at).toLocaleString()}</span>
</div>
<p class="text-sm text-text-primary/80">{msg.body.slice(0, 200)}</p>
</a>
<!-- Filter toggle -->
<div class="flex items-center gap-3 mb-4">
<button
type="button"
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded transition-colors {showFilters ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary hover:text-text-primary'}"
onclick={() => showFilters = !showFilters}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Filters
{#if activeFilterCount > 0}
<span class="inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-accent-blue text-white">
{activeFilterCount}
</span>
{/if}
</button>
{#if activeFilterCount > 0}
<button
type="button"
onclick={() => { timeRange = 'any'; customAfter = ''; customBefore = ''; channelFilter = ''; agentFilter = ''; }}
class="text-xs text-text-secondary hover:text-accent-red transition-colors"
>
Clear filters
</button>
{/if}
</div>
<!-- Filters panel -->
{#if showFilters}
<div class="card p-4 mb-4 space-y-4">
<!-- Time range -->
<div>
<label class="block text-xs font-medium text-text-secondary mb-1.5">Time range</label>
<div class="flex flex-wrap gap-1.5">
{#each [
{ value: 'any', label: 'Any time' },
{ value: '24h', label: 'Past 24h' },
{ value: 'week', label: 'Past week' },
{ value: 'month', label: 'Past month' },
{ value: '3months', label: 'Past 3 months' },
{ value: 'custom', label: 'Custom range' }
] as opt}
<button
type="button"
onclick={() => timeRange = opt.value}
class="px-3 py-1 text-xs rounded transition-colors {timeRange === opt.value ? 'bg-accent-blue/20 text-accent-blue font-medium' : 'bg-bg-tertiary text-text-secondary hover:text-text-primary'}"
>
{opt.label}
</button>
{/each}
</div>
{#if timeRange === 'custom'}
<div class="flex items-center gap-3 mt-2">
<div class="flex-1">
<label class="block text-[10px] text-text-secondary mb-0.5">From</label>
<input
type="date"
class="w-full px-2 py-1 text-xs bg-bg-input border border-border rounded text-text-primary focus:border-border-active outline-none"
bind:value={customAfter}
/>
</div>
<div class="flex-1">
<label class="block text-[10px] text-text-secondary mb-0.5">To</label>
<input
type="date"
class="w-full px-2 py-1 text-xs bg-bg-input border border-border rounded text-text-primary focus:border-border-active outline-none"
bind:value={customBefore}
/>
</div>
</div>
{/if}
</div>
<!-- Channel filter -->
<div>
<label class="block text-xs font-medium text-text-secondary mb-1.5">
Channels
<span class="font-normal text-text-secondary/60 ml-1">comma-separated, prefix with - to exclude</span>
</label>
<input
type="text"
placeholder="e.g. general, news-tech or -spam, -test"
class="w-full px-3 py-1.5 text-sm bg-bg-input border border-border rounded text-text-primary placeholder-text-secondary/50 focus:border-border-active outline-none"
bind:value={channelFilter}
/>
{#if channelList.length > 0}
<div class="flex flex-wrap gap-1 mt-1.5">
{#each channelList.slice(0, 8) as ch}
<button
type="button"
onclick={() => {
if (channelFilter) {
channelFilter = channelFilter + ', ' + ch;
} else {
channelFilter = ch;
}
}}
class="px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
>
#{ch}
</button>
{/each}
</div>
{/if}
</div>
<!-- Agent filter -->
<div>
<label class="block text-xs font-medium text-text-secondary mb-1.5">
Agents
<span class="font-normal text-text-secondary/60 ml-1">comma-separated, prefix with - to exclude</span>
</label>
<input
type="text"
placeholder="e.g. research-mcpproxy, social-commenter or -system"
class="w-full px-3 py-1.5 text-sm bg-bg-input border border-border rounded text-text-primary placeholder-text-secondary/50 focus:border-border-active outline-none"
bind:value={agentFilter}
/>
</div>
<!-- Apply filters button (re-search) -->
{#if hasSearched}
<button
type="button"
onclick={performSearch}
class="btn-primary text-xs py-1.5 px-4"
>
Apply filters
</button>
{/if}
</div>
{/if}
<div class="card">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">All Conversations</h2>
</div>
{#if loadingData}
<div class="p-5 space-y-4">
<!-- Results area -->
{#if loading}
<div class="card">
<div class="p-5 space-y-5">
{#each Array(5) as _}
<div class="space-y-2">
<div class="skeleton h-3 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
<div class="flex items-center gap-2">
<div class="skeleton h-3 w-24"></div>
<div class="skeleton h-3 w-16"></div>
</div>
<div class="skeleton h-3 w-3/4"></div>
<div class="skeleton h-3 w-1/2"></div>
</div>
{/each}
</div>
{:else if convList.length === 0}
<div class="p-8 text-center text-text-secondary text-sm">
No conversations yet.
</div>
{:else if hasSearched && searchResults !== null}
<!-- Search result count -->
<div class="flex items-center justify-between mb-3">
<p class="text-xs text-text-secondary">
{searchTotal} result{searchTotal !== 1 ? 's' : ''} for "<span class="text-text-primary font-medium">{$page.url.searchParams.get('q')}</span>"
</p>
</div>
{#if searchResults.length === 0}
<div class="card">
<div class="p-12 text-center">
<svg class="w-12 h-12 mx-auto mb-3 text-text-secondary/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-sm text-text-secondary mb-1">No messages found</p>
<p class="text-xs text-text-secondary/60">Try different keywords or adjust your filters</p>
</div>
</div>
{:else}
<div class="divide-y divide-border">
{#each convList as conv}
<a href="/conversations/{conv.id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
{#if conv.last_agent}
<span class="font-semibold text-sm text-text-primary">{conv.last_agent}</span>
<span class="text-[10px] text-text-secondary">&middot;</span>
{/if}
<span class="text-xs text-text-secondary">{conv.updated_at ? new Date(conv.updated_at).toLocaleString() : ''}</span>
</div>
<p class="text-sm text-text-primary/80 truncate">
{conv.last_message ? (conv.last_message.length > 120 ? conv.last_message.slice(0, 120) + '...' : conv.last_message) : 'No messages'}
</p>
<div class="card divide-y divide-border">
{#each searchResults as msg}
<a href={getMessageLink(msg)} class="block px-5 py-3.5 hover:bg-bg-tertiary/50 transition-colors group">
<div class="flex items-center gap-2 mb-1.5">
<!-- Agent avatar -->
<div class="w-6 h-6 rounded flex-shrink-0 flex items-center justify-center text-[10px] font-bold text-white" style="background-color: var(--accent-purple)">
{msg.from_agent?.charAt(0)?.toUpperCase() || '?'}
</div>
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
{conv.message_count}
</span>
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
{#if msg.to_agent}
<svg class="w-3 h-3 text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="text-sm text-text-secondary">{msg.to_agent}</span>
{/if}
<span class="text-[10px] text-text-secondary/60 ml-auto flex-shrink-0">{formatTimestamp(msg.created_at)}</span>
</div>
<p class="text-sm text-text-primary/80 leading-relaxed line-clamp-2 ml-8">
{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}
</p>
{#if getMessageContext(msg)}
<div class="flex items-center gap-1.5 mt-1.5 ml-8">
{#if msg.channel_id}
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
</svg>
channel
</span>
{:else}
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary">
DM
</span>
{/if}
</div>
{/if}
</a>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Empty state - no search performed yet -->
<div class="flex flex-col items-center justify-center py-20">
<div class="w-16 h-16 mb-5 rounded-2xl bg-bg-tertiary flex items-center justify-center">
<svg class="w-8 h-8 text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h2 class="text-lg font-display font-semibold text-text-primary mb-2">Search messages</h2>
<p class="text-sm text-text-secondary text-center max-w-md mb-6">
Search across all your conversations, channels, and direct messages.
Use filters to narrow results by time, channel, or agent.
</p>
<div class="flex flex-wrap gap-2 justify-center">
<button
type="button"
onclick={() => { searchQuery = 'deployment'; performSearch(); }}
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
>
Try "deployment"
</button>
<button
type="button"
onclick={() => { searchQuery = 'error'; performSearch(); }}
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
>
Try "error"
</button>
<button
type="button"
onclick={() => { searchQuery = 'update'; performSearch(); }}
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
>
Try "update"
</button>
</div>
</div>
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
import MessageBody from '$lib/components/MessageBody.svelte';
let conversation = $state<any>(null);
let messagesList = $state<any[]>([]);
@@ -151,7 +152,7 @@
{/if}
<span class="text-xs text-text-secondary ml-auto">{formatTime(msg.created_at)}</span>
</div>
<div class="text-sm text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</div>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
</div>
</div>
</div>
+2 -1
View File
@@ -3,6 +3,7 @@
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
import { openThread, closeThread } from '$lib/stores/thread';
import { notifications } from '$lib/stores/notifications';
import MessageBody from '$lib/components/MessageBody.svelte';
let peerAgent = $derived($page.params.name);
let peer = $state<any>(null);
@@ -244,7 +245,7 @@
<span class="text-[9px] px-1.5 py-0.5 rounded {msg.status === 'pending' ? 'bg-accent-yellow/20 text-accent-yellow' : msg.status === 'processing' ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary'}">{msg.status}</span>
{/if}
</div>
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
{#if msg.reply_count > 0}
<button
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
-23
View File
@@ -64,29 +64,6 @@
</div>
</div>
<!-- Quick links -->
<div class="card mb-5">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Management</h2>
</div>
<div class="divide-y divide-border">
<a href="/settings/api-keys" class="flex items-center justify-between px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
<div class="flex items-center gap-3">
<svg class="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<p class="text-sm text-text-primary font-medium">API Keys</p>
<p class="text-xs text-text-secondary">Manage API keys for agent access</p>
</div>
</div>
<svg class="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
<!-- Change Password -->
<div class="card">
<div class="px-5 py-3 border-b border-border">
@@ -1,397 +0,0 @@
<script lang="ts">
import { apiKeys, agents as agentsApi } from '$lib/api/client';
let keys = $state<any[]>([]);
let agentList = $state<any[]>([]);
let loadingData = $state(true);
// Create form state
let showCreate = $state(false);
let creating = $state(false);
let createError = $state('');
let newKeyName = $state('');
let newKeyAgentId = $state<number | undefined>(undefined);
let newKeyPermRead = $state(true);
let newKeyPermWrite = $state(true);
let newKeyPermAdmin = $state(false);
let newKeyChannels = $state('');
let newKeyReadOnly = $state(false);
let newKeyExpiry = $state('');
// Created key display
let createdKey = $state<{ key: any; api_key: string; mcp_config: any } | null>(null);
let copied = $state(false);
// Revoke
let confirmRevokeId = $state<number | null>(null);
let revoking = $state(false);
let _initialized = $state(false);
$effect(() => {
if (!_initialized) {
_initialized = true;
loadData();
}
});
async function loadData() {
loadingData = true;
try {
const [keysRes, agRes] = await Promise.all([
apiKeys.list(),
agentsApi.list()
]);
keys = keysRes.keys ?? [];
agentList = agRes.agents ?? [];
} catch {
// handled
} finally {
loadingData = false;
}
}
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (!newKeyName.trim()) {
createError = 'Key name is required';
return;
}
creating = true;
createError = '';
try {
const permissions: Record<string, boolean> = {};
if (newKeyPermRead) permissions.read = true;
if (newKeyPermWrite) permissions.write = true;
if (newKeyPermAdmin) permissions.admin = true;
const channels = newKeyChannels.trim()
? newKeyChannels.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
const res = await apiKeys.create({
name: newKeyName.trim(),
agent_id: newKeyAgentId,
permissions: Object.keys(permissions).length > 0 ? permissions : undefined,
allowed_channels: channels,
read_only: newKeyReadOnly || undefined,
expires_at: newKeyExpiry || undefined
});
createdKey = res;
newKeyName = '';
newKeyAgentId = undefined;
newKeyPermRead = true;
newKeyPermWrite = true;
newKeyPermAdmin = false;
newKeyChannels = '';
newKeyReadOnly = false;
newKeyExpiry = '';
showCreate = false;
await loadData();
} catch (err: any) {
createError = err.message || 'Failed to create API key';
} finally {
creating = false;
}
}
async function handleRevoke(id: number) {
revoking = true;
try {
await apiKeys.revoke(id);
confirmRevokeId = null;
await loadData();
} catch (err: any) {
alert(err.message || 'Failed to revoke key');
} finally {
revoking = false;
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch {
// fallback
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copied = true;
setTimeout(() => (copied = false), 2000);
}
}
function formatDate(iso: string): string {
if (!iso) return '-';
return new Date(iso).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });
}
function getMcpConfig(apiKey: string): string {
return JSON.stringify({
mcpServers: {
synapbus: {
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
headers: {
Authorization: `Bearer ${apiKey}`
}
}
}
}, null, 2);
}
function getClaudeConfig(apiKey: string): string {
return JSON.stringify({
synapbus: {
type: 'streamableHttp',
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
headers: {
Authorization: `Bearer ${apiKey}`
}
}
}, null, 2);
}
</script>
<div class="p-5 max-w-5xl">
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-3">
<a href="/settings" class="text-text-secondary hover:text-text-primary transition-colors" aria-label="Back to settings">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h1 class="text-xl font-bold text-text-primary font-display">API Keys</h1>
</div>
<button
class="btn-primary flex items-center gap-1.5"
onclick={() => { showCreate = !showCreate; createdKey = null; }}
>
{#if showCreate}
Cancel
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Create API Key
{/if}
</button>
</div>
<!-- Created key display -->
{#if createdKey}
<div class="card mb-5 border-accent-yellow/30">
<div class="px-5 py-3 border-b border-border bg-accent-yellow/5">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-accent-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span class="text-sm font-semibold text-accent-yellow">This key will only be shown once. Save it now.</span>
</div>
</div>
<div class="p-5 space-y-4">
<!-- The key -->
<div>
<p class="block text-xs font-medium text-text-secondary mb-1.5">API Key</p>
<div class="flex items-center gap-2">
<code class="flex-1 p-3 bg-bg-primary rounded-lg text-sm font-mono text-accent-green break-all select-all border border-accent-green/20">{createdKey.api_key}</code>
<button
class="flex-shrink-0 p-2.5 rounded-lg bg-bg-tertiary hover:bg-border-active text-text-secondary hover:text-text-primary transition-all"
onclick={() => copyToClipboard(createdKey?.api_key ?? '')}
title="Copy to clipboard"
>
{#if copied}
<svg class="w-4 h-4 text-accent-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
{/if}
</button>
</div>
</div>
<!-- MCP Config -->
<div>
<p class="block text-xs font-medium text-text-secondary mb-1.5">MCP Config (for MCP clients)</p>
<pre class="p-3 bg-bg-primary rounded-lg text-xs font-mono text-text-primary/80 overflow-x-auto border border-border">{getMcpConfig(createdKey.api_key)}</pre>
</div>
<!-- Claude Code config -->
<div>
<p class="block text-xs font-medium text-text-secondary mb-1.5">Claude Code Config (add to ~/.claude/mcp.json)</p>
<pre class="p-3 bg-bg-primary rounded-lg text-xs font-mono text-text-primary/80 overflow-x-auto border border-border">{getClaudeConfig(createdKey.api_key)}</pre>
</div>
</div>
<div class="px-5 py-3 border-t border-border">
<button
class="btn-secondary text-xs"
onclick={() => (createdKey = null)}
>
Dismiss
</button>
</div>
</div>
{/if}
<!-- Create form -->
{#if showCreate}
<form class="card p-5 mb-5" onsubmit={handleCreate}>
<h3 class="font-semibold text-sm text-text-primary font-display mb-4">Create New API Key</h3>
{#if createError}
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{createError}</div>
{/if}
<div class="space-y-4">
<div>
<label for="key-name" class="block text-xs font-medium text-text-secondary mb-1.5">Name</label>
<input id="key-name" type="text" class="input" placeholder="e.g. production-researcher" bind:value={newKeyName} required />
</div>
<div>
<label for="key-agent" class="block text-xs font-medium text-text-secondary mb-1.5">Agent (optional)</label>
<select id="key-agent" class="input" bind:value={newKeyAgentId}>
<option value={undefined}>No specific agent</option>
{#each agentList as agent}
<option value={agent.id}>{agent.display_name || agent.name} (@{agent.name})</option>
{/each}
</select>
</div>
<div>
<p class="block text-xs font-medium text-text-secondary mb-2">Permissions</p>
<div class="flex flex-wrap gap-4">
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyPermRead} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Read
</label>
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyPermWrite} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Write
</label>
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyPermAdmin} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Admin
</label>
</div>
</div>
<div>
<label for="key-channels" class="block text-xs font-medium text-text-secondary mb-1.5">Allowed Channels (comma-separated, leave empty for all)</label>
<input id="key-channels" type="text" class="input" placeholder="general, research, alerts" bind:value={newKeyChannels} />
</div>
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
<input type="checkbox" bind:checked={newKeyReadOnly} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
Read-only mode
</label>
<div>
<label for="key-expiry" class="block text-xs font-medium text-text-secondary mb-1.5">Expiry Date (optional)</label>
<input id="key-expiry" type="date" class="input" bind:value={newKeyExpiry} />
</div>
<button type="submit" class="btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create API Key'}
</button>
</div>
</form>
{/if}
<!-- Keys list -->
<div class="card">
<div class="px-5 py-3 border-b border-border">
<h2 class="font-semibold text-sm text-text-primary font-display">Your API Keys</h2>
</div>
{#if loadingData}
<div class="p-5 space-y-3">
{#each Array(3) as _}
<div class="skeleton h-10 w-full"></div>
{/each}
</div>
{:else if keys.length === 0}
<div class="p-8 text-center">
<svg class="w-10 h-10 mx-auto mb-3 text-border-active" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p class="text-sm text-text-secondary">No API keys yet. Create one to get started.</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border">
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Name</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Prefix</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Permissions</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Channels</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Expires</th>
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Created</th>
<th class="text-right px-5 py-2.5 text-xs font-medium text-text-secondary">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
{#each keys as key}
<tr class="hover:bg-bg-tertiary/30 transition-colors">
<td class="px-5 py-2.5 text-text-primary font-medium">{key.name}</td>
<td class="px-5 py-2.5 font-mono text-xs text-text-secondary">{key.prefix ?? 'sb_...'}</td>
<td class="px-5 py-2.5">
<div class="flex gap-1">
{#if key.permissions?.read}
<span class="badge bg-accent-blue/20 text-accent-blue">read</span>
{/if}
{#if key.permissions?.write}
<span class="badge bg-accent-green/20 text-accent-green">write</span>
{/if}
{#if key.permissions?.admin}
<span class="badge bg-accent-purple/20 text-accent-purple">admin</span>
{/if}
{#if key.read_only}
<span class="badge bg-accent-yellow/20 text-accent-yellow">RO</span>
{/if}
</div>
</td>
<td class="px-5 py-2.5 text-xs text-text-secondary">
{key.allowed_channels?.length ? key.allowed_channels.join(', ') : 'All'}
</td>
<td class="px-5 py-2.5 text-xs text-text-secondary">{formatDate(key.expires_at)}</td>
<td class="px-5 py-2.5 text-xs text-text-secondary">{formatDate(key.created_at)}</td>
<td class="px-5 py-2.5 text-right">
{#if confirmRevokeId === key.id}
<div class="flex items-center justify-end gap-1">
<button
class="px-2 py-1 text-xs bg-accent-red text-white rounded hover:brightness-110 transition-all disabled:opacity-50"
onclick={() => handleRevoke(key.id)}
disabled={revoking}
>
{revoking ? '...' : 'Confirm'}
</button>
<button
class="px-2 py-1 text-xs bg-bg-tertiary text-text-primary rounded hover:bg-border-active transition-all"
onclick={() => (confirmRevokeId = null)}
>
Cancel
</button>
</div>
{:else}
<button
class="px-2 py-1 text-xs text-accent-red hover:bg-accent-red/10 rounded transition-colors"
onclick={() => (confirmRevokeId = key.id)}
>
Revoke
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
+25 -2
View File
@@ -1,3 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#7c3aed"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
<g transform="translate(4,4)">
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 1.4 KiB