handlers.go

   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}