package suggestions import ( "database/sql" "encoding/json" "errors" "net/http" "strings" "github.com/go-chi/chi/v5" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/llm" ) // chatRequest is the body the AskPetal panel posts: the full conversation so // far. The suggestion context is loaded server-side and never trusted from the // client (spec Note #10). type chatRequest struct { Messages []llm.Message `json:"messages"` } // chat streams an Ask Petal conversational reply over SSE. It loads the // suggestion and its parent document's surrounding paragraph, injects them as // the tutor system prompt, then relays the model's tokens to the browser as // `data:` events. History persistence lives entirely in the client. func (h *Handler) chat(w http.ResponseWriter, r *http.Request) { sugID := chi.URLParam(r, "id") var body chatRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { errorJSON(w, http.StatusBadRequest, "invalid request body") return } // One query for the suggestion fields and the parent document's plain text, // scoped to the local user so a stray id can't read another user's doc. var ( original, replacement, explanation, typ string fromPos int contentText string ) err := h.DB.QueryRow( `SELECT s.original, s.replacement, s.explanation, s.type, s.from_pos, d.content_text FROM suggestions s JOIN documents d ON d.id = s.doc_id WHERE s.id = ? AND d.user_id = ?`, sugID, db.LocalUserID, ).Scan(&original, &replacement, &explanation, &typ, &fromPos, &contentText) if errors.Is(err, sql.ErrNoRows) { errorJSON(w, http.StatusNotFound, "suggestion not found") return } if err != nil { serverError(w, err) return } paragraph := surroundingParagraph(contentText, fromPos) systemPrompt := llm.AskPetalSystemPrompt(original, replacement, typ, explanation, paragraph) // SSE requires an unbuffered, flushable writer. chi's middleware writers pass // Flush through; bail with a plain error if somehow they don't. flusher, ok := w.(http.Flusher) if !ok { serverError(w, errors.New("streaming unsupported")) return } ch, err := llm.StreamAskPetal(r.Context(), h.Client, systemPrompt, body.Messages) if err != nil { // The stream never opened (e.g. LLM unreachable) — a normal JSON error is // still appropriate since we haven't written SSE headers yet. errorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error()) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (e.g. nginx) w.WriteHeader(http.StatusOK) flusher.Flush() for chunk := range ch { writeSSE(w, "token", map[string]string{"text": chunk}) flusher.Flush() } // Signal a clean end so the client can stop reading without waiting on EOF. writeSSE(w, "done", map[string]bool{"done": true}) flusher.Flush() } // writeSSE emits one named SSE event with a JSON data payload. JSON-encoding the // data keeps token text (which may contain newlines) from breaking SSE framing. func writeSSE(w http.ResponseWriter, event string, data any) { payload, err := json.Marshal(data) if err != nil { return } _, _ = w.Write([]byte("event: " + event + "\ndata: ")) _, _ = w.Write(payload) _, _ = w.Write([]byte("\n\n")) } // surroundingParagraph returns the paragraph of contentText containing the // plain-text offset from. Tiptap flattens blocks with blank-line separators, so // paragraphs are bounded by "\n\n". When the offset is unknown (original wasn't // located, from == -1) it falls back to the latency-capped document so the tutor // still has context to work with. func surroundingParagraph(contentText string, from int) string { if from < 0 || from > len(contentText) { return strings.TrimSpace(llm.TruncateDoc(contentText)) } start := strings.LastIndex(contentText[:from], "\n\n") if start < 0 { start = 0 } else { start += 2 } end := len(contentText) if rel := strings.Index(contentText[from:], "\n\n"); rel >= 0 { end = from + rel } return strings.TrimSpace(contentText[start:end]) }