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:
co-authored by
Claude Opus 4.6
parent
29fe5c4275
commit
78735bb8ee
+2
-1
@@ -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/
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Vendored
+20
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
← 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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
← 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>
|
||||
@@ -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">
|
||||
← 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} · {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">→ {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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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;
|
||||
@@ -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: []
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user