[READ-ONLY] Mirror of https://github.com/just-cameron/note. External storage tool for Letta agents to manage persistent notes
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}