Phase 4: Ask Petal SSE chat

Conversational follow-up on a suggestion, streamed token-by-token.

Backend (interface-only; handlers never touch a concrete LLM client):
- internal/llm/chat.go: StreamAskPetal with conversational sampling
  (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop "\n\n\n"),
  reusing AskPetalSystemPrompt + TrimHistory.
- internal/suggestions/chat.go: POST /api/suggestions/:id/chat. One
  user-scoped join loads the suggestion + parent content_text;
  surroundingParagraph extracts the \n\n-bounded paragraph at from_pos
  (whole-doc fallback when unlocated) and injects it server-side.
  Streams event: token / event: done SSE frames with JSON-encoded data
  so token newlines can't break framing; real http.Flusher per chunk.
  LLM-unreachable -> 502 before SSE headers; unknown suggestion -> 404.

Frontend:
- streamSuggestionChat: fetch + ReadableStream SSE parser (not
  EventSource, needs POST), abortable.
- AskPetal.tsx: whole conversation in component state (no persistence,
  cleared on close), Petal's first bubble pre-seeded with the
  explanation, rose/lavender bubbles, CJK font stack on the bubbles
  only (Note #17), streaming caret.
- SuggestionCard "Ask Petal" pill pins the card open while chatting
  (hover-close suppressed, click-away closes) and widens it to 340px.

Tests: chat_test.go covers streamed-text concat + done event,
server-side context injection on the system message, sampling params,
404, and surroundingParagraph. go build/vet/test clean, tsc clean,
vite build OK. Live SSE smoke-tested against a fake streaming vLLM:
tokens flushed individually through the chi middleware stack, done
terminator, 502 on LLM-down, 404 on unknown suggestion.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 21:05:39 -07:00
parent 3f7e705028
commit 3c5f3ecb96
10 changed files with 599 additions and 10 deletions

View File

@@ -0,0 +1,126 @@
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])
}