[READ-ONLY] Mirror of https://github.com/just-cameron/note. External storage tool for Letta agents to manage persistent notes
0

Configure Feed

Select the types of activity you want to include in your feed.

note / internal / web / server.go
5.3 kB 230 lines
1package web 2 3import ( 4 "embed" 5 "encoding/json" 6 "fmt" 7 "html/template" 8 "net/http" 9 "strings" 10 11 "github.com/cpfiffer/note/internal/api" 12) 13 14//go:embed templates/*.html 15var templateFS embed.FS 16 17//go:embed static/* 18var staticFS embed.FS 19 20type Server struct { 21 client *api.Client 22 templates *template.Template 23 mux *http.ServeMux 24} 25 26func NewServer() (*Server, error) { 27 client, err := api.NewClient("") 28 if err != nil { 29 return nil, err 30 } 31 32 tmpl, err := template.ParseFS(templateFS, "templates/*.html") 33 if err != nil { 34 return nil, fmt.Errorf("failed to parse templates: %w", err) 35 } 36 37 s := &Server{ 38 client: client, 39 templates: tmpl, 40 mux: http.NewServeMux(), 41 } 42 43 s.setupRoutes() 44 return s, nil 45} 46 47func (s *Server) setupRoutes() { 48 // Static files 49 s.mux.Handle("/static/", http.FileServer(http.FS(staticFS))) 50 51 // Pages 52 s.mux.HandleFunc("/", s.handleIndex) 53 s.mux.HandleFunc("/agents", s.handleAgents) 54 s.mux.HandleFunc("/notes", s.handleNotes) 55 56 // API endpoints for htmx 57 s.mux.HandleFunc("/api/agents", s.handleAPIAgents) 58 s.mux.HandleFunc("/api/notes", s.handleAPINotes) 59 s.mux.HandleFunc("/api/note/", s.handleAPINote) 60} 61 62func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 63 s.mux.ServeHTTP(w, r) 64} 65 66func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 67 s.templates.ExecuteTemplate(w, "index.html", nil) 68} 69 70func (s *Server) handleAgents(w http.ResponseWriter, r *http.Request) { 71 search := r.URL.Query().Get("search") 72 agents, err := s.client.ListAgents(search) 73 if err != nil { 74 http.Error(w, err.Error(), http.StatusInternalServerError) 75 return 76 } 77 78 s.templates.ExecuteTemplate(w, "agents.html", map[string]interface{}{ 79 "Agents": agents, 80 "Search": search, 81 }) 82} 83 84func (s *Server) handleNotes(w http.ResponseWriter, r *http.Request) { 85 agentID := r.URL.Query().Get("agent") 86 if agentID == "" { 87 http.Redirect(w, r, "/agents", http.StatusFound) 88 return 89 } 90 91 ownerSearch := fmt.Sprintf("owner:%s", agentID) 92 blocks, err := s.client.ListBlocks(ownerSearch) 93 if err != nil { 94 http.Error(w, err.Error(), http.StatusInternalServerError) 95 return 96 } 97 98 // Filter to note paths only 99 var notes []api.Block 100 for _, b := range blocks { 101 if strings.HasPrefix(b.Label, "/") && b.Label != "/note_directory" { 102 notes = append(notes, b) 103 } 104 } 105 106 s.templates.ExecuteTemplate(w, "notes.html", map[string]interface{}{ 107 "AgentID": agentID, 108 "Notes": notes, 109 }) 110} 111 112func (s *Server) handleAPIAgents(w http.ResponseWriter, r *http.Request) { 113 search := r.URL.Query().Get("search") 114 agents, err := s.client.ListAgents(search) 115 if err != nil { 116 http.Error(w, err.Error(), http.StatusInternalServerError) 117 return 118 } 119 120 w.Header().Set("Content-Type", "text/html") 121 s.templates.ExecuteTemplate(w, "agent-list.html", agents) 122} 123 124func (s *Server) handleAPINotes(w http.ResponseWriter, r *http.Request) { 125 agentID := r.URL.Query().Get("agent") 126 if agentID == "" { 127 http.Error(w, "agent required", http.StatusBadRequest) 128 return 129 } 130 131 ownerSearch := fmt.Sprintf("owner:%s", agentID) 132 blocks, err := s.client.ListBlocks(ownerSearch) 133 if err != nil { 134 http.Error(w, err.Error(), http.StatusInternalServerError) 135 return 136 } 137 138 var notes []api.Block 139 for _, b := range blocks { 140 if strings.HasPrefix(b.Label, "/") && b.Label != "/note_directory" { 141 notes = append(notes, b) 142 } 143 } 144 145 w.Header().Set("Content-Type", "text/html") 146 s.templates.ExecuteTemplate(w, "note-list.html", notes) 147} 148 149func (s *Server) handleAPINote(w http.ResponseWriter, r *http.Request) { 150 // Extract path from URL: /api/note/{agent}/{path...} 151 path := strings.TrimPrefix(r.URL.Path, "/api/note/") 152 parts := strings.SplitN(path, "/", 2) 153 if len(parts) < 2 { 154 http.Error(w, "invalid path", http.StatusBadRequest) 155 return 156 } 157 agentID := parts[0] 158 notePath := "/" + parts[1] 159 160 ownerSearch := fmt.Sprintf("owner:%s", agentID) 161 162 switch r.Method { 163 case "GET": 164 blocks, err := s.client.ListBlocks(ownerSearch) 165 if err != nil { 166 http.Error(w, err.Error(), http.StatusInternalServerError) 167 return 168 } 169 170 var note *api.Block 171 for _, b := range blocks { 172 if b.Label == notePath { 173 note = &b 174 break 175 } 176 } 177 178 if note == nil { 179 http.Error(w, "note not found", http.StatusNotFound) 180 return 181 } 182 183 w.Header().Set("Content-Type", "application/json") 184 json.NewEncoder(w).Encode(note) 185 186 case "PUT": 187 var body struct { 188 Value string `json:"value"` 189 } 190 if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 191 http.Error(w, err.Error(), http.StatusBadRequest) 192 return 193 } 194 195 blocks, err := s.client.ListBlocks(ownerSearch) 196 if err != nil { 197 http.Error(w, err.Error(), http.StatusInternalServerError) 198 return 199 } 200 201 var blockID string 202 for _, b := range blocks { 203 if b.Label == notePath { 204 blockID = b.ID 205 break 206 } 207 } 208 209 if blockID == "" { 210 http.Error(w, "note not found", http.StatusNotFound) 211 return 212 } 213 214 _, err = s.client.UpdateBlock(blockID, body.Value) 215 if err != nil { 216 http.Error(w, err.Error(), http.StatusInternalServerError) 217 return 218 } 219 220 w.WriteHeader(http.StatusOK) 221 222 default: 223 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 224 } 225} 226 227func (s *Server) Start(addr string) error { 228 fmt.Printf("Starting server at http://%s\n", addr) 229 return http.ListenAndServe(addr, s) 230}