feat: implement Web UI with Svelte 5 SPA and REST API

Add complete Web UI infrastructure:

Go backend:
- REST API handlers for messages, agents, channels (internal/api/)
- SSE hub for real-time event streaming
- Session-to-owner middleware bridging auth sessions to API context
- SPA file server with go:embed for static assets
- GetMessageByID on MessagingService, RevokeKey on AgentService
- Updated router with RouterConfig for full service wiring

Svelte 5 SPA (web/):
- SvelteKit with static adapter for SPA mode
- Tailwind CSS with dark mode (class-based, localStorage persisted)
- API client with auto-redirect on 401
- SSE client with exponential backoff reconnect
- Pages: Login, Dashboard, Conversations, Channels, Agents, Settings
- Components: Sidebar, Header, MessageList, ComposeForm, AgentCard, TraceViewer
- Responsive layout with mobile sidebar toggle

Build:
- Placeholder index.html in internal/web/dist/ for go:embed compilation
- Updated Makefile web target to copy build output
- All existing Go tests pass, CGO_ENABLED=0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-13 12:34:36 +02:00
co-authored by Claude Opus 4.6
parent 29fe5c4275
commit 78735bb8ee
45 changed files with 3192 additions and 12 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ data/
web/node_modules/
web/build/
web/.svelte-kit/
internal/web/dist/
internal/web/dist/*
!internal/web/dist/index.html
# IDE
.idea/
+2 -1
View File
@@ -18,7 +18,8 @@ dev:
web:
cd web && npm install && npm run build
@echo "Svelte SPA built to internal/web/dist/"
rm -rf internal/web/dist
cp -r web/build internal/web/dist
clean:
rm -rf $(BUILD_DIR)
+19 -2
View File
@@ -29,6 +29,7 @@ import (
"github.com/smart-mcp-proxy/synapbus/internal/search/embedding"
"github.com/smart-mcp-proxy/synapbus/internal/storage"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
"github.com/smart-mcp-proxy/synapbus/internal/web"
)
var (
@@ -339,10 +340,26 @@ func runServe(cmd *cobra.Command, args []string) error {
// MCP SSE endpoint
r.Mount("/mcp", mcpSrv.SSEHandler())
// Mount API routes (traces, export, stats, metrics, attachments)
apiRouter := api.NewRouter(traceStore, metrics, attachmentService)
// Create SSE hub for real-time events
sseHub := api.NewSSEHub()
// Mount API routes (traces, export, stats, metrics, attachments, messages, agents, channels, SSE)
sessionMiddleware := api.SessionToOwnerMiddleware(userStore, sessionStore)
apiRouter := api.NewRouterWithConfig(api.RouterConfig{
TraceStore: traceStore,
Metrics: metrics,
AttachmentService: attachmentService,
MsgService: msgService,
AgentService: agentService,
ChannelService: channelService,
SSEHub: sseHub,
SessionMiddleware: sessionMiddleware,
})
r.Mount("/", apiRouter)
// Serve embedded Web UI SPA (catch-all for non-API routes)
r.NotFound(web.NewSPAHandler().ServeHTTP)
// Start HTTP server
addr := fmt.Sprintf(":%d", port)
srv := &http.Server{
+46
View File
@@ -209,6 +209,52 @@ func (s *AgentService) ListAgents(ctx context.Context, ownerID int64) ([]*Agent,
return s.store.ListAgentsByOwner(ctx, ownerID)
}
// RevokeKey generates a new API key for an agent. Only the owner can do this.
// Returns the agent and the new raw API key (shown once).
func (s *AgentService) RevokeKey(ctx context.Context, name string, ownerID int64) (*Agent, string, error) {
agent, err := s.store.GetAgentByName(ctx, name)
if err != nil {
if err == sql.ErrNoRows {
return nil, "", fmt.Errorf("agent not found: %s", name)
}
return nil, "", err
}
if agent.OwnerID != ownerID {
return nil, "", fmt.Errorf("only the agent's owner can revoke its API key")
}
// Generate new API key
apiKey, err := generateAPIKey()
if err != nil {
return nil, "", fmt.Errorf("generate API key: %w", err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(apiKey), bcrypt.DefaultCost)
if err != nil {
return nil, "", fmt.Errorf("hash API key: %w", err)
}
agent.APIKeyHash = string(hash)
if err := s.store.UpdateAgent(ctx, agent); err != nil {
return nil, "", fmt.Errorf("update agent: %w", err)
}
s.logger.Info("agent API key revoked",
"name", name,
"owner_id", ownerID,
)
if s.tracer != nil {
s.tracer.Record(ctx, name, "revoke_api_key", map[string]any{
"agent_id": agent.ID,
"owner_id": ownerID,
})
}
return agent, apiKey, nil
}
// generateAPIKey creates a cryptographically random API key (32 bytes, hex encoded).
func generateAPIKey() (string, error) {
b := make([]byte, 32)
+166
View File
@@ -0,0 +1,166 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// AgentsHandler handles REST API requests for agents.
type AgentsHandler struct {
agentService *agents.AgentService
traceStore trace.TraceStore
logger *slog.Logger
}
// NewAgentsHandler creates a new agents handler.
func NewAgentsHandler(agentService *agents.AgentService, traceStore trace.TraceStore) *AgentsHandler {
return &AgentsHandler{
agentService: agentService,
traceStore: traceStore,
logger: slog.Default().With("component", "api.agents"),
}
}
// ListAgents handles GET /api/agents.
func (h *AgentsHandler) ListAgents(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
agentList, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
writeJSON(w, http.StatusOK, map[string]any{"agents": agentList})
}
// GetAgent handles GET /api/agents/{name}.
func (h *AgentsHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
agent, err := h.agentService.GetAgent(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Agent not found"))
return
}
if agent.OwnerID != ownerID {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this agent"))
return
}
// Get recent traces for this agent
var traces []trace.Trace
if h.traceStore != nil {
filter := trace.TraceFilter{
AgentName: name,
PageSize: 20,
Page: 1,
}
traces, _, _ = h.traceStore.Query(r.Context(), filter)
}
if traces == nil {
traces = []trace.Trace{}
}
writeJSON(w, http.StatusOK, map[string]any{
"agent": agent,
"traces": traces,
})
}
// RegisterAgent handles POST /api/agents.
func (h *AgentsHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Type string `json:"type"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Agent name is required"))
return
}
agent, apiKey, err := h.agentService.Register(r.Context(), req.Name, req.DisplayName, req.Type, req.Capabilities, ownerID)
if err != nil {
h.logger.Error("register agent failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("register_failed", err.Error()))
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"agent": agent,
"api_key": apiKey,
})
}
// DeleteAgent handles DELETE /api/agents/{name}.
func (h *AgentsHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
if err := h.agentService.Deregister(r.Context(), name, ownerID); err != nil {
h.logger.Error("deregister agent failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("deregister_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deregistered"})
}
// RevokeKey handles POST /api/agents/{name}/revoke-key.
func (h *AgentsHandler) RevokeKey(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
agent, newKey, err := h.agentService.RevokeKey(r.Context(), name, ownerID)
if err != nil {
h.logger.Error("revoke key failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("revoke_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"agent": agent,
"api_key": newKey,
})
}
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/channels"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
)
// ChannelsHandler handles REST API requests for channels.
type ChannelsHandler struct {
channelService *channels.Service
agentService *agents.AgentService
msgService *messaging.MessagingService
logger *slog.Logger
}
// NewChannelsHandler creates a new channels handler.
func NewChannelsHandler(channelService *channels.Service, agentService *agents.AgentService, msgService *messaging.MessagingService) *ChannelsHandler {
return &ChannelsHandler{
channelService: channelService,
agentService: agentService,
msgService: msgService,
logger: slog.Default().With("component", "api.channels"),
}
}
// ListChannels handles GET /api/channels.
func (h *ChannelsHandler) ListChannels(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
// Get the first owned agent to list channels visible to it
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
var allChannels []*channels.ChannelWithCount
seen := make(map[int64]bool)
for _, agent := range ownedAgents {
chs, err := h.channelService.ListChannels(r.Context(), agent.Name)
if err != nil {
continue
}
for _, ch := range chs {
if !seen[ch.ID] {
seen[ch.ID] = true
allChannels = append(allChannels, ch)
}
}
}
if allChannels == nil {
allChannels = []*channels.ChannelWithCount{}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": allChannels})
}
// GetChannel handles GET /api/channels/{name}.
func (h *ChannelsHandler) GetChannel(w http.ResponseWriter, r *http.Request) {
_, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
members, err := h.channelService.GetMembers(r.Context(), ch.ID)
if err != nil {
members = []*channels.Membership{}
}
writeJSON(w, http.StatusOK, map[string]any{
"channel": ch,
"members": members,
})
}
// CreateChannel handles POST /api/channels.
func (h *ChannelsHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Topic string `json:"topic"`
Type string `json:"type"`
IsPrivate bool `json:"is_private"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Name == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Channel name is required"))
return
}
// Use the first owned agent as the creator
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil || len(ownedAgents) == 0 {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered. Register an agent first."))
return
}
createReq := channels.CreateChannelRequest{
Name: req.Name,
Description: req.Description,
Topic: req.Topic,
Type: req.Type,
IsPrivate: req.IsPrivate,
CreatedBy: ownedAgents[0].Name,
}
ch, err := h.channelService.CreateChannel(r.Context(), createReq)
if err != nil {
h.logger.Error("create channel failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("create_failed", err.Error()))
return
}
writeJSON(w, http.StatusCreated, ch)
}
// JoinChannel handles POST /api/channels/{name}/join.
func (h *ChannelsHandler) JoinChannel(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
var req struct {
Agent string `json:"agent"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
// If no agent specified, use the first owned agent
agentName := req.Agent
if agentName == "" {
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil || len(ownedAgents) == 0 {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered"))
return
}
agentName = ownedAgents[0].Name
}
// Verify the agent belongs to this user
agent, err := h.agentService.GetAgent(r.Context(), agentName)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_agent", "Agent not found"))
return
}
if agent.OwnerID != ownerID {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not own this agent"))
return
}
if err := h.channelService.JoinChannel(r.Context(), ch.ID, agentName); err != nil {
h.logger.Error("join channel failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("join_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
// LeaveChannel handles POST /api/channels/{name}/leave.
func (h *ChannelsHandler) LeaveChannel(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
name := chi.URLParam(r, "name")
ch, err := h.channelService.GetChannelByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Channel not found"))
return
}
var req struct {
Agent string `json:"agent"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
agentName := req.Agent
if agentName == "" {
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil || len(ownedAgents) == 0 {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered"))
return
}
agentName = ownedAgents[0].Name
}
agent, err := h.agentService.GetAgent(r.Context(), agentName)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_agent", "Agent not found"))
return
}
if agent.OwnerID != ownerID {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not own this agent"))
return
}
if err := h.channelService.LeaveChannel(r.Context(), ch.ID, agentName); err != nil {
h.logger.Error("leave channel failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("leave_failed", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "left"})
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"encoding/json"
"net/http"
)
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// errorBody creates a standard error response body.
func errorBody(code, message string) map[string]string {
return map[string]string{
"error": code,
"message": message,
}
}
// truncateStr truncates a string to the given length, appending "..." if truncated.
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
+404
View File
@@ -0,0 +1,404 @@
package api
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
)
// MessagesHandler handles REST API requests for messages.
type MessagesHandler struct {
msgService *messaging.MessagingService
agentService *agents.AgentService
logger *slog.Logger
}
// NewMessagesHandler creates a new messages handler.
func NewMessagesHandler(msgService *messaging.MessagingService, agentService *agents.AgentService) *MessagesHandler {
return &MessagesHandler{
msgService: msgService,
agentService: agentService,
logger: slog.Default().With("component", "api.messages"),
}
}
// ListMessages handles GET /api/messages.
func (h *MessagesHandler) ListMessages(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
if len(ownedAgents) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"messages": []*messaging.Message{}, "total": 0})
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 50
}
status := r.URL.Query().Get("status")
agentFilter := r.URL.Query().Get("agent")
var allMessages []*messaging.Message
for _, agent := range ownedAgents {
if agentFilter != "" && agent.Name != agentFilter {
continue
}
opts := messaging.ReadOptions{
Limit: limit,
IncludeRead: true,
Status: status,
}
msgs, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
if err != nil {
h.logger.Error("read inbox failed", "agent", agent.Name, "error", err)
continue
}
allMessages = append(allMessages, msgs...)
}
if allMessages == nil {
allMessages = []*messaging.Message{}
}
sortMessagesByTime(allMessages)
if len(allMessages) > limit {
allMessages = allMessages[:limit]
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": allMessages,
"total": len(allMessages),
})
}
// GetMessage handles GET /api/messages/{id}.
func (h *MessagesHandler) GetMessage(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
if !h.isAgentOwnedBy(r, msg.FromAgent, ownerID) && !h.isAgentOwnedBy(r, msg.ToAgent, ownerID) {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this message"))
return
}
writeJSON(w, http.StatusOK, msg)
}
// ListConversations handles GET /api/conversations.
func (h *MessagesHandler) ListConversations(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
if len(ownedAgents) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"conversations": []any{}})
return
}
type convSummary struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
LastMessage string `json:"last_message"`
LastAgent string `json:"last_agent"`
LastTime string `json:"last_time"`
MsgCount int `json:"message_count"`
}
convMap := make(map[int64]*convSummary)
for _, agent := range ownedAgents {
opts := messaging.ReadOptions{
Limit: 100,
IncludeRead: true,
}
msgs, err := h.msgService.ReadInbox(r.Context(), agent.Name, opts)
if err != nil {
continue
}
for _, msg := range msgs {
existing, exists := convMap[msg.ConversationID]
if !exists {
convMap[msg.ConversationID] = &convSummary{
ID: msg.ConversationID,
LastMessage: truncateStr(msg.Body, 100),
LastAgent: msg.FromAgent,
LastTime: msg.CreatedAt.Format(time.RFC3339),
MsgCount: 1,
}
} else {
existing.MsgCount++
lt, _ := time.Parse(time.RFC3339, existing.LastTime)
if msg.CreatedAt.After(lt) {
existing.LastMessage = truncateStr(msg.Body, 100)
existing.LastAgent = msg.FromAgent
existing.LastTime = msg.CreatedAt.Format(time.RFC3339)
}
}
}
}
conversations := make([]*convSummary, 0, len(convMap))
for _, c := range convMap {
conv, _, err := h.msgService.GetConversation(r.Context(), c.ID)
if err == nil {
c.Subject = conv.Subject
}
conversations = append(conversations, c)
}
writeJSON(w, http.StatusOK, map[string]any{"conversations": conversations})
}
// GetConversation handles GET /api/conversations/{id}.
func (h *MessagesHandler) GetConversation(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid conversation ID"))
return
}
conv, messages, err := h.msgService.GetConversation(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Conversation not found"))
return
}
hasAccess := false
for _, msg := range messages {
if h.isAgentOwnedBy(r, msg.FromAgent, ownerID) || h.isAgentOwnedBy(r, msg.ToAgent, ownerID) {
hasAccess = true
break
}
}
if !hasAccess && len(messages) > 0 {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this conversation"))
return
}
writeJSON(w, http.StatusOK, map[string]any{
"conversation": conv,
"messages": messages,
})
}
// SendMessage handles POST /api/messages.
func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
var req struct {
From string `json:"from"`
To string `json:"to"`
Body string `json:"body"`
Priority int `json:"priority"`
ChannelID *int64 `json:"channel_id,omitempty"`
Subject string `json:"subject,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_request", "Invalid JSON body"))
return
}
if req.Body == "" {
writeJSON(w, http.StatusBadRequest, errorBody("validation_error", "Message body is required"))
return
}
if req.From == "" {
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil || len(ownedAgents) == 0 {
writeJSON(w, http.StatusBadRequest, errorBody("no_agents", "No agents registered. Register an agent first."))
return
}
req.From = ownedAgents[0].Name
}
if !h.isAgentOwnedBy(r, req.From, ownerID) {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not own agent: "+req.From))
return
}
opts := messaging.SendOptions{
Priority: req.Priority,
ChannelID: req.ChannelID,
Subject: req.Subject,
}
msg, err := h.msgService.SendMessage(r.Context(), req.From, req.To, req.Body, opts)
if err != nil {
h.logger.Error("send message failed", "error", err)
writeJSON(w, http.StatusBadRequest, errorBody("send_failed", err.Error()))
return
}
writeJSON(w, http.StatusCreated, msg)
}
// MarkDone handles POST /api/messages/{id}/done.
func (h *MessagesHandler) MarkDone(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_id", "Invalid message ID"))
return
}
msg, err := h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
agentToCheck := msg.ToAgent
if msg.ClaimedBy != "" {
agentToCheck = msg.ClaimedBy
}
if !h.isAgentOwnedBy(r, agentToCheck, ownerID) {
writeJSON(w, http.StatusForbidden, errorBody("forbidden", "You do not have access to this message"))
return
}
if msg.Status == messaging.StatusPending {
_, _ = h.msgService.ClaimMessages(r.Context(), msg.ToAgent, 1)
msg, err = h.msgService.GetMessageByID(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, errorBody("not_found", "Message not found"))
return
}
}
if msg.Status != messaging.StatusProcessing {
writeJSON(w, http.StatusBadRequest, errorBody("invalid_status", "Message is not in processing status"))
return
}
if err := h.msgService.MarkDone(r.Context(), id, msg.ClaimedBy); err != nil {
h.logger.Error("mark done failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "done"})
}
// SearchMessages handles GET /api/messages/search.
func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, errorBody("unauthorized", "Authentication required"))
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusBadRequest, errorBody("missing_query", "Search query 'q' is required"))
return
}
ownedAgents, err := h.agentService.ListAgents(r.Context(), ownerID)
if err != nil {
h.logger.Error("list agents failed", "error", err)
writeJSON(w, http.StatusInternalServerError, errorBody("server_error", "Failed to list agents"))
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 20
}
var allMessages []*messaging.Message
for _, agent := range ownedAgents {
opts := messaging.SearchOptions{Limit: limit}
msgs, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
if err != nil {
continue
}
allMessages = append(allMessages, msgs...)
}
if allMessages == nil {
allMessages = []*messaging.Message{}
}
writeJSON(w, http.StatusOK, map[string]any{
"messages": allMessages,
"query": query,
"total": len(allMessages),
})
}
func (h *MessagesHandler) isAgentOwnedBy(r *http.Request, agentName string, ownerID int64) bool {
if agentName == "" {
return false
}
agent, err := h.agentService.GetAgent(r.Context(), agentName)
if err != nil {
return false
}
return agent.OwnerID == ownerID
}
func sortMessagesByTime(msgs []*messaging.Message) {
for i := 1; i < len(msgs); i++ {
for j := i; j > 0 && msgs[j].CreatedAt.After(msgs[j-1].CreatedAt); j-- {
msgs[j], msgs[j-1] = msgs[j-1], msgs[j]
}
}
}
+89 -8
View File
@@ -5,25 +5,56 @@ import (
"github.com/go-chi/chi/v5"
"github.com/smart-mcp-proxy/synapbus/internal/agents"
"github.com/smart-mcp-proxy/synapbus/internal/attachments"
"github.com/smart-mcp-proxy/synapbus/internal/channels"
"github.com/smart-mcp-proxy/synapbus/internal/messaging"
"github.com/smart-mcp-proxy/synapbus/internal/trace"
)
// RouterConfig holds optional services for the API router.
// Fields may be nil if the corresponding feature is not configured.
type RouterConfig struct {
TraceStore trace.TraceStore
Metrics *trace.Metrics
AttachmentService *attachments.Service
MsgService *messaging.MessagingService
AgentService *agents.AgentService
ChannelService *channels.Service
SSEHub *SSEHub
SessionMiddleware func(http.Handler) http.Handler
}
// NewRouter creates a chi router with all API routes configured.
// metricsInstance may be nil if metrics are disabled.
// attachmentService may be nil if attachments are not configured.
func NewRouter(traceStore trace.TraceStore, metricsInstance *trace.Metrics, attachmentService *attachments.Service) chi.Router {
return NewRouterWithConfig(RouterConfig{
TraceStore: traceStore,
Metrics: metricsInstance,
AttachmentService: attachmentService,
})
}
// NewRouterWithConfig creates a chi router using the full configuration.
func NewRouterWithConfig(cfg RouterConfig) chi.Router {
r := chi.NewRouter()
// Global middleware
r.Use(RequestIDMiddleware)
r.Use(LoggingMiddleware)
tracesHandler := NewTracesHandler(traceStore)
tracesHandler := NewTracesHandler(cfg.TraceStore)
// Authenticated API routes
// Determine which auth middleware to use for API routes
authMiddleware := OwnerAuthMiddleware
if cfg.SessionMiddleware != nil {
authMiddleware = cfg.SessionMiddleware
}
// Authenticated API routes (traces)
r.Group(func(r chi.Router) {
r.Use(OwnerAuthMiddleware)
r.Use(authMiddleware)
r.Get("/api/traces", tracesHandler.ListTraces)
r.Get("/api/traces/export", tracesHandler.ExportTraces)
@@ -31,21 +62,71 @@ func NewRouter(traceStore trace.TraceStore, metricsInstance *trace.Metrics, atta
})
// Attachment API routes (for Web UI)
if attachmentService != nil {
attachmentsHandler := NewAttachmentsHandler(attachmentService)
if cfg.AttachmentService != nil {
attachmentsHandler := NewAttachmentsHandler(cfg.AttachmentService)
r.Get("/api/attachments/{hash}", attachmentsHandler.Download)
r.Get("/api/attachments/{hash}/meta", attachmentsHandler.Metadata)
r.Group(func(r chi.Router) {
r.Use(OwnerAuthMiddleware)
r.Use(authMiddleware)
r.Post("/api/attachments", attachmentsHandler.Upload)
})
}
// Web UI API routes (messages, agents, channels, SSE)
if cfg.MsgService != nil && cfg.AgentService != nil {
messagesHandler := NewMessagesHandler(cfg.MsgService, cfg.AgentService)
agentsHandler := NewAgentsHandler(cfg.AgentService, cfg.TraceStore)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
// Messages
r.Get("/api/messages", messagesHandler.ListMessages)
r.Get("/api/messages/search", messagesHandler.SearchMessages)
r.Get("/api/messages/{id}", messagesHandler.GetMessage)
r.Post("/api/messages", messagesHandler.SendMessage)
r.Post("/api/messages/{id}/done", messagesHandler.MarkDone)
// Conversations
r.Get("/api/conversations", messagesHandler.ListConversations)
r.Get("/api/conversations/{id}", messagesHandler.GetConversation)
// Agents
r.Get("/api/agents", agentsHandler.ListAgents)
r.Get("/api/agents/{name}", agentsHandler.GetAgent)
r.Post("/api/agents", agentsHandler.RegisterAgent)
r.Delete("/api/agents/{name}", agentsHandler.DeleteAgent)
r.Post("/api/agents/{name}/revoke-key", agentsHandler.RevokeKey)
})
// Channels
if cfg.ChannelService != nil {
channelsHandler := NewChannelsHandler(cfg.ChannelService, cfg.AgentService, cfg.MsgService)
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/channels", channelsHandler.ListChannels)
r.Get("/api/channels/{name}", channelsHandler.GetChannel)
r.Post("/api/channels", channelsHandler.CreateChannel)
r.Post("/api/channels/{name}/join", channelsHandler.JoinChannel)
r.Post("/api/channels/{name}/leave", channelsHandler.LeaveChannel)
})
}
// SSE events
if cfg.SSEHub != nil {
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/api/events", cfg.SSEHub.HandleEvents)
})
}
}
// Metrics endpoint (unauthenticated, only registered when enabled)
if metricsInstance != nil {
if cfg.Metrics != nil {
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
metricsInstance.WritePrometheus(w)
cfg.Metrics.WritePrometheus(w)
})
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"net/http"
"github.com/smart-mcp-proxy/synapbus/internal/auth"
)
// SessionToOwnerMiddleware wraps RequireSession and extracts the owner ID
// into the API context so that API handlers can use OwnerIDFromContext.
func SessionToOwnerMiddleware(userStore auth.UserStore, sessionStore auth.SessionStore) func(http.Handler) http.Handler {
sessionMW := auth.RequireSession(userStore, sessionStore)
return func(next http.Handler) http.Handler {
return sessionMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized","message":"Authentication required"}`, http.StatusUnauthorized)
return
}
ctx := ContextWithOwnerID(r.Context(), user.ID)
next.ServeHTTP(w, r.WithContext(ctx))
}))
}
}
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
// SSEEvent represents a server-sent event.
type SSEEvent struct {
Type string `json:"type"`
Data any `json:"data"`
}
// SSEHub manages SSE client connections and event broadcasting.
type SSEHub struct {
mu sync.RWMutex
clients map[int64]map[chan SSEEvent]struct{} // ownerID -> set of channels
nextID int64
logger *slog.Logger
}
// NewSSEHub creates a new SSE hub.
func NewSSEHub() *SSEHub {
return &SSEHub{
clients: make(map[int64]map[chan SSEEvent]struct{}),
logger: slog.Default().With("component", "api.sse"),
}
}
// Broadcast sends an event to all clients for the given owner.
func (h *SSEHub) Broadcast(ownerID int64, event SSEEvent) {
h.mu.RLock()
defer h.mu.RUnlock()
clientSet, ok := h.clients[ownerID]
if !ok {
return
}
for ch := range clientSet {
select {
case ch <- event:
default:
// Client channel full, skip
}
}
}
// BroadcastAll sends an event to all connected clients.
func (h *SSEHub) BroadcastAll(event SSEEvent) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, clientSet := range h.clients {
for ch := range clientSet {
select {
case ch <- event:
default:
}
}
}
}
func (h *SSEHub) addClient(ownerID int64) chan SSEEvent {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan SSEEvent, 32)
if _, ok := h.clients[ownerID]; !ok {
h.clients[ownerID] = make(map[chan SSEEvent]struct{})
}
h.clients[ownerID][ch] = struct{}{}
h.logger.Info("SSE client connected", "owner_id", ownerID)
return ch
}
func (h *SSEHub) removeClient(ownerID int64, ch chan SSEEvent) {
h.mu.Lock()
defer h.mu.Unlock()
if clientSet, ok := h.clients[ownerID]; ok {
delete(clientSet, ch)
if len(clientSet) == 0 {
delete(h.clients, ownerID)
}
}
close(ch)
h.logger.Info("SSE client disconnected", "owner_id", ownerID)
}
// HandleEvents handles GET /api/events — the SSE endpoint.
func (h *SSEHub) HandleEvents(w http.ResponseWriter, r *http.Request) {
ownerID, ok := OwnerIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
ch := h.addClient(ownerID)
defer h.removeClient(ownerID, ch)
// Send initial connected event
writeSSE(w, flusher, "connected", map[string]any{
"owner_id": ownerID,
"timestamp": time.Now().Format(time.RFC3339),
})
// Heartbeat ticker
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case event, ok := <-ch:
if !ok {
return
}
writeSSE(w, flusher, event.Type, event.Data)
case <-ticker.C:
writeSSE(w, flusher, "heartbeat", map[string]any{
"timestamp": time.Now().Format(time.RFC3339),
})
}
}
}
func writeSSE(w http.ResponseWriter, flusher http.Flusher, eventType string, data any) {
jsonData, err := json.Marshal(data)
if err != nil {
return
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, string(jsonData))
flusher.Flush()
}
+12
View File
@@ -314,6 +314,18 @@ func (s *MessagingService) SearchMessages(ctx context.Context, agentName, query
return messages, nil
}
// GetMessageByID returns a single message by its ID.
func (s *MessagingService) GetMessageByID(ctx context.Context, id int64) (*Message, error) {
msg, err := s.store.GetMessageByID(ctx, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("message not found: %d", id)
}
return nil, fmt.Errorf("get message: %w", err)
}
return msg, nil
}
// GetConversation returns a conversation and its messages.
func (s *MessagingService) GetConversation(ctx context.Context, id int64) (*Conversation, []*Message, error) {
conv, err := s.store.GetConversation(ctx, id)
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SynapBus</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f8fafc; color: #334155; }
.placeholder { text-align: center; }
.placeholder h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.placeholder p { color: #64748b; }
</style>
</head>
<body>
<div class="placeholder">
<h1>SynapBus</h1>
<p>Web UI placeholder. Run <code>make web</code> to build the Svelte SPA.</p>
</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
// Package web embeds the built Svelte SPA for serving from the Go binary.
package web
import "embed"
//go:embed dist/*
var DistFS embed.FS
+43
View File
@@ -0,0 +1,43 @@
package web
import (
"io/fs"
"net/http"
"strings"
)
// NewSPAHandler returns an http.Handler that serves the embedded SPA.
// Static files are served directly; all other routes serve index.html
// to support client-side routing.
func NewSPAHandler() http.Handler {
subFS, err := fs.Sub(DistFS, "dist")
if err != nil {
// Should never happen — if dist/ is missing the binary won't compile.
panic("web: embedded dist/ directory not found: " + err.Error())
}
fileServer := http.FileServer(http.FS(subFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Strip leading slash for fs.Open
fsPath := strings.TrimPrefix(path, "/")
if fsPath == "" {
fsPath = "index.html"
}
// Try to open the file in the embedded FS
f, err := subFS.Open(fsPath)
if err == nil {
f.Close()
// File exists — serve it directly
fileServer.ServeHTTP(w, r)
return
}
// File doesn't exist — serve index.html for SPA routing
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
})
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "synapbus-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"svelte": "^5.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"type": "module"
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+45
View File
@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100;
}
}
@layer components {
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50;
}
.input {
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition-colors;
}
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-pending {
@apply badge bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
}
.badge-processing {
@apply badge bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200;
}
.badge-done {
@apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
}
.badge-failed {
@apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
}
.skeleton {
@apply animate-pulse bg-gray-200 dark:bg-gray-700 rounded;
}
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SynapBus</title>
<script>
// Prevent flash of wrong theme
const theme = localStorage.getItem('synapbus-theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
/** API client for SynapBus REST API */
class ApiError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
this.name = 'ApiError';
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const opts: RequestInit = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin'
};
if (body) {
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 401) {
// Redirect to login on auth failure
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = `/login?return=${encodeURIComponent(window.location.pathname)}`;
}
throw new ApiError(401, 'unauthorized', 'Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'unknown', message: res.statusText }));
throw new ApiError(res.status, err.error || 'unknown', err.message || err.error_description || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Auth
export const auth = {
login: (username: string, password: string) =>
request<{ id: number; username: string; display_name: string }>('POST', '/auth/login', { username, password }),
logout: () => request<void>('POST', '/auth/logout'),
me: () => request<{ id: number; username: string; display_name: string; role: string }>('GET', '/auth/me'),
changePassword: (current_password: string, new_password: string) =>
request<{ status: string }>('PUT', '/auth/password', { current_password, new_password })
};
// Messages
export const messages = {
list: (params?: { limit?: number; status?: string; agent?: string }) => {
const qs = new URLSearchParams();
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.status) qs.set('status', params.status);
if (params?.agent) qs.set('agent', params.agent);
const q = qs.toString();
return request<{ messages: any[]; total: number }>('GET', `/api/messages${q ? '?' + q : ''}`);
},
get: (id: number) => request<any>('GET', `/api/messages/${id}`),
send: (body: { from?: string; to: string; body: string; priority?: number; subject?: string; channel_id?: number }) =>
request<any>('POST', '/api/messages', body),
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
search: (q: string, limit?: number) => {
const qs = new URLSearchParams({ q });
if (limit) qs.set('limit', String(limit));
return request<{ messages: any[]; query: string; total: number }>('GET', `/api/messages/search?${qs}`);
}
};
// Conversations
export const conversations = {
list: () => request<{ conversations: any[] }>('GET', '/api/conversations'),
get: (id: number) => request<{ conversation: any; messages: any[] }>('GET', `/api/conversations/${id}`)
};
// Agents
export const agents = {
list: () => request<{ agents: any[] }>('GET', '/api/agents'),
get: (name: string) => request<{ agent: any; traces: any[] }>('GET', `/api/agents/${encodeURIComponent(name)}`),
register: (body: { name: string; display_name?: string; type?: string; capabilities?: object }) =>
request<{ agent: any; api_key: string }>('POST', '/api/agents', body),
delete: (name: string) => request<{ status: string }>('DELETE', `/api/agents/${encodeURIComponent(name)}`),
revokeKey: (name: string) =>
request<{ agent: any; api_key: string }>('POST', `/api/agents/${encodeURIComponent(name)}/revoke-key`)
};
// Channels
export const channels = {
list: () => request<{ channels: any[] }>('GET', '/api/channels'),
get: (name: string) => request<{ channel: any; members: any[] }>('GET', `/api/channels/${encodeURIComponent(name)}`),
create: (body: { name: string; description?: string; topic?: string; type?: string; is_private?: boolean }) =>
request<any>('POST', '/api/channels', body),
join: (name: string, agent?: string) =>
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/join`, agent ? { agent } : {}),
leave: (name: string, agent?: string) =>
request<{ status: string }>('POST', `/api/channels/${encodeURIComponent(name)}/leave`, agent ? { agent } : {})
};
export { ApiError };
+74
View File
@@ -0,0 +1,74 @@
/** SSE client with auto-reconnect and exponential backoff */
type SSEHandler = (event: { type: string; data: any }) => void;
export class SSEClient {
private url: string;
private eventSource: EventSource | null = null;
private handlers: SSEHandler[] = [];
private reconnectDelay = 1000;
private maxReconnectDelay = 30000;
private shouldReconnect = true;
constructor(url: string = '/api/events') {
this.url = url;
}
connect() {
if (this.eventSource) {
this.eventSource.close();
}
this.eventSource = new EventSource(this.url, { withCredentials: true });
this.eventSource.onopen = () => {
this.reconnectDelay = 1000; // Reset backoff on successful connection
};
// Listen for typed events
const eventTypes = ['connected', 'new_message', 'message_updated', 'agent_connected', 'agent_disconnected', 'heartbeat'];
for (const type of eventTypes) {
this.eventSource.addEventListener(type, (e: MessageEvent) => {
try {
const data = JSON.parse(e.data);
this.emit({ type, data });
} catch {
// Ignore parse errors
}
});
}
this.eventSource.onerror = () => {
this.eventSource?.close();
this.eventSource = null;
if (this.shouldReconnect) {
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
}
};
}
onEvent(handler: SSEHandler) {
this.handlers.push(handler);
return () => {
this.handlers = this.handlers.filter((h) => h !== handler);
};
}
private emit(event: { type: string; data: any }) {
for (const handler of this.handlers) {
handler(event);
}
}
disconnect() {
this.shouldReconnect = false;
this.eventSource?.close();
this.eventSource = null;
}
get connected(): boolean {
return this.eventSource?.readyState === EventSource.OPEN;
}
}
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
type Agent = {
id: number;
name: string;
display_name: string;
type: string;
status: string;
created_at: string;
};
let { agent }: { agent: Agent } = $props();
</script>
<a href="/agents/{agent.name}" class="card p-4 block hover:shadow-md transition-shadow">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{agent.display_name || agent.name}</h3>
{#if agent.display_name}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">@{agent.name}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="badge {agent.type === 'ai' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300' : 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'}">
{agent.type}
</span>
<span class="badge {agent.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'}">
{agent.status}
</span>
</div>
</div>
</a>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import { messages as messagesApi } from '$lib/api/client';
let { onSent = () => {} }: { onSent?: () => void } = $props();
let to = $state('');
let body = $state('');
let priority = $state(5);
let subject = $state('');
let sending = $state(false);
let error = $state('');
let showAdvanced = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
if (!body.trim()) {
error = 'Message body is required';
return;
}
if (!to.trim()) {
error = 'Recipient is required';
return;
}
sending = true;
try {
await messagesApi.send({
to: to.trim(),
body: body.trim(),
priority,
subject: subject.trim() || undefined
});
to = '';
body = '';
priority = 5;
subject = '';
onSent();
} catch (err: any) {
error = err.message || 'Failed to send message';
} finally {
sending = false;
}
}
</script>
<form class="card p-4" onsubmit={handleSubmit}>
<h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">New Message</h3>
{#if error}
<div class="mb-3 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
{error}
</div>
{/if}
<div class="space-y-3">
<input type="text" placeholder="Recipient agent name" class="input" bind:value={to} />
<textarea
placeholder="Message body"
class="input min-h-[80px] resize-y"
bind:value={body}
rows="3"
></textarea>
{#if showAdvanced}
<input type="text" placeholder="Subject (optional)" class="input" bind:value={subject} />
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400">Priority:</label>
<input type="range" min="1" max="10" class="flex-1" bind:value={priority} />
<span class="text-sm font-medium w-6 text-center">{priority}</span>
</div>
{/if}
<div class="flex items-center justify-between">
<button type="button" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick={() => (showAdvanced = !showAdvanced)}>
{showAdvanced ? 'Hide' : 'Show'} options
</button>
<button type="submit" class="btn-primary" disabled={sending}>
{sending ? 'Sending...' : 'Send'}
</button>
</div>
</div>
</form>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { user, logout } from '$lib/stores/auth';
import { theme, toggleTheme } from '$lib/stores/theme';
let { onMenuClick }: { onMenuClick: () => void } = $props();
let searchQuery = $state('');
let showUserMenu = $state(false);
function handleSearch(e: SubmitEvent) {
e.preventDefault();
if (searchQuery.trim()) {
goto(`/conversations?q=${encodeURIComponent(searchQuery.trim())}`);
searchQuery = '';
}
}
async function handleLogout() {
await logout();
goto('/login');
}
</script>
<header class="sticky top-0 z-20 flex items-center gap-4 h-16 px-4 lg:px-6 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<!-- Mobile menu button -->
<button class="lg:hidden p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick={onMenuClick}>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Search -->
<form class="flex-1 max-w-lg" onsubmit={handleSearch}>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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-10 pr-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-none rounded-lg focus:ring-2 focus:ring-primary-500 outline-none"
bind:value={searchQuery}
/>
</div>
</form>
<div class="flex items-center gap-2">
<!-- Theme toggle -->
<button
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg"
onclick={toggleTheme}
title="Toggle dark mode"
>
{#if $theme === 'dark'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{/if}
</button>
<!-- User menu -->
<div class="relative">
<button
class="flex items-center gap-2 p-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
onclick={() => (showUserMenu = !showUserMenu)}
>
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-primary-700 dark:text-primary-300 font-medium">
{$user?.username?.charAt(0).toUpperCase() ?? '?'}
</div>
<span class="hidden sm:inline">{$user?.display_name ?? $user?.username ?? ''}</span>
</button>
{#if showUserMenu}
<div class="absolute right-0 mt-2 w-48 card py-1 shadow-lg">
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" onclick={() => (showUserMenu = false)}>
Settings
</a>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700"
onclick={handleLogout}
>
Logout
</button>
</div>
{/if}
</div>
</div>
</header>
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
type Message = {
id: number;
conversation_id: number;
from_agent: string;
to_agent?: string;
body: string;
priority: number;
status: string;
created_at: string;
};
let { messages = [], showConversationLink = false }: { messages: Message[]; showConversationLink?: boolean } = $props();
function statusClass(status: string): string {
switch (status) {
case 'pending': return 'badge-pending';
case 'processing': return 'badge-processing';
case 'done': return 'badge-done';
case 'failed': return 'badge-failed';
default: return 'badge bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
}
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return d.toLocaleDateString();
}
function truncate(s: string, len: number): string {
return s.length > len ? s.slice(0, len - 3) + '...' : s;
}
</script>
{#if messages.length === 0}
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p>No messages yet</p>
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each messages as msg (msg.id)}
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm text-gray-900 dark:text-gray-100">{msg.from_agent}</span>
{#if msg.to_agent}
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
<span class="text-sm text-gray-600 dark:text-gray-400">{msg.to_agent}</span>
{/if}
<span class="{statusClass(msg.status)}">{msg.status}</span>
{#if msg.priority >= 8}
<span class="badge bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">P{msg.priority}</span>
{:else if msg.priority >= 5}
<span class="badge bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300">P{msg.priority}</span>
{/if}
</div>
<p class="text-sm text-gray-700 dark:text-gray-300">{truncate(msg.body, 200)}</p>
</div>
<div class="flex flex-col items-end gap-1 flex-shrink-0">
<span class="text-xs text-gray-500 dark:text-gray-400">{formatTime(msg.created_at)}</span>
{#if showConversationLink}
<a href="/conversations/{msg.conversation_id}" class="text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400">
View thread
</a>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { page } from '$app/stores';
let { open = $bindable(false) } = $props();
const links = [
{ href: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/conversations', label: 'Conversations', icon: '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' },
{ href: '/channels', label: 'Channels', icon: 'M7 20l4-16m2 16l4-16M6 9h14M4 15h14' },
{ href: '/agents', label: 'Agents', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
{ href: '/settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }
];
</script>
<!-- Mobile overlay -->
{#if open}
<div class="fixed inset-0 z-30 bg-black/50 lg:hidden" onclick={() => (open = false)}></div>
{/if}
<aside
class="fixed top-0 left-0 z-40 h-screen w-64 transform bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-transform lg:translate-x-0 {open ? 'translate-x-0' : '-translate-x-full'}"
>
<div class="flex items-center gap-2 h-16 px-6 border-b border-gray-200 dark:border-gray-700">
<svg class="w-8 h-8 text-primary-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<span class="text-xl font-bold text-gray-900 dark:text-white">SynapBus</span>
</div>
<nav class="px-3 py-4 space-y-1">
{#each links as link}
{@const active = $page.url.pathname === link.href || (link.href !== '/' && $page.url.pathname.startsWith(link.href))}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {active
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => (open = false)}
>
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d={link.icon} />
</svg>
{link.label}
</a>
{/each}
</nav>
</aside>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
type Trace = {
id: number;
agent_name: string;
action: string;
details: any;
error?: string;
timestamp: string;
};
let { traces = [] }: { traces: Trace[] } = $props();
let expandedId = $state<number | null>(null);
function formatTime(iso: string): string {
return new Date(iso).toLocaleString();
}
function toggle(id: number) {
expandedId = expandedId === id ? null : id;
}
</script>
{#if traces.length === 0}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No activity traces</p>
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each traces as trace (trace.id)}
<div class="py-2">
<button
class="w-full flex items-center justify-between text-left px-3 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50"
onclick={() => toggle(trace.id)}
>
<div class="flex items-center gap-3">
<span class="badge bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 font-mono text-xs">
{trace.action}
</span>
{#if trace.error}
<span class="badge-failed">error</span>
{/if}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">{formatTime(trace.timestamp)}</span>
</button>
{#if expandedId === trace.id}
<div class="mx-3 mt-1 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<pre class="text-xs text-gray-700 dark:text-gray-300 overflow-x-auto whitespace-pre-wrap">{JSON.stringify(trace.details, null, 2)}</pre>
{#if trace.error}
<p class="mt-2 text-xs text-red-600 dark:text-red-400">Error: {trace.error}</p>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
+36
View File
@@ -0,0 +1,36 @@
import { writable } from 'svelte/store';
import { auth as authApi } from '$lib/api/client';
export type User = {
id: number;
username: string;
display_name: string;
role: string;
} | null;
export const user = writable<User>(null);
export const loading = writable(true);
export async function checkAuth(): Promise<boolean> {
try {
loading.set(true);
const u = await authApi.me();
user.set(u);
return true;
} catch {
user.set(null);
return false;
} finally {
loading.set(false);
}
}
export async function login(username: string, password: string): Promise<void> {
const u = await authApi.login(username, password);
user.set({ ...u, role: 'user' });
}
export async function logout(): Promise<void> {
await authApi.logout();
user.set(null);
}
+27
View File
@@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
function getInitialTheme(): 'light' | 'dark' {
if (!browser) return 'light';
const stored = localStorage.getItem('synapbus-theme');
if (stored === 'dark') return 'dark';
if (stored === 'light') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export const theme = writable<'light' | 'dark'>(getInitialTheme());
export function toggleTheme() {
theme.update((t) => {
const next = t === 'light' ? 'dark' : 'light';
if (browser) {
localStorage.setItem('synapbus-theme', next);
if (next === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
return next;
});
}
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { checkAuth, user, loading } from '$lib/stores/auth';
import { SSEClient } from '$lib/api/sse';
import Sidebar from '$lib/components/Sidebar.svelte';
import Header from '$lib/components/Header.svelte';
let { children } = $props();
let sidebarOpen = $state(false);
let sseClient: SSEClient | null = null;
onMount(async () => {
const isLoginPage = $page.url.pathname === '/login';
const authenticated = await checkAuth();
if (!authenticated && !isLoginPage) {
goto(`/login?return=${encodeURIComponent($page.url.pathname)}`);
return;
}
if (authenticated) {
sseClient = new SSEClient();
sseClient.connect();
}
return () => {
sseClient?.disconnect();
};
});
$effect(() => {
if ($user && sseClient && !sseClient.connected) {
sseClient.connect();
}
});
$: isLoginPage = $page.url.pathname === '/login';
</script>
{#if $loading}
<div class="flex items-center justify-center min-h-screen">
<div class="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin"></div>
</div>
{:else if isLoginPage}
{@render children()}
{:else if $user}
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<Sidebar bind:open={sidebarOpen} />
<div class="lg:ml-64">
<Header onMenuClick={() => (sidebarOpen = !sidebarOpen)} />
<main class="p-4 lg:p-6">
{@render children()}
</main>
</div>
</div>
{/if}
+2
View File
@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = false;
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import { messages as messagesApi, conversations as convsApi, agents as agentsApi } from '$lib/api/client';
import MessageList from '$lib/components/MessageList.svelte';
import ComposeForm from '$lib/components/ComposeForm.svelte';
let recentMessages = $state<any[]>([]);
let recentConversations = $state<any[]>([]);
let agentCount = $state(0);
let loadingData = $state(true);
let showCompose = $state(false);
async function loadData() {
loadingData = true;
try {
const [msgRes, convRes, agentRes] = await Promise.all([
messagesApi.list({ limit: 20 }),
convsApi.list(),
agentsApi.list()
]);
recentMessages = msgRes.messages;
recentConversations = convRes.conversations;
agentCount = agentRes.agents.length;
} catch {
// Errors handled by API client
} finally {
loadingData = false;
}
}
onMount(loadData);
</script>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<button class="btn-primary" onclick={() => (showCompose = !showCompose)}>
{showCompose ? 'Cancel' : 'New Message'}
</button>
</div>
{#if showCompose}
<div class="mb-6">
<ComposeForm onSent={() => { showCompose = false; loadData(); }} />
</div>
{/if}
<!-- Stats -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Messages</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{loadingData ? '-' : recentMessages.length}</p>
</div>
<div class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Conversations</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{loadingData ? '-' : recentConversations.length}</p>
</div>
<div class="card p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Agents</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{loadingData ? '-' : agentCount}</p>
</div>
</div>
<!-- Recent Conversations -->
{#if recentConversations.length > 0}
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Recent Conversations</h2>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each recentConversations.slice(0, 5) as conv}
<a href="/conversations/{conv.id}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{conv.subject || 'Untitled conversation'}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{conv.last_agent}: {conv.last_message}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">{conv.message_count} msgs</span>
</div>
</div>
</a>
{/each}
</div>
{#if recentConversations.length > 5}
<div class="px-4 py-2 border-t border-gray-200 dark:border-gray-700">
<a href="/conversations" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">View all conversations</a>
</div>
{/if}
</div>
{/if}
<!-- Recent Messages -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Recent Messages</h2>
</div>
{#if loadingData}
<div class="p-4 space-y-3">
{#each Array(3) as _}
<div class="space-y-2">
<div class="skeleton h-4 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
{/each}
</div>
{:else}
<MessageList messages={recentMessages} showConversationLink={true} />
{/if}
</div>
{#if !loadingData && recentMessages.length === 0 && agentCount === 0}
<div class="card p-8 text-center mt-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">Welcome to SynapBus</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">No conversations yet. Register an agent to get started.</p>
<a href="/agents" class="btn-primary inline-block">Register an Agent</a>
</div>
{/if}
</div>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte';
import { agents as agentsApi } from '$lib/api/client';
import AgentCard from '$lib/components/AgentCard.svelte';
let agentList = $state<any[]>([]);
let loadingData = $state(true);
let showRegister = $state(false);
// Register form
let newName = $state('');
let newDisplayName = $state('');
let newType = $state('ai');
let registering = $state(false);
let registerError = $state('');
let newApiKey = $state('');
async function loadAgents() {
loadingData = true;
try {
const res = await agentsApi.list();
agentList = res.agents;
} catch {
// handled
} finally {
loadingData = false;
}
}
onMount(loadAgents);
async function handleRegister(e: SubmitEvent) {
e.preventDefault();
if (!newName.trim()) {
registerError = 'Agent name is required';
return;
}
registering = true;
registerError = '';
newApiKey = '';
try {
const res = await agentsApi.register({
name: newName.trim(),
display_name: newDisplayName.trim() || undefined,
type: newType
});
newApiKey = res.api_key;
newName = '';
newDisplayName = '';
newType = 'ai';
await loadAgents();
} catch (err: any) {
registerError = err.message || 'Failed to register agent';
} finally {
registering = false;
}
}
</script>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Agents</h1>
<button class="btn-primary" onclick={() => { showRegister = !showRegister; newApiKey = ''; }}>
{showRegister ? 'Cancel' : 'Register Agent'}
</button>
</div>
{#if showRegister}
<form class="card p-4 mb-6" onsubmit={handleRegister}>
<h3 class="font-medium text-gray-900 dark:text-gray-100 mb-3">Register New Agent</h3>
{#if registerError}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{registerError}</div>
{/if}
{#if newApiKey}
<div class="mb-3 p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm font-medium text-green-800 dark:text-green-200 mb-1">Agent registered! Save this API key - it will not be shown again:</p>
<code class="block p-2 bg-white dark:bg-gray-800 rounded text-xs font-mono break-all select-all">{newApiKey}</code>
</div>
{/if}
<div class="space-y-3">
<input type="text" class="input" placeholder="Agent name (e.g. researcher-01)" bind:value={newName} />
<input type="text" class="input" placeholder="Display name (optional)" bind:value={newDisplayName} />
<select class="input" bind:value={newType}>
<option value="ai">AI Agent</option>
<option value="human">Human Agent</option>
</select>
<button type="submit" class="btn-primary" disabled={registering}>
{registering ? 'Registering...' : 'Register'}
</button>
</div>
</form>
{/if}
{#if loadingData}
<div class="grid gap-4 sm:grid-cols-2">
{#each Array(4) as _}
<div class="card p-4">
<div class="skeleton h-5 w-1/3 mb-2"></div>
<div class="skeleton h-4 w-1/2"></div>
</div>
{/each}
</div>
{:else if agentList.length === 0}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">No agents registered yet.</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
{#each agentList as agent (agent.id)}
<AgentCard {agent} />
{/each}
</div>
{/if}
</div>
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { agents as agentsApi } from '$lib/api/client';
import TraceViewer from '$lib/components/TraceViewer.svelte';
let agent = $state<any>(null);
let traces = $state<any[]>([]);
let loadingData = $state(true);
let revokeKey = $state('');
let revoking = $state(false);
let deleting = $state(false);
let confirmDelete = $state(false);
$: agentName = $page.params.name;
async function loadAgent() {
loadingData = true;
try {
const res = await agentsApi.get(agentName);
agent = res.agent;
traces = res.traces;
} catch {
// handled
} finally {
loadingData = false;
}
}
onMount(loadAgent);
async function handleRevokeKey() {
revoking = true;
revokeKey = '';
try {
const res = await agentsApi.revokeKey(agentName);
revokeKey = res.api_key;
agent = res.agent;
} catch (err: any) {
alert(err.message || 'Failed to revoke key');
} finally {
revoking = false;
}
}
async function handleDelete() {
deleting = true;
try {
await agentsApi.delete(agentName);
goto('/agents');
} catch (err: any) {
alert(err.message || 'Failed to delete agent');
} finally {
deleting = false;
confirmDelete = false;
}
}
</script>
<div class="max-w-4xl mx-auto">
<a href="/agents" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mb-4 inline-block">
&larr; Back to agents
</a>
{#if loadingData}
<div class="card p-6">
<div class="skeleton h-8 w-1/3 mb-4"></div>
<div class="skeleton h-4 w-1/2 mb-2"></div>
<div class="skeleton h-4 w-1/4"></div>
</div>
{:else if agent}
<!-- Agent Details -->
<div class="card mb-6">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{agent.display_name || agent.name}</h1>
{#if agent.display_name}
<p class="text-sm text-gray-500 dark:text-gray-400">@{agent.name}</p>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="badge {agent.type === 'ai' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300' : 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'}">
{agent.type}
</span>
<span class="badge {agent.status === 'active' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-600'}">
{agent.status}
</span>
</div>
</div>
</div>
<div class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Created</p>
<p class="text-gray-900 dark:text-gray-100">{new Date(agent.created_at).toLocaleString()}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Last Updated</p>
<p class="text-gray-900 dark:text-gray-100">{new Date(agent.updated_at).toLocaleString()}</p>
</div>
</div>
{#if agent.capabilities && JSON.stringify(agent.capabilities) !== '{}'}
<div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Capabilities</p>
<pre class="text-xs bg-gray-50 dark:bg-gray-900 p-2 rounded overflow-x-auto">{JSON.stringify(agent.capabilities, null, 2)}</pre>
</div>
{/if}
</div>
<!-- Actions -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
{#if revokeKey}
<div class="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
<p class="text-sm font-medium text-green-800 dark:text-green-200 mb-1">New API key generated. Save it now - it will not be shown again:</p>
<code class="block p-2 bg-white dark:bg-gray-800 rounded text-xs font-mono break-all select-all">{revokeKey}</code>
</div>
{/if}
<div class="flex gap-2">
<button class="btn-secondary text-sm" onclick={handleRevokeKey} disabled={revoking}>
{revoking ? 'Regenerating...' : 'Regenerate API Key'}
</button>
{#if !confirmDelete}
<button class="btn-danger text-sm" onclick={() => (confirmDelete = true)}>
Delete Agent
</button>
{:else}
<button class="btn-danger text-sm" onclick={handleDelete} disabled={deleting}>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button class="btn-secondary text-sm" onclick={() => (confirmDelete = false)}>Cancel</button>
{/if}
</div>
</div>
</div>
<!-- Activity Traces -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Activity Traces</h2>
</div>
<TraceViewer {traces} />
</div>
{:else}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">Agent not found</p>
</div>
{/if}
</div>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import { onMount } from 'svelte';
import { channels as channelsApi } from '$lib/api/client';
let channelList = $state<any[]>([]);
let loadingData = $state(true);
let showCreate = $state(false);
// Create form
let newName = $state('');
let newDescription = $state('');
let newIsPrivate = $state(false);
let creating = $state(false);
let createError = $state('');
async function loadChannels() {
loadingData = true;
try {
const res = await channelsApi.list();
channelList = res.channels;
} catch {
// handled
} finally {
loadingData = false;
}
}
onMount(loadChannels);
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (!newName.trim()) {
createError = 'Channel name is required';
return;
}
creating = true;
createError = '';
try {
await channelsApi.create({
name: newName.trim(),
description: newDescription.trim(),
is_private: newIsPrivate
});
newName = '';
newDescription = '';
newIsPrivate = false;
showCreate = false;
await loadChannels();
} catch (err: any) {
createError = err.message || 'Failed to create channel';
} finally {
creating = false;
}
}
</script>
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Channels</h1>
<button class="btn-primary" onclick={() => (showCreate = !showCreate)}>
{showCreate ? 'Cancel' : 'Create Channel'}
</button>
</div>
{#if showCreate}
<form class="card p-4 mb-6" onsubmit={handleCreate}>
{#if createError}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{createError}</div>
{/if}
<div class="space-y-3">
<input type="text" class="input" placeholder="Channel name (e.g. research-findings)" bind:value={newName} />
<input type="text" class="input" placeholder="Description (optional)" bind:value={newDescription} />
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" bind:checked={newIsPrivate} class="rounded" />
Private channel (invite-only)
</label>
<button type="submit" class="btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
{/if}
<div class="card">
{#if loadingData}
<div class="p-4 space-y-3">
{#each Array(3) as _}
<div class="skeleton h-12 w-full"></div>
{/each}
</div>
{:else if channelList.length === 0}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<p>No channels yet. Create one to get started.</p>
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each channelList as ch}
<a href="/channels/{ch.name}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm text-gray-900 dark:text-gray-100">#{ch.name}</span>
{#if ch.is_private}
<svg class="w-3.5 h-3.5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
{/if}
</div>
{#if ch.description}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>
{/if}
</div>
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 flex-shrink-0 ml-3">
{ch.member_count} members
</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { channels as channelsApi } from '$lib/api/client';
let channel = $state<any>(null);
let members = $state<any[]>([]);
let loadingData = $state(true);
let joinError = $state('');
let joining = $state(false);
$: channelName = $page.params.name;
async function loadChannel() {
loadingData = true;
try {
const res = await channelsApi.get(channelName);
channel = res.channel;
members = res.members;
} catch {
// handled
} finally {
loadingData = false;
}
}
onMount(loadChannel);
async function handleJoin() {
joining = true;
joinError = '';
try {
await channelsApi.join(channelName);
await loadChannel();
} catch (err: any) {
joinError = err.message || 'Failed to join channel';
} finally {
joining = false;
}
}
async function handleLeave() {
joining = true;
joinError = '';
try {
await channelsApi.leave(channelName);
await loadChannel();
} catch (err: any) {
joinError = err.message || 'Failed to leave channel';
} finally {
joining = false;
}
}
</script>
<div class="max-w-4xl mx-auto">
<a href="/channels" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mb-4 inline-block">
&larr; Back to channels
</a>
{#if loadingData}
<div class="card p-6">
<div class="skeleton h-6 w-1/4 mb-2"></div>
<div class="skeleton h-4 w-1/2 mb-4"></div>
<div class="skeleton h-20 w-full"></div>
</div>
{:else if channel}
<div class="card mb-6">
<div class="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">#{channel.name}</h1>
{#if channel.is_private}
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">Private</span>
{/if}
<span class="badge bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">{channel.type}</span>
</div>
{#if channel.description}
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{channel.description}</p>
{/if}
{#if channel.topic}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Topic: {channel.topic}</p>
{/if}
</div>
<div class="flex gap-2">
<button class="btn-primary text-sm" onclick={handleJoin} disabled={joining}>
{joining ? '...' : 'Join'}
</button>
<button class="btn-secondary text-sm" onclick={handleLeave} disabled={joining}>
Leave
</button>
</div>
</div>
{#if joinError}
<div class="mt-2 text-sm text-red-600 dark:text-red-400">{joinError}</div>
{/if}
</div>
</div>
<!-- Members -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Members ({members.length})</h2>
</div>
{#if members.length === 0}
<div class="p-6 text-center text-gray-500 dark:text-gray-400">No members</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each members as member}
<div class="px-4 py-2 flex items-center justify-between">
<span class="text-sm text-gray-900 dark:text-gray-100">{member.agent_name}</span>
<span class="badge {member.role === 'owner' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'}">
{member.role}
</span>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">Channel not found</p>
</div>
{/if}
</div>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
let convList = $state<any[]>([]);
let searchResults = $state<any[] | null>(null);
let loadingData = $state(true);
onMount(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;
}
});
</script>
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Conversations</h1>
{#if searchResults !== null}
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">
Search Results ({searchResults.length})
</h2>
</div>
{#if searchResults.length === 0}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No messages found for '{$page.url.searchParams.get('q')}'
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each searchResults as msg}
<a href="/conversations/{msg.conversation_id}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm">{msg.from_agent}</span>
<span class="text-xs text-gray-500">{new Date(msg.created_at).toLocaleString()}</span>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300">{msg.body.slice(0, 200)}</p>
</a>
{/each}
</div>
{/if}
</div>
{/if}
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">All Conversations</h2>
</div>
{#if loadingData}
<div class="p-4 space-y-3">
{#each Array(5) as _}
<div class="space-y-2">
<div class="skeleton h-4 w-1/3"></div>
<div class="skeleton h-3 w-2/3"></div>
</div>
{/each}
</div>
{:else if convList.length === 0}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<p>No conversations yet.</p>
</div>
{:else}
<div class="divide-y divide-gray-100 dark:divide-gray-700">
{#each convList as conv}
<a href="/conversations/{conv.id}" class="block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{conv.subject || 'Untitled'}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{conv.last_agent}: {conv.last_message}
</p>
</div>
<span class="badge bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 flex-shrink-0 ml-3">
{conv.message_count}
</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,130 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
let conversation = $state<any>(null);
let messagesList = $state<any[]>([]);
let loadingData = $state(true);
let replyBody = $state('');
let sending = $state(false);
let error = $state('');
$: convId = Number($page.params.id);
async function loadConversation() {
loadingData = true;
try {
const res = await convsApi.get(convId);
conversation = res.conversation;
messagesList = res.messages;
} catch {
// handled
} finally {
loadingData = false;
}
}
onMount(loadConversation);
async function sendReply(e: SubmitEvent) {
e.preventDefault();
if (!replyBody.trim() || messagesList.length === 0) return;
sending = true;
error = '';
try {
// Reply to the most recent sender
const lastMsg = messagesList[messagesList.length - 1];
const to = lastMsg.from_agent;
await messagesApi.send({
to,
body: replyBody.trim(),
subject: conversation?.subject
});
replyBody = '';
await loadConversation();
} catch (err: any) {
error = err.message || 'Failed to send reply';
} finally {
sending = false;
}
}
function statusBadge(status: string): string {
switch (status) {
case 'pending': return 'badge-pending';
case 'processing': return 'badge-processing';
case 'done': return 'badge-done';
case 'failed': return 'badge-failed';
default: return 'badge bg-gray-100 text-gray-800';
}
}
</script>
<div class="max-w-3xl mx-auto">
<a href="/conversations" class="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 mb-4 inline-block">
&larr; Back to conversations
</a>
{#if loadingData}
<div class="card p-6">
<div class="skeleton h-6 w-1/3 mb-4"></div>
<div class="space-y-3">
{#each Array(3) as _}
<div class="skeleton h-16 w-full"></div>
{/each}
</div>
</div>
{:else if conversation}
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h1 class="font-semibold text-lg text-gray-900 dark:text-gray-100">
{conversation.subject || 'Conversation #' + conversation.id}
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Started by {conversation.created_by} &middot; {messagesList.length} messages
</p>
</div>
<!-- Messages thread -->
<div class="divide-y divide-gray-100 dark:divide-gray-700 max-h-[60vh] overflow-y-auto">
{#each messagesList as msg (msg.id)}
<div class="px-4 py-3">
<div class="flex items-center gap-2 mb-1">
<div class="w-6 h-6 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center text-xs font-medium text-primary-700 dark:text-primary-300">
{msg.from_agent.charAt(0).toUpperCase()}
</div>
<span class="font-medium text-sm text-gray-900 dark:text-gray-100">{msg.from_agent}</span>
{#if msg.to_agent}
<span class="text-xs text-gray-400">&rarr; {msg.to_agent}</span>
{/if}
<span class="{statusBadge(msg.status)}">{msg.status}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-auto">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
<div class="ml-8 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{msg.body}</div>
</div>
{/each}
</div>
<!-- Reply form -->
<div class="border-t border-gray-200 dark:border-gray-700 p-4">
{#if error}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{error}</div>
{/if}
<form class="flex gap-2" onsubmit={sendReply}>
<input type="text" class="input flex-1" placeholder="Type a reply..." bind:value={replyBody} />
<button type="submit" class="btn-primary" disabled={sending || !replyBody.trim()}>
{sending ? '...' : 'Send'}
</button>
</form>
</div>
</div>
{:else}
<div class="card p-8 text-center">
<p class="text-gray-500 dark:text-gray-400">Conversation not found</p>
</div>
{/if}
</div>
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { login } from '$lib/stores/auth';
let username = $state('');
let password = $state('');
let error = $state('');
let submitting = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
submitting = true;
try {
await login(username, password);
const returnUrl = $page.url.searchParams.get('return') || '/';
goto(returnUrl);
} catch (err: any) {
error = err.message || 'Invalid username or password';
} finally {
submitting = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<svg class="w-12 h-12 mx-auto text-primary-600" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<h1 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">SynapBus</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Agent-to-agent messaging</p>
</div>
<form class="card p-6" onsubmit={handleSubmit}>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Sign In</h2>
{#if error}
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
{error}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<input
id="username"
type="text"
class="input"
bind:value={username}
required
autocomplete="username"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input
id="password"
type="password"
class="input"
bind:value={password}
required
autocomplete="current-password"
/>
</div>
<button type="submit" class="btn-primary w-full" disabled={submitting}>
{submitting ? 'Signing in...' : 'Sign In'}
</button>
</div>
</form>
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { user } from '$lib/stores/auth';
import { theme, toggleTheme } from '$lib/stores/theme';
import { auth as authApi } from '$lib/api/client';
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let changingPassword = $state(false);
let passwordError = $state('');
let passwordSuccess = $state('');
async function handleChangePassword(e: SubmitEvent) {
e.preventDefault();
passwordError = '';
passwordSuccess = '';
if (newPassword.length < 8) {
passwordError = 'Password must be at least 8 characters';
return;
}
if (newPassword !== confirmPassword) {
passwordError = 'Passwords do not match';
return;
}
changingPassword = true;
try {
await authApi.changePassword(currentPassword, newPassword);
passwordSuccess = 'Password changed successfully';
currentPassword = '';
newPassword = '';
confirmPassword = '';
} catch (err: any) {
passwordError = err.message || 'Failed to change password';
} finally {
changingPassword = false;
}
}
</script>
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Settings</h1>
<!-- Appearance -->
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Appearance</h2>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-sm text-gray-900 dark:text-gray-100">Dark Mode</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Toggle between light and dark theme</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {$theme === 'dark' ? 'bg-primary-600' : 'bg-gray-300'}"
onclick={toggleTheme}
role="switch"
aria-checked={$theme === 'dark'}
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {$theme === 'dark' ? 'translate-x-6' : 'translate-x-1'}" />
</button>
</div>
</div>
</div>
<!-- Account -->
<div class="card mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Account</h2>
</div>
<div class="p-4 space-y-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Username</p>
<p class="font-medium text-gray-900 dark:text-gray-100">{$user?.username ?? '-'}</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Display Name</p>
<p class="font-medium text-gray-900 dark:text-gray-100">{$user?.display_name ?? '-'}</p>
</div>
</div>
</div>
</div>
<!-- Change Password -->
<div class="card">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Change Password</h2>
</div>
<form class="p-4" onsubmit={handleChangePassword}>
{#if passwordError}
<div class="mb-3 p-2 bg-red-50 dark:bg-red-900/30 rounded text-sm text-red-700 dark:text-red-300">{passwordError}</div>
{/if}
{#if passwordSuccess}
<div class="mb-3 p-2 bg-green-50 dark:bg-green-900/30 rounded text-sm text-green-700 dark:text-green-300">{passwordSuccess}</div>
{/if}
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
<input type="password" class="input" bind:value={currentPassword} required autocomplete="current-password" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<input type="password" class="input" bind:value={newPassword} required minlength="8" autocomplete="new-password" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
<input type="password" class="input" bind:value={confirmPassword} required autocomplete="new-password" />
</div>
<button type="submit" class="btn-primary" disabled={changingPassword}>
{changingPassword ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 184 B

+18
View File
@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false
}),
paths: {
base: ''
}
}
};
export default config;
+24
View File
@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a'
}
}
}
},
plugins: []
};
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/auth': 'http://localhost:8080',
'/health': 'http://localhost:8080'
}
}
});