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
149 lines
4.7 KiB
Go
149 lines
4.7 KiB
Go
package suggestions
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
|
)
|
|
|
|
// streamClient serves a canned checkpoint (so a suggestion can be seeded through
|
|
// the public /check route) and a fixed token stream for chat. It records the
|
|
// last streamed request so a test can assert on the injected system context.
|
|
type streamClient struct {
|
|
checkpoint string
|
|
tokens []string
|
|
lastStream llm.CompletionRequest
|
|
}
|
|
|
|
func (c *streamClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
|
|
return c.checkpoint, nil
|
|
}
|
|
|
|
func (c *streamClient) Stream(_ context.Context, req llm.CompletionRequest) (<-chan string, error) {
|
|
c.lastStream = req
|
|
ch := make(chan string)
|
|
go func() {
|
|
defer close(ch)
|
|
for _, t := range c.tokens {
|
|
ch <- t
|
|
}
|
|
}()
|
|
return ch, nil
|
|
}
|
|
|
|
func TestAskPetalChat(t *testing.T) {
|
|
client := &streamClient{
|
|
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
|
|
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
|
|
}
|
|
srv, docID := newTestServer(t, client)
|
|
|
|
// Seed one suggestion via the checkpoint route, then grab its id.
|
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
|
listed := do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "")
|
|
var sugs []db.Suggestion
|
|
if err := json.Unmarshal(listed.Body.Bytes(), &sugs); err != nil {
|
|
t.Fatalf("decode suggestions: %v", err)
|
|
}
|
|
if len(sugs) != 1 {
|
|
t.Fatalf("seed: want 1 suggestion, got %d", len(sugs))
|
|
}
|
|
sugID := sugs[0].ID
|
|
|
|
rec := do(t, srv, http.MethodPost, "/suggestions/"+sugID+"/chat",
|
|
`{"messages":[{"role":"user","content":"why is this wrong?"}]}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chat: code=%d body=%s", rec.Code, rec.Body)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
|
|
t.Fatalf("chat: content-type=%q, want text/event-stream", ct)
|
|
}
|
|
|
|
// The streamed tokens should arrive concatenated across data: events, and the
|
|
// stream should terminate with a done event.
|
|
body := rec.Body.String()
|
|
got := collectSSEText(body)
|
|
if want := "Because \"I\" takes \"have\"."; got != want {
|
|
t.Fatalf("streamed text = %q, want %q", got, want)
|
|
}
|
|
if !strings.Contains(body, "event: done") {
|
|
t.Fatalf("chat stream missing done event:\n%s", body)
|
|
}
|
|
|
|
// Context injection: the system prompt (first message) must carry the
|
|
// suggestion's original text and the surrounding paragraph — loaded
|
|
// server-side, never from the client.
|
|
msgs := client.lastStream.Messages
|
|
if len(msgs) < 2 || msgs[0].Role != "system" {
|
|
t.Fatalf("expected a leading system message, got %+v", msgs)
|
|
}
|
|
sys := msgs[0].Content
|
|
if !strings.Contains(sys, "I has") {
|
|
t.Fatalf("system prompt missing original text:\n%s", sys)
|
|
}
|
|
if !strings.Contains(sys, "I has two apple.") {
|
|
t.Fatalf("system prompt missing surrounding paragraph:\n%s", sys)
|
|
}
|
|
// The client's user message rides after the system turn.
|
|
if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "why is this wrong?" {
|
|
t.Fatalf("user message not forwarded: %+v", last)
|
|
}
|
|
|
|
// Chat sampling parameters from the spec.
|
|
if client.lastStream.MaxTokens != 512 || client.lastStream.Temperature != 0.7 {
|
|
t.Fatalf("unexpected chat params: %+v", client.lastStream)
|
|
}
|
|
}
|
|
|
|
func TestAskPetalChatNotFound(t *testing.T) {
|
|
client := &streamClient{checkpoint: `{"suggestions":[]}`}
|
|
srv, _ := newTestServer(t, client)
|
|
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
|
|
`{"messages":[{"role":"user","content":"hi"}]}`)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404 for unknown suggestion, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestSurroundingParagraph(t *testing.T) {
|
|
text := "First paragraph here.\n\nThe target sentence lives here.\n\nThird paragraph."
|
|
from := strings.Index(text, "target")
|
|
got := surroundingParagraph(text, from)
|
|
if got != "The target sentence lives here." {
|
|
t.Fatalf("paragraph = %q", got)
|
|
}
|
|
|
|
// Unknown offset falls back to the (trimmed) document.
|
|
if got := surroundingParagraph(text, -1); !strings.Contains(got, "First paragraph") {
|
|
t.Fatalf("fallback paragraph = %q", got)
|
|
}
|
|
}
|
|
|
|
// collectSSEText concatenates the JSON text fields from every `event: token`
|
|
// frame in an SSE body.
|
|
func collectSSEText(body string) string {
|
|
var b strings.Builder
|
|
for _, frame := range strings.Split(body, "\n\n") {
|
|
if !strings.Contains(frame, "event: token") {
|
|
continue
|
|
}
|
|
for _, line := range strings.Split(frame, "\n") {
|
|
data, ok := strings.CutPrefix(line, "data: ")
|
|
if !ok {
|
|
continue
|
|
}
|
|
var payload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
_ = json.Unmarshal([]byte(data), &payload)
|
|
b.WriteString(payload.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|