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
248 lines
7.2 KiB
Go
248 lines
7.2 KiB
Go
// Package suggestions implements the grammar-checkpoint endpoint and the
|
|
// accept/dismiss surface for LLM-proposed edits. Checkpoints run a single LLM
|
|
// pass over a document and persist the resulting pending suggestions; the
|
|
// frontend re-anchors each one by its `original` string at render time, so the
|
|
// stored positions are advisory only (spec Note #6).
|
|
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"
|
|
)
|
|
|
|
// Handler holds the dependencies for the checkpoint + suggestion routes.
|
|
type Handler struct {
|
|
DB *db.DB
|
|
Client llm.LLMClient
|
|
Limit *llm.RateLimiter
|
|
}
|
|
|
|
// New constructs a Handler with a per-document checkpoint rate limiter.
|
|
func New(database *db.DB, client llm.LLMClient) *Handler {
|
|
return &Handler{
|
|
DB: database,
|
|
Client: client,
|
|
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
|
}
|
|
}
|
|
|
|
// RegisterDocRoutes adds the document-scoped routes (check + list) onto the
|
|
// existing /api/docs router so they share its base path.
|
|
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
|
r.Post("/{id}/check", h.check)
|
|
r.Get("/{id}/suggestions", h.listForDoc)
|
|
}
|
|
|
|
// Routes returns the router mounted at /api/suggestions for per-suggestion
|
|
// actions.
|
|
func (h *Handler) Routes() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Post("/{id}/accept", h.accept)
|
|
r.Post("/{id}/dismiss", h.dismiss)
|
|
r.Post("/{id}/chat", h.chat)
|
|
return r
|
|
}
|
|
|
|
// check runs a grammar checkpoint over the document and returns the fresh
|
|
// pending suggestions. It is rate-limited per document (429 when too soon).
|
|
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
|
docID := chi.URLParam(r, "id")
|
|
|
|
var contentText string
|
|
err := h.DB.QueryRow(
|
|
`SELECT content_text FROM documents WHERE id = ? AND user_id = ?`,
|
|
docID, db.LocalUserID,
|
|
).Scan(&contentText)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
errorJSON(w, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
|
|
// Nothing to check on an empty document — skip the LLM round-trip.
|
|
if strings.TrimSpace(contentText) == "" {
|
|
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
|
return
|
|
}
|
|
|
|
if ok, _ := h.Limit.Allow(docID); !ok {
|
|
// Throttled: return the existing pending set unchanged rather than an
|
|
// error, so the frontend keeps showing current suggestions.
|
|
existing, err := h.fetchPending(docID)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, existing)
|
|
return
|
|
}
|
|
|
|
raw, err := llm.RunCheckpoint(r.Context(), h.Client, contentText)
|
|
if err != nil {
|
|
errorJSON(w, http.StatusBadGateway, "checkpoint failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
saved, err := h.replacePending(docID, contentText, raw)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, saved)
|
|
}
|
|
|
|
// replacePending swaps a document's pending suggestions for a fresh batch in one
|
|
// transaction. Accepted/rejected suggestions are left untouched (history).
|
|
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion) ([]db.Suggestion, error) {
|
|
tx, err := h.DB.Begin()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.Exec(
|
|
`DELETE FROM suggestions WHERE doc_id = ? AND status = ?`,
|
|
docID, db.SuggestionStatusPending,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := []db.Suggestion{}
|
|
for _, s := range raw {
|
|
typ := normalizeType(s.Type)
|
|
from, to := locate(contentText, s.Original)
|
|
var saved db.Suggestion
|
|
err := tx.QueryRow(
|
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at`,
|
|
docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
|
|
).Scan(
|
|
&saved.ID, &saved.DocID, &saved.FromPos, &saved.ToPos, &saved.Original,
|
|
&saved.Replacement, &saved.Explanation, &saved.Type, &saved.Status, &saved.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, saved)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// listForDoc returns the document's current pending suggestions (used when the
|
|
// editor loads a document, before any new checkpoint fires).
|
|
func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) {
|
|
out, err := h.fetchPending(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) {
|
|
rows, err := h.DB.Query(
|
|
`SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at
|
|
FROM suggestions
|
|
WHERE doc_id = ? AND status = ?
|
|
ORDER BY from_pos ASC, created_at ASC`,
|
|
docID, db.SuggestionStatusPending,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []db.Suggestion{}
|
|
for rows.Next() {
|
|
var s db.Suggestion
|
|
if err := rows.Scan(
|
|
&s.ID, &s.DocID, &s.FromPos, &s.ToPos, &s.Original, &s.Replacement,
|
|
&s.Explanation, &s.Type, &s.Status, &s.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// accept marks a suggestion accepted (the client applies the replacement text).
|
|
func (h *Handler) accept(w http.ResponseWriter, r *http.Request) {
|
|
h.setStatus(w, r, db.SuggestionStatusAccepted)
|
|
}
|
|
|
|
// dismiss marks a suggestion rejected.
|
|
func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) {
|
|
h.setStatus(w, r, db.SuggestionStatusRejected)
|
|
}
|
|
|
|
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
|
res, err := h.DB.Exec(
|
|
`UPDATE suggestions SET status = ? WHERE id = ? AND status = ?`,
|
|
status, chi.URLParam(r, "id"), db.SuggestionStatusPending,
|
|
)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
errorJSON(w, http.StatusNotFound, "pending suggestion not found")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// locate finds the plaintext offsets of original within contentText. Returns
|
|
// (-1, -1) when not found; the frontend anchors by string regardless, so a miss
|
|
// here is non-fatal.
|
|
func locate(contentText, original string) (int, int) {
|
|
idx := strings.Index(contentText, original)
|
|
if idx < 0 {
|
|
return -1, -1
|
|
}
|
|
return idx, idx + len(original)
|
|
}
|
|
|
|
// normalizeType maps the model's type string onto a valid suggestion type,
|
|
// defaulting unknown values to grammar so a stray label never trips the CHECK.
|
|
func normalizeType(t string) string {
|
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
|
case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity:
|
|
return strings.ToLower(strings.TrimSpace(t))
|
|
default:
|
|
return db.SuggestionTypeGrammar
|
|
}
|
|
}
|
|
|
|
// --- response helpers -------------------------------------------------------
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func errorJSON(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func serverError(w http.ResponseWriter, err error) {
|
|
errorJSON(w, http.StatusInternalServerError, err.Error())
|
|
}
|