Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef2705d08 | ||
|
|
faad47f0cf | ||
|
|
47ba1e9740 | ||
|
|
6d817867f2 | ||
|
|
877900af98 | ||
|
|
640838d5a6 | ||
|
|
6abd45eeff | ||
|
|
e94dcb84fd | ||
|
|
96d9fbd246 | ||
|
|
5938fcd555 |
+1
-1
@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache ca-certificates tzdata && touch /.dockerenv
|
||||
COPY --from=go-builder /synapbus /synapbus
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -966,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)
|
||||
}
|
||||
|
||||
@@ -270,8 +270,8 @@ func TestDefaultSocketPath(t *testing.T) {
|
||||
if f == nil {
|
||||
t.Fatal("--socket persistent flag not found")
|
||||
}
|
||||
if f.DefValue != "/data/synapbus.sock" {
|
||||
t.Errorf("default socket path = %q, want %q", f.DefValue, "/data/synapbus.sock")
|
||||
if f.DefValue != "/tmp/synapbus.sock" {
|
||||
t.Errorf("default socket path = %q, want %q", f.DefValue, "/tmp/synapbus.sock")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-2
@@ -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)
|
||||
|
||||
@@ -60,6 +60,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: run
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
@@ -68,6 +70,10 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 >= ?")
|
||||
|
||||
Vendored
+11
-11
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">·</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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user