1package server
2
3import (
4 "compress/gzip"
5 "context"
6 "crypto/rand"
7 "database/sql"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "mime"
14 "net/http"
15 "net/url"
16 "os"
17 "path/filepath"
18 "strconv"
19 "strings"
20 "time"
21
22 "shelley.exe.dev/claudetool/browse"
23 "shelley.exe.dev/db"
24 "shelley.exe.dev/db/generated"
25 "shelley.exe.dev/llm"
26 "shelley.exe.dev/models"
27 "shelley.exe.dev/slug"
28 "shelley.exe.dev/ui"
29 "shelley.exe.dev/version"
30)
31
32// handleRead serves files from limited allowed locations via /api/read?path=
33func (s *Server) handleRead(w http.ResponseWriter, r *http.Request) {
34 if r.Method != http.MethodGet {
35 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
36 return
37 }
38 p := r.URL.Query().Get("path")
39 if p == "" {
40 http.Error(w, "path required", http.StatusBadRequest)
41 return
42 }
43 // Clean and enforce prefix restriction
44 clean := p
45 // Do not resolve symlinks here; enforce string prefix restriction only
46 if !(strings.HasPrefix(clean, browse.ScreenshotDir+"/")) {
47 http.Error(w, "path not allowed", http.StatusForbidden)
48 return
49 }
50 f, err := os.Open(clean)
51 if err != nil {
52 http.Error(w, "not found", http.StatusNotFound)
53 return
54 }
55 defer f.Close()
56 // Determine content type by extension first, then fallback to sniffing
57 ext := strings.ToLower(filepath.Ext(clean))
58 switch ext {
59 case ".png":
60 w.Header().Set("Content-Type", "image/png")
61 case ".jpg", ".jpeg":
62 w.Header().Set("Content-Type", "image/jpeg")
63 case ".gif":
64 w.Header().Set("Content-Type", "image/gif")
65 case ".webp":
66 w.Header().Set("Content-Type", "image/webp")
67 case ".svg":
68 w.Header().Set("Content-Type", "image/svg+xml")
69 default:
70 buf := make([]byte, 512)
71 n, _ := f.Read(buf)
72 contentType := http.DetectContentType(buf[:n])
73 if _, err := f.Seek(0, 0); err != nil {
74 http.Error(w, "seek failed", http.StatusInternalServerError)
75 return
76 }
77 w.Header().Set("Content-Type", contentType)
78 }
79 // Reasonable short-term caching for assets, allow quick refresh during sessions
80 w.Header().Set("Cache-Control", "public, max-age=300")
81 io.Copy(w, f)
82}
83
84// handleWriteFile writes content to a file (for diff viewer edit mode)
85func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) {
86 if r.Method != http.MethodPost {
87 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
88 return
89 }
90
91 var req struct {
92 Path string `json:"path"`
93 Content string `json:"content"`
94 }
95 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
96 http.Error(w, "invalid request body", http.StatusBadRequest)
97 return
98 }
99
100 if req.Path == "" {
101 http.Error(w, "path required", http.StatusBadRequest)
102 return
103 }
104
105 // Security: only allow writing within certain directories
106 // For now, require the path to be within a git repository
107 clean := filepath.Clean(req.Path)
108 if !filepath.IsAbs(clean) {
109 http.Error(w, "absolute path required", http.StatusBadRequest)
110 return
111 }
112
113 // Write the file
114 if err := os.WriteFile(clean, []byte(req.Content), 0o644); err != nil {
115 http.Error(w, fmt.Sprintf("failed to write file: %v", err), http.StatusInternalServerError)
116 return
117 }
118
119 w.Header().Set("Content-Type", "application/json")
120 json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
121}
122
123// handleUpload handles file uploads via POST /api/upload
124// Files are saved to the ScreenshotDir with a random filename
125func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
126 if r.Method != http.MethodPost {
127 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
128 return
129 }
130
131 // Limit to 10MB file size
132 r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
133
134 // Parse the multipart form
135 if err := r.ParseMultipartForm(10 * 1024 * 1024); err != nil {
136 http.Error(w, "failed to parse form: "+err.Error(), http.StatusBadRequest)
137 return
138 }
139
140 // Get the file from the multipart form
141 file, handler, err := r.FormFile("file")
142 if err != nil {
143 http.Error(w, "failed to get uploaded file: "+err.Error(), http.StatusBadRequest)
144 return
145 }
146 defer file.Close()
147
148 // Generate a unique ID (8 random bytes converted to 16 hex chars)
149 randBytes := make([]byte, 8)
150 if _, err := rand.Read(randBytes); err != nil {
151 http.Error(w, "failed to generate random filename: "+err.Error(), http.StatusInternalServerError)
152 return
153 }
154
155 // Get file extension from the original filename
156 ext := filepath.Ext(handler.Filename)
157
158 // Create a unique filename in the ScreenshotDir
159 filename := filepath.Join(browse.ScreenshotDir, fmt.Sprintf("upload_%s%s", hex.EncodeToString(randBytes), ext))
160
161 // Ensure the directory exists
162 if err := os.MkdirAll(browse.ScreenshotDir, 0o755); err != nil {
163 http.Error(w, "failed to create directory: "+err.Error(), http.StatusInternalServerError)
164 return
165 }
166
167 // Create the destination file
168 destFile, err := os.Create(filename)
169 if err != nil {
170 http.Error(w, "failed to create destination file: "+err.Error(), http.StatusInternalServerError)
171 return
172 }
173 defer destFile.Close()
174
175 // Copy the file contents to the destination file
176 if _, err := io.Copy(destFile, file); err != nil {
177 http.Error(w, "failed to save file: "+err.Error(), http.StatusInternalServerError)
178 return
179 }
180
181 // Return the path to the saved file
182 w.Header().Set("Content-Type", "application/json")
183 json.NewEncoder(w).Encode(map[string]string{"path": filename})
184}
185
186// staticHandler serves files from the provided filesystem.
187// For JS/CSS files, it serves pre-compressed .gz versions with content-based ETags.
188func isConversationSlugPath(path string) bool {
189 return strings.HasPrefix(path, "/c/")
190}
191
192// acceptsGzip returns true if the client accepts gzip encoding
193func acceptsGzip(r *http.Request) bool {
194 return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
195}
196
197// etagMatches checks if the client's If-None-Match header matches the given ETag.
198// Per RFC 7232, If-None-Match can contain multiple ETags (comma-separated)
199// and may use weak validators (W/"..."). For GET/HEAD, weak comparison is used.
200func etagMatches(ifNoneMatch, etag string) bool {
201 if ifNoneMatch == "" {
202 return false
203 }
204 // Normalize our ETag by stripping W/ prefix if present
205 normEtag := strings.TrimPrefix(etag, `W/`)
206
207 // If-None-Match can be "*" which matches any
208 if ifNoneMatch == "*" {
209 return true
210 }
211
212 // Split by comma and check each tag
213 for _, tag := range strings.Split(ifNoneMatch, ",") {
214 tag = strings.TrimSpace(tag)
215 // Strip W/ prefix for weak comparison
216 tag = strings.TrimPrefix(tag, `W/`)
217 if tag == normEtag {
218 return true
219 }
220 }
221 return false
222}
223
224func (s *Server) staticHandler(fsys http.FileSystem) http.Handler {
225 fileServer := http.FileServer(fsys)
226
227 // Load checksums for ETag support (content-based, not git-based)
228 checksums := ui.Checksums()
229
230 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
231 // Inject initialization data into index.html
232 if r.URL.Path == "/" || r.URL.Path == "/index.html" || isConversationSlugPath(r.URL.Path) {
233 w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
234 w.Header().Set("Pragma", "no-cache")
235 w.Header().Set("Expires", "0")
236 w.Header().Set("Content-Type", "text/html")
237 s.serveIndexWithInit(w, r, fsys)
238 return
239 }
240
241 // For JS and CSS files, serve from .gz files (only .gz versions are embedded)
242 if strings.HasSuffix(r.URL.Path, ".js") || strings.HasSuffix(r.URL.Path, ".css") {
243 gzPath := r.URL.Path + ".gz"
244 gzFile, err := fsys.Open(gzPath)
245 if err != nil {
246 // No .gz file, fall through to regular file server
247 fileServer.ServeHTTP(w, r)
248 return
249 }
250 defer gzFile.Close()
251
252 stat, err := gzFile.Stat()
253 if err != nil || stat.IsDir() {
254 fileServer.ServeHTTP(w, r)
255 return
256 }
257
258 // Get filename without leading slash for checksum lookup
259 filename := strings.TrimPrefix(r.URL.Path, "/")
260
261 // Check ETag for cache validation (content-based)
262 if checksums != nil {
263 if hash, ok := checksums[filename]; ok {
264 etag := `"` + hash + `"`
265 w.Header().Set("ETag", etag)
266 if etagMatches(r.Header.Get("If-None-Match"), etag) {
267 w.WriteHeader(http.StatusNotModified)
268 return
269 }
270 }
271 }
272
273 w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(r.URL.Path)))
274 w.Header().Set("Vary", "Accept-Encoding")
275 // Use must-revalidate so browsers check ETag on each request.
276 // We can't use immutable since we don't have content-hashed filenames.
277 w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
278
279 if acceptsGzip(r) {
280 // Client accepts gzip - serve compressed directly
281 w.Header().Set("Content-Encoding", "gzip")
282 io.Copy(w, gzFile)
283 } else {
284 // Rare: client doesn't accept gzip - decompress on the fly
285 gr, err := gzip.NewReader(gzFile)
286 if err != nil {
287 http.Error(w, "failed to decompress", http.StatusInternalServerError)
288 return
289 }
290 defer gr.Close()
291 io.Copy(w, gr)
292 }
293 return
294 }
295
296 fileServer.ServeHTTP(w, r)
297 })
298}
299
300// hashString computes a simple hash of a string
301func hashString(s string) uint32 {
302 var hash uint32
303 for _, c := range s {
304 hash = ((hash << 5) - hash) + uint32(c)
305 }
306 return hash
307}
308
309// generateFaviconSVG creates a Cool S favicon with color based on hostname hash
310// Big colored circle background with the Cool S inscribed in white
311func generateFaviconSVG(hostname string) string {
312 hash := hashString(hostname)
313 h := hash % 360
314 bgColor := fmt.Sprintf("hsl(%d, 70%%, 55%%)", h)
315 // White S on colored background - good contrast on any saturated hue
316 strokeColor := "#ffffff"
317
318 // Original Cool S viewBox: 0 0 171 393 (tall rectangle)
319 // Square viewBox 0 0 400 400 with circle, S scaled and centered inside
320 // S dimensions: 171x393, scale 0.97 gives 166x381, centered in 400x400
321 return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
322<circle cx="200" cy="200" r="200" fill="%s"/>
323<g transform="translate(117 10) scale(0.97)">
324<g stroke-linecap="round"><g transform="translate(13.3 97.5) rotate(0 1.4 42.2)"><path d="M1.28 0.48C1.15 14.67,-0.96 71.95,-1.42 86.14M-1.47-1.73C-0.61 11.51,4.65 66.62,4.21 81.75" stroke="%s" stroke-width="14" fill="none"/></g></g>
325<g stroke-linecap="round"><g transform="translate(87.6 97.2) rotate(0 1.2 42.4)"><path d="M-1.42 1.14C-1.89 15.33,-1.41 71.93,-1.52 85.6M3-0.71C3.35 12.53,3.95 66.59,4.06 80.91" stroke="%s" stroke-width="14" fill="none"/></g></g>
326<g stroke-linecap="round"><g transform="translate(156.3 91) rotate(0 0.7 42.1)"><path d="M-1.52 0.6C-1.62 14.26,-1.97 68.6,-2.04 83.12M2.86-1.55C3.77 12.32,3.09 71.53,3.26 85.73" stroke="%s" stroke-width="14" fill="none"/></g></g>
327<g stroke-linecap="round"><g transform="translate(157.7 230.3) rotate(0 0.6 42.9)"><path d="M-2.04-1.88C-2.11 12.64,-2.52 72.91,-1.93 87.72M2.05 3.27C3.01 17.02,3.68 70.97,3.43 84.18" stroke="%s" stroke-width="14" fill="none"/></g></g>
328<g stroke-linecap="round"><g transform="translate(12.6 226.7) rotate(0 0.2 44.3)"><path d="M-1.93 2.72C-1.33 17.52,1.37 73.57,1.54 86.96M2.23 1.72C2.77 15.92,1.05 69.12,0.14 83.02" stroke="%s" stroke-width="14" fill="none"/></g></g>
329<g stroke-linecap="round"><g transform="translate(82.8 226.6) rotate(0 -1.1 43.1)"><path d="M1.54 1.96C1.7 15.35,-0.76 69.37,-0.93 83.06M-1.07 0.56C-1.19 15.45,-3.69 71.28,-3.67 85.64" stroke="%s" stroke-width="14" fill="none"/></g></g>
330<g stroke-linecap="round"><g transform="translate(152.7 311.8) rotate(0 -32.3 34.6)"><path d="M-0.93-1.94C-12.26 9.08,-55.27 56.42,-66.46 68.08M3.76 3.18C-8.04 14.42,-56.04 59.98,-68.41 71.22" stroke="%s" stroke-width="14" fill="none"/></g></g>
331<g stroke-linecap="round"><g transform="translate(14.7 308.2) rotate(0 34.1 33.6)"><path d="M0.54-0.92C12.51 10.75,58.76 55.93,70.91 68.03M-2.62-3.88C8.97 8.35,55.58 59.22,68.08 71.13" stroke="%s" stroke-width="14" fill="none"/></g></g>
332<g stroke-linecap="round"><g transform="translate(11.3 178.5) rotate(0 35.7 23.4)"><path d="M-1.09-0.97C10.89 7.63,60.55 42.51,72.41 50.67M3.51-3.96C15.2 4,60.24 37.93,70.94 47.11" stroke="%s" stroke-width="14" fill="none"/></g></g>
333<g stroke-linecap="round"><g transform="translate(11.3 223.5) rotate(0 13.4 -10.2)"><path d="M1.41 2.67C6.27-1,23.83-19.1,28.07-23M-1.26 1.66C3.24-1.45,19.69-14.92,25.32-19.37" stroke="%s" stroke-width="14" fill="none"/></g></g>
334<g stroke-linecap="round"><g transform="translate(13.3 94.5) rotate(0 34.6 -42.2)"><path d="M-0.93 0C9.64-13.89,53.62-66.83,64.85-80.71M3.76-2.46C15.07-15.91,59.99-71.5,70.08-84.48" stroke="%s" stroke-width="14" fill="none"/></g></g>
335<g stroke-linecap="round"><g transform="translate(81.3 12.5) rotate(0 36.1 39.1)"><path d="M-2.15 2.29C10.41 14.58,61.78 62.2,74.43 73.73M1.88 1.07C14.1 13.81,60.32 65.18,71.89 77.21" stroke="%s" stroke-width="14" fill="none"/></g></g>
336<g stroke-linecap="round"><g transform="translate(88.3 177.5) rotate(0 31.2 22.9)"><path d="M-0.57-0.27C10.92 7.09,55.6 38.04,66.75 46.48M-4.32-2.89C6.87 4.52,51.07 40.67,63.83 48.74" stroke="%s" stroke-width="14" fill="none"/></g></g>
337<g stroke-linecap="round"><g transform="translate(155.3 174.5) rotate(0 -10.7 13.4)"><path d="M-1.25-2.52C-5.27 2.41,-21.09 24.62,-24.67 29.33M3.26 2.28C0.21 6.4,-14.57 20.81,-19.18 25.04" stroke="%s" stroke-width="14" fill="none"/></g></g>
338</g>
339</svg>`,
340 bgColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor, strokeColor,
341 )
342}
343
344// serveIndexWithInit serves index.html with injected initialization data
345func (s *Server) serveIndexWithInit(w http.ResponseWriter, r *http.Request, fs http.FileSystem) {
346 // Read index.html from the filesystem
347 file, err := fs.Open("/index.html")
348 if err != nil {
349 http.Error(w, "index.html not found", http.StatusNotFound)
350 return
351 }
352 defer file.Close()
353
354 indexHTML, err := io.ReadAll(file)
355 if err != nil {
356 http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
357 return
358 }
359
360 // Build initialization data
361 modelList := s.getModelList()
362
363 // Select default model - use configured default if available, otherwise first ready model
364 // If no models are available, default_model should be empty
365 defaultModel := ""
366 if len(modelList) > 0 {
367 defaultModel = s.defaultModel
368 if defaultModel == "" {
369 defaultModel = models.Default().ID
370 }
371 defaultModelAvailable := false
372 for _, m := range modelList {
373 if m.ID == defaultModel && m.Ready {
374 defaultModelAvailable = true
375 break
376 }
377 }
378 if !defaultModelAvailable {
379 // Fall back to first ready model
380 for _, m := range modelList {
381 if m.Ready {
382 defaultModel = m.ID
383 break
384 }
385 }
386 }
387 }
388
389 // Get hostname (add .exe.xyz suffix if no dots, matching system_prompt.go)
390 hostname := "localhost"
391 if h, err := os.Hostname(); err == nil {
392 if !strings.Contains(h, ".") {
393 hostname = h + ".exe.xyz"
394 } else {
395 hostname = h
396 }
397 }
398
399 // Get default working directory
400 defaultCwd, err := os.Getwd()
401 if err != nil {
402 defaultCwd = "/"
403 }
404
405 // Get home directory for tilde display
406 homeDir, _ := os.UserHomeDir()
407
408 initData := map[string]interface{}{
409 "models": modelList,
410 "default_model": defaultModel,
411 "hostname": hostname,
412 "default_cwd": defaultCwd,
413 "home_dir": homeDir,
414 }
415 if s.terminalURL != "" {
416 initData["terminal_url"] = s.terminalURL
417 }
418 if len(s.links) > 0 {
419 initData["links"] = s.links
420 }
421
422 initJSON, err := json.Marshal(initData)
423 if err != nil {
424 http.Error(w, "Failed to marshal init data", http.StatusInternalServerError)
425 return
426 }
427
428 // Generate favicon as data URI
429 faviconSVG := generateFaviconSVG(hostname)
430 faviconDataURI := "data:image/svg+xml," + url.PathEscape(faviconSVG)
431 faviconLink := fmt.Sprintf(`<link rel="icon" type="image/svg+xml" href="%s"/>`, faviconDataURI)
432
433 // Inject the script tag and favicon before </head>
434 initScript := fmt.Sprintf(`<script>window.__SHELLEY_INIT__=%s;</script>`, initJSON)
435 injection := faviconLink + initScript
436 modifiedHTML := strings.Replace(string(indexHTML), "</head>", injection+"</head>", 1)
437
438 w.Write([]byte(modifiedHTML))
439}
440
441// handleConfig returns server configuration
442// handleConversations handles GET /conversations
443func (s *Server) handleConversations(w http.ResponseWriter, r *http.Request) {
444 if r.Method != http.MethodGet {
445 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
446 return
447 }
448 ctx := r.Context()
449 limit := 5000
450 offset := 0
451 var query string
452
453 // Parse query parameters
454 if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
455 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
456 limit = l
457 }
458 }
459 if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
460 if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
461 offset = o
462 }
463 }
464 query = r.URL.Query().Get("q")
465 searchContent := r.URL.Query().Get("search_content") == "true"
466
467 // Get conversations from database
468 var conversations []generated.Conversation
469 var err error
470
471 if query != "" {
472 if searchContent {
473 // Search in both slug and message content
474 conversations, err = s.db.SearchConversationsWithMessages(ctx, query, int64(limit), int64(offset))
475 } else {
476 // Search only in slug
477 conversations, err = s.db.SearchConversations(ctx, query, int64(limit), int64(offset))
478 }
479 } else {
480 conversations, err = s.db.ListConversations(ctx, int64(limit), int64(offset))
481 }
482
483 if err != nil {
484 s.logger.Error("Failed to get conversations", "error", err)
485 http.Error(w, "Internal server error", http.StatusInternalServerError)
486 return
487 }
488
489 // Get working states for all active conversations
490 workingStates := s.getWorkingConversations()
491
492 // Build response with working state included
493 result := make([]ConversationWithState, len(conversations))
494 for i, conv := range conversations {
495 result[i] = ConversationWithState{
496 Conversation: conv,
497 Working: workingStates[conv.ConversationID],
498 }
499 }
500
501 w.Header().Set("Content-Type", "application/json")
502 json.NewEncoder(w).Encode(result)
503}
504
505// conversationMux returns a mux for /api/conversation/<id>/* routes
506func (s *Server) conversationMux() *http.ServeMux {
507 mux := http.NewServeMux()
508 // GET /api/conversation/<id> - returns all messages (can be large, compress)
509 mux.Handle("GET /{id}", gzipHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
510 s.handleGetConversation(w, r, r.PathValue("id"))
511 })))
512 // GET /api/conversation/<id>/stream - SSE stream (do NOT compress)
513 // TODO: Consider gzip for SSE in the future. Would reduce bandwidth
514 // for large tool outputs, but needs flush after each event.
515 mux.HandleFunc("GET /{id}/stream", func(w http.ResponseWriter, r *http.Request) {
516 s.handleStreamConversation(w, r, r.PathValue("id"))
517 })
518 // POST endpoints - small responses, no compression needed
519 mux.HandleFunc("POST /{id}/chat", func(w http.ResponseWriter, r *http.Request) {
520 s.handleChatConversation(w, r, r.PathValue("id"))
521 })
522 mux.HandleFunc("POST /{id}/cancel", func(w http.ResponseWriter, r *http.Request) {
523 s.handleCancelConversation(w, r, r.PathValue("id"))
524 })
525 mux.HandleFunc("POST /{id}/archive", func(w http.ResponseWriter, r *http.Request) {
526 s.handleArchiveConversation(w, r, r.PathValue("id"))
527 })
528 mux.HandleFunc("POST /{id}/unarchive", func(w http.ResponseWriter, r *http.Request) {
529 s.handleUnarchiveConversation(w, r, r.PathValue("id"))
530 })
531 mux.HandleFunc("POST /{id}/delete", func(w http.ResponseWriter, r *http.Request) {
532 s.handleDeleteConversation(w, r, r.PathValue("id"))
533 })
534 mux.HandleFunc("POST /{id}/rename", func(w http.ResponseWriter, r *http.Request) {
535 s.handleRenameConversation(w, r, r.PathValue("id"))
536 })
537 mux.HandleFunc("GET /{id}/subagents", func(w http.ResponseWriter, r *http.Request) {
538 s.handleGetSubagents(w, r, r.PathValue("id"))
539 })
540 return mux
541}
542
543// handleGetConversation handles GET /conversation/<id>
544func (s *Server) handleGetConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
545 if r.Method != http.MethodGet {
546 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
547 return
548 }
549
550 ctx := r.Context()
551 var (
552 messages []generated.Message
553 conversation generated.Conversation
554 )
555 err := s.db.Queries(ctx, func(q *generated.Queries) error {
556 var err error
557 messages, err = q.ListMessages(ctx, conversationID)
558 if err != nil {
559 return err
560 }
561 conversation, err = q.GetConversation(ctx, conversationID)
562 return err
563 })
564 if errors.Is(err, sql.ErrNoRows) {
565 http.Error(w, "Conversation not found", http.StatusNotFound)
566 return
567 }
568 if err != nil {
569 s.logger.Error("Failed to get conversation messages", "conversationID", conversationID, "error", err)
570 http.Error(w, "Internal server error", http.StatusInternalServerError)
571 return
572 }
573
574 w.Header().Set("Content-Type", "application/json")
575 apiMessages := toAPIMessages(messages)
576 json.NewEncoder(w).Encode(StreamResponse{
577 Messages: apiMessages,
578 Conversation: conversation,
579 // ConversationState is sent via the streaming endpoint, not on initial load
580 ContextWindowSize: calculateContextWindowSize(apiMessages),
581 })
582}
583
584// ChatRequest represents a chat message from the user
585type ChatRequest struct {
586 Message string `json:"message"`
587 Model string `json:"model,omitempty"`
588 Cwd string `json:"cwd,omitempty"`
589}
590
591// handleChatConversation handles POST /conversation/<id>/chat
592func (s *Server) handleChatConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
593 if r.Method != http.MethodPost {
594 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
595 return
596 }
597
598 ctx := r.Context()
599
600 // Parse request
601 var req ChatRequest
602 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
603 http.Error(w, "Invalid JSON", http.StatusBadRequest)
604 return
605 }
606
607 if req.Message == "" {
608 http.Error(w, "Message is required", http.StatusBadRequest)
609 return
610 }
611
612 // Get LLM service for the requested model
613 modelID := req.Model
614 if modelID == "" {
615 modelID = s.defaultModel
616 }
617
618 llmService, err := s.llmManager.GetService(modelID)
619 if err != nil {
620 s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
621 http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
622 return
623 }
624
625 // Get or create conversation manager
626 manager, err := s.getOrCreateConversationManager(ctx, conversationID)
627 if errors.Is(err, errConversationModelMismatch) {
628 http.Error(w, err.Error(), http.StatusBadRequest)
629 return
630 }
631 if err != nil {
632 s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
633 http.Error(w, "Internal server error", http.StatusInternalServerError)
634 return
635 }
636
637 // Create user message
638 userMessage := llm.Message{
639 Role: llm.MessageRoleUser,
640 Content: []llm.Content{
641 {Type: llm.ContentTypeText, Text: req.Message},
642 },
643 }
644
645 firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
646 if errors.Is(err, errConversationModelMismatch) {
647 http.Error(w, err.Error(), http.StatusBadRequest)
648 return
649 }
650 if err != nil {
651 s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
652 http.Error(w, "Internal server error", http.StatusInternalServerError)
653 return
654 }
655
656 if firstMessage {
657 ctxNoCancel := context.WithoutCancel(ctx)
658 go func() {
659 slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
660 defer cancel()
661 _, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, req.Message, modelID)
662 if err != nil {
663 s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
664 } else {
665 go s.notifySubscribers(ctxNoCancel, conversationID)
666 }
667 }()
668 }
669
670 w.WriteHeader(http.StatusAccepted)
671 json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})
672}
673
674// handleNewConversation handles POST /api/conversations/new - creates conversation implicitly on first message
675func (s *Server) handleNewConversation(w http.ResponseWriter, r *http.Request) {
676 if r.Method != http.MethodPost {
677 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
678 return
679 }
680
681 ctx := r.Context()
682
683 // Parse request
684 var req ChatRequest
685 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
686 http.Error(w, "Invalid JSON", http.StatusBadRequest)
687 return
688 }
689
690 if req.Message == "" {
691 http.Error(w, "Message is required", http.StatusBadRequest)
692 return
693 }
694
695 // Get LLM service for the requested model
696 modelID := req.Model
697 if modelID == "" {
698 // Default to Qwen3 Coder on Fireworks
699 modelID = "qwen3-coder-fireworks"
700 }
701
702 llmService, err := s.llmManager.GetService(modelID)
703 if err != nil {
704 s.logger.Error("Unsupported model requested", "model", modelID, "error", err)
705 http.Error(w, fmt.Sprintf("Unsupported model: %s", modelID), http.StatusBadRequest)
706 return
707 }
708
709 // Create new conversation with optional cwd
710 var cwdPtr *string
711 if req.Cwd != "" {
712 cwdPtr = &req.Cwd
713 }
714 conversation, err := s.db.CreateConversation(ctx, nil, true, cwdPtr, &modelID)
715 if err != nil {
716 s.logger.Error("Failed to create conversation", "error", err)
717 http.Error(w, "Internal server error", http.StatusInternalServerError)
718 return
719 }
720 conversationID := conversation.ConversationID
721
722 // Notify conversation list subscribers about the new conversation
723 go s.publishConversationListUpdate(ConversationListUpdate{
724 Type: "update",
725 Conversation: conversation,
726 })
727
728 // Get or create conversation manager
729 manager, err := s.getOrCreateConversationManager(ctx, conversationID)
730 if errors.Is(err, errConversationModelMismatch) {
731 http.Error(w, err.Error(), http.StatusBadRequest)
732 return
733 }
734 if err != nil {
735 s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
736 http.Error(w, "Internal server error", http.StatusInternalServerError)
737 return
738 }
739
740 // Create user message
741 userMessage := llm.Message{
742 Role: llm.MessageRoleUser,
743 Content: []llm.Content{
744 {Type: llm.ContentTypeText, Text: req.Message},
745 },
746 }
747
748 firstMessage, err := manager.AcceptUserMessage(ctx, llmService, modelID, userMessage)
749 if errors.Is(err, errConversationModelMismatch) {
750 http.Error(w, err.Error(), http.StatusBadRequest)
751 return
752 }
753 if err != nil {
754 s.logger.Error("Failed to accept user message", "conversationID", conversationID, "error", err)
755 http.Error(w, "Internal server error", http.StatusInternalServerError)
756 return
757 }
758
759 if firstMessage {
760 ctxNoCancel := context.WithoutCancel(ctx)
761 go func() {
762 slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
763 defer cancel()
764 _, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, req.Message, modelID)
765 if err != nil {
766 s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
767 } else {
768 go s.notifySubscribers(ctxNoCancel, conversationID)
769 }
770 }()
771 }
772
773 w.Header().Set("Content-Type", "application/json")
774 w.WriteHeader(http.StatusCreated)
775 json.NewEncoder(w).Encode(map[string]interface{}{
776 "status": "accepted",
777 "conversation_id": conversationID,
778 })
779}
780
781// ContinueConversationRequest represents the request to continue a conversation in a new one
782type ContinueConversationRequest struct {
783 SourceConversationID string `json:"source_conversation_id"`
784 Model string `json:"model,omitempty"`
785 Cwd string `json:"cwd,omitempty"`
786}
787
788// handleContinueConversation handles POST /api/conversations/continue
789// Creates a new conversation with a summary of the source conversation as the initial user message,
790// but does NOT start the agent. The user can then add additional instructions before sending.
791func (s *Server) handleContinueConversation(w http.ResponseWriter, r *http.Request) {
792 if r.Method != http.MethodPost {
793 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
794 return
795 }
796
797 ctx := r.Context()
798
799 // Parse request
800 var req ContinueConversationRequest
801 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
802 http.Error(w, "Invalid JSON", http.StatusBadRequest)
803 return
804 }
805
806 if req.SourceConversationID == "" {
807 http.Error(w, "source_conversation_id is required", http.StatusBadRequest)
808 return
809 }
810
811 // Get source conversation
812 sourceConv, err := s.db.GetConversationByID(ctx, req.SourceConversationID)
813 if err != nil {
814 s.logger.Error("Failed to get source conversation", "conversationID", req.SourceConversationID, "error", err)
815 http.Error(w, "Source conversation not found", http.StatusNotFound)
816 return
817 }
818
819 // Get messages from source conversation
820 messages, err := s.db.ListMessages(ctx, req.SourceConversationID)
821 if err != nil {
822 s.logger.Error("Failed to get messages", "conversationID", req.SourceConversationID, "error", err)
823 http.Error(w, "Failed to get messages", http.StatusInternalServerError)
824 return
825 }
826
827 // Build summary message
828 sourceSlug := "unknown"
829 if sourceConv.Slug != nil {
830 sourceSlug = *sourceConv.Slug
831 }
832 summary := buildConversationSummary(sourceSlug, messages)
833
834 // Determine model to use
835 modelID := req.Model
836 if modelID == "" && sourceConv.Model != nil {
837 modelID = *sourceConv.Model
838 }
839 if modelID == "" {
840 modelID = "qwen3-coder-fireworks"
841 }
842
843 // Create new conversation with cwd from request or source conversation
844 var cwdPtr *string
845 if req.Cwd != "" {
846 cwdPtr = &req.Cwd
847 } else if sourceConv.Cwd != nil {
848 cwdPtr = sourceConv.Cwd
849 }
850 conversation, err := s.db.CreateConversation(ctx, nil, true, cwdPtr, &modelID)
851 if err != nil {
852 s.logger.Error("Failed to create conversation", "error", err)
853 http.Error(w, "Internal server error", http.StatusInternalServerError)
854 return
855 }
856 conversationID := conversation.ConversationID
857
858 // Notify conversation list subscribers about the new conversation
859 go s.publishConversationListUpdate(ConversationListUpdate{
860 Type: "update",
861 Conversation: conversation,
862 })
863
864 // Create and record the user message with the summary, but do NOT start the agent loop.
865 // This allows the user to see the summary and add additional instructions before sending.
866 userMessage := llm.Message{
867 Role: llm.MessageRoleUser,
868 Content: []llm.Content{
869 {Type: llm.ContentTypeText, Text: summary},
870 },
871 }
872
873 if err := s.recordMessage(ctx, conversationID, userMessage, llm.Usage{}); err != nil {
874 s.logger.Error("Failed to record summary message", "conversationID", conversationID, "error", err)
875 http.Error(w, "Internal server error", http.StatusInternalServerError)
876 return
877 }
878
879 // Generate slug for the new conversation in background
880 ctxNoCancel := context.WithoutCancel(ctx)
881 go func() {
882 slugCtx, cancel := context.WithTimeout(ctxNoCancel, 15*time.Second)
883 defer cancel()
884 _, err := slug.GenerateSlug(slugCtx, s.llmManager, s.db, s.logger, conversationID, summary, modelID)
885 if err != nil {
886 s.logger.Warn("Failed to generate slug for conversation", "conversationID", conversationID, "error", err)
887 } else {
888 go s.notifySubscribers(ctxNoCancel, conversationID)
889 }
890 }()
891
892 w.Header().Set("Content-Type", "application/json")
893 w.WriteHeader(http.StatusCreated)
894 json.NewEncoder(w).Encode(map[string]interface{}{
895 "status": "created",
896 "conversation_id": conversationID,
897 })
898}
899
900// buildConversationSummary creates a summary of messages from a conversation
901// for use as the initial prompt in a continuation conversation
902func buildConversationSummary(slug string, messages []generated.Message) string {
903 var sb strings.Builder
904 sb.WriteString(fmt.Sprintf("Continue the conversation with slug %q. Here are the user and agent messages so far (including tool inputs up to ~250 characters and tool outputs up to ~250 characters); use sqlite to look up additional details.\n\n", slug))
905
906 for _, msg := range messages {
907 if msg.Type != string(db.MessageTypeUser) && msg.Type != string(db.MessageTypeAgent) {
908 continue
909 }
910
911 if msg.LlmData == nil {
912 continue
913 }
914
915 var llmMsg llm.Message
916 if err := json.Unmarshal([]byte(*msg.LlmData), &llmMsg); err != nil {
917 continue
918 }
919
920 var role string
921 if msg.Type == string(db.MessageTypeUser) {
922 role = "User"
923 } else {
924 role = "Agent"
925 }
926
927 for _, content := range llmMsg.Content {
928 switch content.Type {
929 case llm.ContentTypeText:
930 if content.Text != "" {
931 sb.WriteString(fmt.Sprintf("%s: %s\n\n", role, content.Text))
932 }
933 case llm.ContentTypeToolUse:
934 inputStr := string(content.ToolInput)
935 if len(inputStr) > 250 {
936 inputStr = inputStr[:250] + "..."
937 }
938 sb.WriteString(fmt.Sprintf("%s: [Tool: %s] %s\n\n", role, content.ToolName, inputStr))
939 case llm.ContentTypeToolResult:
940 // Get the text content from tool result
941 var resultText string
942 for _, res := range content.ToolResult {
943 if res.Type == llm.ContentTypeText && res.Text != "" {
944 resultText = res.Text
945 break
946 }
947 }
948 if len(resultText) > 250 {
949 resultText = resultText[:250] + "..."
950 }
951 if resultText != "" {
952 errStr := ""
953 if content.ToolError {
954 errStr = " (error)"
955 }
956 sb.WriteString(fmt.Sprintf("%s: [Tool Result%s] %s\n\n", role, errStr, resultText))
957 }
958 case llm.ContentTypeThinking:
959 // Skip thinking blocks - they're internal
960 }
961 }
962 }
963
964 return sb.String()
965}
966
967// handleCancelConversation handles POST /conversation/<id>/cancel
968func (s *Server) handleCancelConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
969 if r.Method != http.MethodPost {
970 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
971 return
972 }
973
974 ctx := r.Context()
975
976 // Get the conversation manager if it exists
977 s.mu.Lock()
978 manager, exists := s.activeConversations[conversationID]
979 s.mu.Unlock()
980
981 if !exists {
982 // No active conversation to cancel
983 w.WriteHeader(http.StatusOK)
984 json.NewEncoder(w).Encode(map[string]string{"status": "no_active_conversation"})
985 return
986 }
987
988 // Cancel the conversation
989 if err := manager.CancelConversation(ctx); err != nil {
990 s.logger.Error("Failed to cancel conversation", "conversationID", conversationID, "error", err)
991 http.Error(w, "Failed to cancel conversation", http.StatusInternalServerError)
992 return
993 }
994
995 s.logger.Info("Conversation cancelled", "conversationID", conversationID)
996 w.WriteHeader(http.StatusOK)
997 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled"})
998}
999
1000// handleStreamConversation handles GET /conversation/<id>/stream
1001// Query parameters:
1002// - last_sequence_id: Resume from this sequence ID (skip messages up to and including this ID)
1003func (s *Server) handleStreamConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
1004 if r.Method != http.MethodGet {
1005 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1006 return
1007 }
1008
1009 ctx := r.Context()
1010
1011 // Parse last_sequence_id for resuming streams
1012 lastSeqID := int64(-1)
1013 if lastSeqStr := r.URL.Query().Get("last_sequence_id"); lastSeqStr != "" {
1014 if parsed, err := strconv.ParseInt(lastSeqStr, 10, 64); err == nil {
1015 lastSeqID = parsed
1016 }
1017 }
1018
1019 // Set up SSE headers
1020 w.Header().Set("Content-Type", "text/event-stream")
1021 w.Header().Set("Cache-Control", "no-cache")
1022 w.Header().Set("Connection", "keep-alive")
1023 w.Header().Set("Access-Control-Allow-Origin", "*")
1024
1025 // For fresh connections, get messages BEFORE calling getOrCreateConversationManager.
1026 // This is important because getOrCreateConversationManager may create a system prompt
1027 // message during hydration, and we want to return the messages as they were before.
1028 var messages []generated.Message
1029 var conversation generated.Conversation
1030 if lastSeqID < 0 {
1031 err := s.db.Queries(ctx, func(q *generated.Queries) error {
1032 var err error
1033 messages, err = q.ListMessages(ctx, conversationID)
1034 if err != nil {
1035 return err
1036 }
1037 conversation, err = q.GetConversation(ctx, conversationID)
1038 return err
1039 })
1040 if err != nil {
1041 s.logger.Error("Failed to get conversation data", "conversationID", conversationID, "error", err)
1042 http.Error(w, "Internal server error", http.StatusInternalServerError)
1043 return
1044 }
1045 // Update lastSeqID based on messages we're sending
1046 if len(messages) > 0 {
1047 lastSeqID = messages[len(messages)-1].SequenceID
1048 }
1049 } else {
1050 // Resuming - just get conversation metadata
1051 err := s.db.Queries(ctx, func(q *generated.Queries) error {
1052 var err error
1053 conversation, err = q.GetConversation(ctx, conversationID)
1054 return err
1055 })
1056 if err != nil {
1057 s.logger.Error("Failed to get conversation data", "conversationID", conversationID, "error", err)
1058 http.Error(w, "Internal server error", http.StatusInternalServerError)
1059 return
1060 }
1061 }
1062
1063 // Get or create conversation manager to access working state
1064 manager, err := s.getOrCreateConversationManager(ctx, conversationID)
1065 if err != nil {
1066 s.logger.Error("Failed to get conversation manager", "conversationID", conversationID, "error", err)
1067 http.Error(w, "Internal server error", http.StatusInternalServerError)
1068 return
1069 }
1070
1071 // Send initial response
1072 if len(messages) > 0 {
1073 // Fresh connection - send all messages
1074 apiMessages := toAPIMessages(messages)
1075 streamData := StreamResponse{
1076 Messages: apiMessages,
1077 Conversation: conversation,
1078 ConversationState: &ConversationState{
1079 ConversationID: conversationID,
1080 Working: manager.IsAgentWorking(),
1081 Model: manager.GetModel(),
1082 },
1083 ContextWindowSize: calculateContextWindowSize(apiMessages),
1084 }
1085 data, _ := json.Marshal(streamData)
1086 fmt.Fprintf(w, "data: %s\n\n", data)
1087 w.(http.Flusher).Flush()
1088 } else {
1089 // Either resuming or no messages yet - send current state as heartbeat
1090 streamData := StreamResponse{
1091 Conversation: conversation,
1092 ConversationState: &ConversationState{
1093 ConversationID: conversationID,
1094 Working: manager.IsAgentWorking(),
1095 Model: manager.GetModel(),
1096 },
1097 Heartbeat: true,
1098 }
1099 data, _ := json.Marshal(streamData)
1100 fmt.Fprintf(w, "data: %s\n\n", data)
1101 w.(http.Flusher).Flush()
1102 }
1103
1104 // Subscribe to new messages after the last one we sent
1105 next := manager.subpub.Subscribe(ctx, lastSeqID)
1106
1107 // Start heartbeat goroutine - sends state every 30 seconds if no other messages
1108 heartbeatDone := make(chan struct{})
1109 go func() {
1110 ticker := time.NewTicker(30 * time.Second)
1111 defer ticker.Stop()
1112 for {
1113 select {
1114 case <-ctx.Done():
1115 return
1116 case <-heartbeatDone:
1117 return
1118 case <-ticker.C:
1119 // Get current conversation state for heartbeat
1120 var conv generated.Conversation
1121 err := s.db.Queries(ctx, func(q *generated.Queries) error {
1122 var err error
1123 conv, err = q.GetConversation(ctx, conversationID)
1124 return err
1125 })
1126 if err != nil {
1127 continue // Skip heartbeat on error
1128 }
1129
1130 heartbeat := StreamResponse{
1131 Conversation: conv,
1132 ConversationState: &ConversationState{
1133 ConversationID: conversationID,
1134 Working: manager.IsAgentWorking(),
1135 Model: manager.GetModel(),
1136 },
1137 Heartbeat: true,
1138 }
1139 manager.subpub.Broadcast(heartbeat)
1140 }
1141 }
1142 }()
1143 defer close(heartbeatDone)
1144
1145 for {
1146 streamData, cont := next()
1147 if !cont {
1148 break
1149 }
1150 // Always forward updates, even if only the conversation changed (e.g., slug added)
1151 data, _ := json.Marshal(streamData)
1152 fmt.Fprintf(w, "data: %s\n\n", data)
1153 w.(http.Flusher).Flush()
1154 }
1155}
1156
1157// handleVersion returns version information as JSON
1158func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
1159 if r.Method != http.MethodGet {
1160 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1161 return
1162 }
1163
1164 w.Header().Set("Content-Type", "application/json")
1165 json.NewEncoder(w).Encode(version.GetInfo())
1166}
1167
1168// ModelInfo represents a model in the API response
1169type ModelInfo struct {
1170 ID string `json:"id"`
1171 DisplayName string `json:"display_name,omitempty"`
1172 Source string `json:"source,omitempty"` // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY")
1173 Ready bool `json:"ready"`
1174 MaxContextTokens int `json:"max_context_tokens,omitempty"`
1175}
1176
1177// getModelList returns the list of available models
1178func (s *Server) getModelList() []ModelInfo {
1179 modelList := []ModelInfo{}
1180 if s.predictableOnly {
1181 modelList = append(modelList, ModelInfo{ID: "predictable", Ready: true, MaxContextTokens: 200000})
1182 } else {
1183 modelIDs := s.llmManager.GetAvailableModels()
1184 for _, id := range modelIDs {
1185 // Skip predictable model unless predictable-only flag is set
1186 if id == "predictable" {
1187 continue
1188 }
1189 svc, err := s.llmManager.GetService(id)
1190 maxCtx := 0
1191 if err == nil && svc != nil {
1192 maxCtx = svc.TokenContextWindow()
1193 }
1194 info := ModelInfo{ID: id, Ready: err == nil, MaxContextTokens: maxCtx}
1195 // Add display name and source from model info
1196 if modelInfo := s.llmManager.GetModelInfo(id); modelInfo != nil {
1197 info.DisplayName = modelInfo.DisplayName
1198 info.Source = modelInfo.Source
1199 }
1200 modelList = append(modelList, info)
1201 }
1202 }
1203 return modelList
1204}
1205
1206// handleModels returns the list of available models
1207func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
1208 if r.Method != http.MethodGet {
1209 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1210 return
1211 }
1212 w.Header().Set("Content-Type", "application/json")
1213 json.NewEncoder(w).Encode(s.getModelList())
1214}
1215
1216// handleArchivedConversations handles GET /api/conversations/archived
1217func (s *Server) handleArchivedConversations(w http.ResponseWriter, r *http.Request) {
1218 if r.Method != http.MethodGet {
1219 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1220 return
1221 }
1222 ctx := r.Context()
1223 limit := 5000
1224 offset := 0
1225 var query string
1226
1227 // Parse query parameters
1228 if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
1229 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
1230 limit = l
1231 }
1232 }
1233 if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
1234 if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
1235 offset = o
1236 }
1237 }
1238 query = r.URL.Query().Get("q")
1239
1240 // Get archived conversations from database
1241 var conversations []generated.Conversation
1242 var err error
1243
1244 if query != "" {
1245 conversations, err = s.db.SearchArchivedConversations(ctx, query, int64(limit), int64(offset))
1246 } else {
1247 conversations, err = s.db.ListArchivedConversations(ctx, int64(limit), int64(offset))
1248 }
1249
1250 if err != nil {
1251 s.logger.Error("Failed to get archived conversations", "error", err)
1252 http.Error(w, "Internal server error", http.StatusInternalServerError)
1253 return
1254 }
1255
1256 w.Header().Set("Content-Type", "application/json")
1257 json.NewEncoder(w).Encode(conversations)
1258}
1259
1260// handleArchiveConversation handles POST /conversation/<id>/archive
1261func (s *Server) handleArchiveConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
1262 if r.Method != http.MethodPost {
1263 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1264 return
1265 }
1266
1267 ctx := r.Context()
1268 conversation, err := s.db.ArchiveConversation(ctx, conversationID)
1269 if err != nil {
1270 s.logger.Error("Failed to archive conversation", "conversationID", conversationID, "error", err)
1271 http.Error(w, "Internal server error", http.StatusInternalServerError)
1272 return
1273 }
1274
1275 // Notify conversation list subscribers
1276 go s.publishConversationListUpdate(ConversationListUpdate{
1277 Type: "update",
1278 Conversation: conversation,
1279 })
1280
1281 w.Header().Set("Content-Type", "application/json")
1282 json.NewEncoder(w).Encode(conversation)
1283}
1284
1285// handleUnarchiveConversation handles POST /conversation/<id>/unarchive
1286func (s *Server) handleUnarchiveConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
1287 if r.Method != http.MethodPost {
1288 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1289 return
1290 }
1291
1292 ctx := r.Context()
1293 conversation, err := s.db.UnarchiveConversation(ctx, conversationID)
1294 if err != nil {
1295 s.logger.Error("Failed to unarchive conversation", "conversationID", conversationID, "error", err)
1296 http.Error(w, "Internal server error", http.StatusInternalServerError)
1297 return
1298 }
1299
1300 // Notify conversation list subscribers
1301 go s.publishConversationListUpdate(ConversationListUpdate{
1302 Type: "update",
1303 Conversation: conversation,
1304 })
1305
1306 w.Header().Set("Content-Type", "application/json")
1307 json.NewEncoder(w).Encode(conversation)
1308}
1309
1310// handleDeleteConversation handles POST /conversation/<id>/delete
1311func (s *Server) handleDeleteConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
1312 if r.Method != http.MethodPost {
1313 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1314 return
1315 }
1316
1317 ctx := r.Context()
1318 if err := s.db.DeleteConversation(ctx, conversationID); err != nil {
1319 s.logger.Error("Failed to delete conversation", "conversationID", conversationID, "error", err)
1320 http.Error(w, "Internal server error", http.StatusInternalServerError)
1321 return
1322 }
1323
1324 // Notify conversation list subscribers about the deletion
1325 go s.publishConversationListUpdate(ConversationListUpdate{
1326 Type: "delete",
1327 ConversationID: conversationID,
1328 })
1329
1330 w.Header().Set("Content-Type", "application/json")
1331 json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
1332}
1333
1334// handleConversationBySlug handles GET /api/conversation-by-slug/<slug>
1335func (s *Server) handleConversationBySlug(w http.ResponseWriter, r *http.Request) {
1336 if r.Method != http.MethodGet {
1337 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1338 return
1339 }
1340
1341 slug := strings.TrimPrefix(r.URL.Path, "/api/conversation-by-slug/")
1342 if slug == "" {
1343 http.Error(w, "Slug required", http.StatusBadRequest)
1344 return
1345 }
1346
1347 ctx := r.Context()
1348 conversation, err := s.db.GetConversationBySlug(ctx, slug)
1349 if err != nil {
1350 if strings.Contains(err.Error(), "not found") {
1351 http.Error(w, "Conversation not found", http.StatusNotFound)
1352 return
1353 }
1354 s.logger.Error("Failed to get conversation by slug", "slug", slug, "error", err)
1355 http.Error(w, "Internal server error", http.StatusInternalServerError)
1356 return
1357 }
1358
1359 w.Header().Set("Content-Type", "application/json")
1360 json.NewEncoder(w).Encode(conversation)
1361}
1362
1363// RenameRequest represents a request to rename a conversation
1364type RenameRequest struct {
1365 Slug string `json:"slug"`
1366}
1367
1368// handleRenameConversation handles POST /conversation/<id>/rename
1369func (s *Server) handleRenameConversation(w http.ResponseWriter, r *http.Request, conversationID string) {
1370 if r.Method != http.MethodPost {
1371 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1372 return
1373 }
1374
1375 ctx := r.Context()
1376
1377 var req RenameRequest
1378 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1379 http.Error(w, "Invalid JSON", http.StatusBadRequest)
1380 return
1381 }
1382
1383 // Sanitize the slug using the same rules as auto-generated slugs
1384 sanitized := slug.Sanitize(req.Slug)
1385 if sanitized == "" {
1386 http.Error(w, "Slug is required (must contain alphanumeric characters)", http.StatusBadRequest)
1387 return
1388 }
1389
1390 conversation, err := s.db.UpdateConversationSlug(ctx, conversationID, sanitized)
1391 if err != nil {
1392 s.logger.Error("Failed to rename conversation", "conversationID", conversationID, "error", err)
1393 http.Error(w, "Internal server error", http.StatusInternalServerError)
1394 return
1395 }
1396
1397 // Notify conversation list subscribers
1398 go s.publishConversationListUpdate(ConversationListUpdate{
1399 Type: "update",
1400 Conversation: conversation,
1401 })
1402
1403 w.Header().Set("Content-Type", "application/json")
1404 json.NewEncoder(w).Encode(conversation)
1405}
1406
1407// handleVersionCheck returns version check information including update availability
1408func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
1409 forceRefresh := r.URL.Query().Get("refresh") == "true"
1410
1411 info, err := s.versionChecker.Check(r.Context(), forceRefresh)
1412 if err != nil {
1413 s.logger.Error("Version check failed", "error", err)
1414 http.Error(w, "Version check failed", http.StatusInternalServerError)
1415 return
1416 }
1417
1418 w.Header().Set("Content-Type", "application/json")
1419 json.NewEncoder(w).Encode(info)
1420}
1421
1422// handleVersionChangelog returns the changelog between current and latest versions
1423func (s *Server) handleVersionChangelog(w http.ResponseWriter, r *http.Request) {
1424 currentTag := r.URL.Query().Get("current")
1425 latestTag := r.URL.Query().Get("latest")
1426
1427 if currentTag == "" || latestTag == "" {
1428 http.Error(w, "current and latest query parameters are required", http.StatusBadRequest)
1429 return
1430 }
1431
1432 commits, err := s.versionChecker.FetchChangelog(r.Context(), currentTag, latestTag)
1433 if err != nil {
1434 s.logger.Error("Failed to fetch changelog", "error", err, "current", currentTag, "latest", latestTag)
1435 http.Error(w, "Failed to fetch changelog", http.StatusInternalServerError)
1436 return
1437 }
1438
1439 w.Header().Set("Content-Type", "application/json")
1440 json.NewEncoder(w).Encode(commits)
1441}
1442
1443// handleUpgrade performs a self-update of the Shelley binary
1444func (s *Server) handleUpgrade(w http.ResponseWriter, r *http.Request) {
1445 err := s.versionChecker.DoUpgrade(r.Context())
1446 if err != nil {
1447 s.logger.Error("Upgrade failed", "error", err)
1448 http.Error(w, fmt.Sprintf("Upgrade failed: %v", err), http.StatusInternalServerError)
1449 return
1450 }
1451
1452 w.Header().Set("Content-Type", "application/json")
1453 json.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": "Upgrade complete. Restart to apply."})
1454}
1455
1456// handleExit exits the process, expecting systemd or similar to restart it
1457func (s *Server) handleExit(w http.ResponseWriter, r *http.Request) {
1458 // Send response before exiting
1459 w.Header().Set("Content-Type", "application/json")
1460 json.NewEncoder(w).Encode(map[string]string{"status": "ok", "message": "Exiting..."})
1461
1462 // Flush the response
1463 if f, ok := w.(http.Flusher); ok {
1464 f.Flush()
1465 }
1466
1467 // Exit after a short delay to allow response to be sent
1468 go func() {
1469 time.Sleep(100 * time.Millisecond)
1470 s.logger.Info("Exiting Shelley via /exit endpoint")
1471 os.Exit(0)
1472 }()
1473}