From 3c5f3ecb96f92a54ae0b71bd8792a0afc477ff29 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:05:39 -0700 Subject: [PATCH] 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 --- BUILD_PLAN.md | 10 +- internal/llm/chat.go | 29 ++++ internal/suggestions/chat.go | 126 +++++++++++++++ internal/suggestions/chat_test.go | 148 +++++++++++++++++ internal/suggestions/handlers.go | 1 + web/src/api/client.ts | 61 +++++++ web/src/components/Editor/AskPetal.tsx | 159 +++++++++++++++++++ web/src/components/Editor/EditorCore.tsx | 37 ++++- web/src/components/Editor/SuggestionCard.tsx | 32 +++- web/src/index.css | 6 + 10 files changed, 599 insertions(+), 10 deletions(-) create mode 100644 internal/llm/chat.go create mode 100644 internal/suggestions/chat.go create mode 100644 internal/suggestions/chat_test.go create mode 100644 web/src/components/Editor/AskPetal.tsx diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index c76c9e0..3e9f04a 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -46,10 +46,11 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - [x] `SuggestionHighlight` (ProseMirror **decorations**, re-anchored by `original` string on every doc change — not stored marks) + `SuggestionCard` (accept applies replacement in-editor then PATCHes; dismiss) - [x] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice) -### Phase 4 — Ask Petal (conversational follow-up) -- [ ] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection -- [ ] AskPetal component, token-by-token render, no persistence -- [ ] CJK font fallbacks on chat bubbles (spec Note #17) +### Phase 4 — Ask Petal (conversational follow-up) ✅ +- [x] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection — `internal/suggestions/chat.go` loads the suggestion + parent doc in one user-scoped query, extracts the `\n\n`-bounded paragraph around `from_pos` (falls back to truncated doc when `from_pos == -1`), injects it via `AskPetalSystemPrompt`, streams `event: token`/`event: done` SSE frames (JSON-encoded data so token newlines don't break framing). LLM-unreachable returns a clean 502 before any SSE headers; unknown suggestion 404s. +- [x] AskPetal component, token-by-token render, no persistence — `AskPetal.tsx` holds the whole conversation in component state (cleared on close), pre-seeds Petal's first bubble with the suggestion explanation, streams via `streamSuggestionChat` (fetch + ReadableStream, not EventSource). `SuggestionCard` gains an "Ask Petal ✨" pill; the card pins open (hover-close suppressed, click-away to dismiss) while the panel is expanded. +- [x] CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the `'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'` stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body. +- `internal/llm/chat.go`: `StreamAskPetal` (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop `\n\n\n`) reusing the existing `AskPetalSystemPrompt` + `TrimHistory`. Backend stays interface-only; the SSE handler never touches a concrete client. ### Phase 5 — Voice consistency pass (Tier 1) - [ ] `POST /api/docs/:id/voice`, whole-document, slow cadence / explicit action @@ -75,3 +76,4 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone. - 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).** - 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).** +- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `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` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).** diff --git a/internal/llm/chat.go b/internal/llm/chat.go new file mode 100644 index 0000000..722c519 --- /dev/null +++ b/internal/llm/chat.go @@ -0,0 +1,29 @@ +package llm + +import "context" + +// AskPetalMessages assembles the message array for one Ask Petal turn: the +// suggestion-context system prompt followed by the (length-capped) client-side +// conversation history. The backend is stateless, so the full history rides on +// every request (spec: no server-side sessions). +func AskPetalMessages(systemPrompt string, history []Message) []Message { + msgs := make([]Message, 0, len(history)+1) + msgs = append(msgs, Message{Role: "system", Content: systemPrompt}) + msgs = append(msgs, TrimHistory(history)...) + return msgs +} + +// StreamAskPetal opens an SSE-friendly token stream for an Ask Petal reply using +// the conversational sampling parameters from the spec. The caller forwards the +// returned chunks to the browser; the channel closes when generation ends. +func StreamAskPetal(ctx context.Context, client LLMClient, systemPrompt string, history []Message) (<-chan string, error) { + return client.Stream(ctx, CompletionRequest{ + Messages: AskPetalMessages(systemPrompt, history), + MaxTokens: 512, + Temperature: 0.7, + RepetitionPenalty: 1.15, + TopP: 0.92, + Stop: []string{"\n\n\n"}, + Stream: true, + }) +} diff --git a/internal/suggestions/chat.go b/internal/suggestions/chat.go new file mode 100644 index 0000000..1f0c5ba --- /dev/null +++ b/internal/suggestions/chat.go @@ -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]) +} diff --git a/internal/suggestions/chat_test.go b/internal/suggestions/chat_test.go new file mode 100644 index 0000000..f04473d --- /dev/null +++ b/internal/suggestions/chat_test.go @@ -0,0 +1,148 @@ +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() +} diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index 0443fe8..4eb4a0d 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -47,6 +47,7 @@ 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 } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a8fdb80..9fb9a2c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -78,3 +78,64 @@ export const api = { dismissSuggestion: (id: string) => req(`/suggestions/${id}/dismiss`, { method: 'POST' }), } + +// One turn in an Ask Petal conversation. History lives only in the component — +// the server is stateless and re-injects the suggestion context every request. +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +// streamSuggestionChat POSTs the conversation to the SSE chat endpoint and +// invokes onToken for each text chunk as it arrives. It uses fetch + a +// ReadableStream reader (not EventSource, which can't POST) and resolves when +// the stream ends. Abort via the optional signal to cancel mid-response. +export async function streamSuggestionChat( + suggestionId: string, + messages: ChatMessage[], + onToken: (text: string) => void, + signal?: AbortSignal, +): Promise { + const res = await fetch(`/api/suggestions/${suggestionId}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages }), + signal, + }) + if (!res.ok || !res.body) { + const detail = await res.text().catch(() => '') + throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + // SSE events are separated by a blank line. Process every complete one and + // keep the trailing partial in the buffer. + let sep: number + while ((sep = buf.indexOf('\n\n')) >= 0) { + const event = parseSSE(buf.slice(0, sep)) + buf = buf.slice(sep + 2) + if (event.name === 'done') return + if (event.name === 'token' && event.data) { + const text = (JSON.parse(event.data) as { text: string }).text + if (text) onToken(text) + } + } + } +} + +// parseSSE pulls the event name and data payload out of one raw SSE frame. +function parseSSE(frame: string): { name: string; data: string } { + let name = 'message' + const dataLines: string[] = [] + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) name = line.slice(6).trim() + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()) + } + return { name, data: dataLines.join('\n') } +} diff --git a/web/src/components/Editor/AskPetal.tsx b/web/src/components/Editor/AskPetal.tsx new file mode 100644 index 0000000..5832192 --- /dev/null +++ b/web/src/components/Editor/AskPetal.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react' +import { streamSuggestionChat, type ChatMessage } from '../../api/client' + +interface Props { + suggestionId: string + // Pre-populates Petal's first bubble so the conversation opens with context. + explanation: string +} + +// CJK fallback stack — Nunito has no Chinese glyphs, and the user asks questions +// in Mandarin (spec Note #17). Applied to the bubbles specifically, not the +// serif editor body. +const CHAT_FONT = "'Nunito', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif" + +// AskPetal is the mini chat panel inside an expanded SuggestionCard. The whole +// conversation lives in this component's state — nothing is persisted; closing +// the card (unmounting) clears it. Each send streams Petal's reply token-by- +// token into the latest assistant bubble. +export function AskPetal({ suggestionId, explanation }: Props) { + const [messages, setMessages] = useState([ + { role: 'assistant', content: explanation }, + ]) + const [input, setInput] = useState('') + const [streaming, setStreaming] = useState(false) + const scrollRef = useRef(null) + const inputRef = useRef(null) + + // Keep the latest bubble in view as tokens arrive. + useEffect(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, [messages]) + + // Focus the input when the panel opens. + useEffect(() => { + inputRef.current?.focus() + }, []) + + async function send() { + const text = input.trim() + if (!text || streaming) return + setInput('') + + // Append the user turn plus an empty assistant bubble to stream into. + const history: ChatMessage[] = [...messages, { role: 'user', content: text }] + setMessages([...history, { role: 'assistant', content: '' }]) + setStreaming(true) + + try { + await streamSuggestionChat(suggestionId, history, (token) => { + setMessages((prev) => { + const next = prev.slice() + const last = next[next.length - 1] + next[next.length - 1] = { ...last, content: last.content + token } + return next + }) + }) + } catch (err) { + setMessages((prev) => { + const next = prev.slice() + next[next.length - 1] = { + role: 'assistant', + content: 'Sorry, I had trouble responding just now. Please try again. 🌸', + } + return next + }) + console.error('Ask Petal chat failed:', err) + } finally { + setStreaming(false) + inputRef.current?.focus() + } + } + + return ( +
+
+ {messages.map((m, i) => ( + + ))} +
+ +
{ + e.preventDefault() + void send() + }} + > + setInput(e.target.value)} + placeholder="Ask why… / 问为什么…" + className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none" + style={{ + background: 'var(--color-surface-alt)', + border: '1px solid var(--color-border)', + color: 'var(--color-plum)', + fontFamily: CHAT_FONT, + }} + /> + +
+
+ ) +} + +// Bubble renders one chat turn: Petal rose-tinted and left-aligned, the user +// lavender and right-aligned. A trailing caret marks the actively streaming +// reply until its first token lands. +function Bubble({ + role, + content, + streaming, +}: { + role: ChatMessage['role'] + content: string + streaming: boolean +}) { + const isPetal = role === 'assistant' + return ( +
+
+ {content} + {streaming && content === '' && ( + + ▍ + + )} +
+
+ ) +} diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index acd9c3c..2ccdedd 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -55,6 +55,9 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc const [hover, setHover] = useState(null) // Delays card close so the pointer can travel from highlight to card. const closeTimer = useRef>(undefined) + // While the Ask Petal panel is expanded the card is pinned: the hover-close + // timer is suppressed so chatting doesn't dismiss it. A click outside closes. + const [pinned, setPinned] = useState(false) const editor = useEditor({ extensions: [ @@ -109,7 +112,11 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth), ) const top = elRect.bottom - wrapRect.top + 6 - setHover({ suggestion, top, left }) + setHover((prev) => { + // Moving to a different highlight resets any Ask Petal pin. + if (prev && prev.suggestion.id !== suggestion.id) setPinned(false) + return { suggestion, top, left } + }) }, [suggestions], ) @@ -127,8 +134,16 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc ) const scheduleClose = useCallback(() => { + if (pinned) return // Ask Petal open — keep the card until an explicit close. clearTimeout(closeTimer.current) closeTimer.current = setTimeout(() => setHover(null), 160) + }, [pinned]) + + // Fully close the card and drop any pin (used on accept/dismiss/click-away). + const closeCard = useCallback(() => { + clearTimeout(closeTimer.current) + setPinned(false) + setHover(null) }, []) // Leaving a highlight schedules a close; entering the card cancels it, so the @@ -151,22 +166,33 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc editor.chain().focus().insertContentAt(range, s.replacement).run() } } - setHover(null) + closeCard() onAccept(s) }, - [editor, onAccept], + [editor, onAccept, closeCard], ) const handleDismiss = useCallback( (s: Suggestion) => { - setHover(null) + closeCard() onDismiss(s) }, - [onDismiss], + [onDismiss, closeCard], ) useEffect(() => () => clearTimeout(closeTimer.current), []) + // While pinned (Ask Petal open), a pointer-down outside the card closes it — + // the only way to dismiss a pinned card without accept/dismiss. + useEffect(() => { + if (!pinned) return + const onDown = (e: MouseEvent) => { + if (!(e.target as HTMLElement).closest('.petal-suggestion-card')) closeCard() + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [pinned, closeCard]) + return (
@@ -185,6 +211,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc onDismiss={handleDismiss} onPointerEnter={keepOpen} onPointerLeave={scheduleClose} + onExpandChange={setPinned} /> )}
diff --git a/web/src/components/Editor/SuggestionCard.tsx b/web/src/components/Editor/SuggestionCard.tsx index 381c894..c9646dd 100644 --- a/web/src/components/Editor/SuggestionCard.tsx +++ b/web/src/components/Editor/SuggestionCard.tsx @@ -1,4 +1,6 @@ +import { useState } from 'react' import type { Suggestion, SuggestionType } from '../../api/client' +import { AskPetal } from './AskPetal' // Per-type accent color + human label, mirroring the design tokens. const TYPE_META: Record = { @@ -16,6 +18,9 @@ interface Props { onDismiss: (s: Suggestion) => void onPointerEnter: () => void onPointerLeave: () => void + // Pins the card open while the Ask Petal panel is expanded, so the chat isn't + // dismissed by the hover-close timer when the pointer drifts away. + onExpandChange: (expanded: boolean) => void } // SuggestionCard is the hover panel for a single suggestion: a colored type tag, @@ -29,9 +34,19 @@ export function SuggestionCard({ onDismiss, onPointerEnter, onPointerLeave, + onExpandChange, }: Props) { const meta = TYPE_META[suggestion.type] const hasReplacement = suggestion.replacement.trim() !== '' + const [asking, setAsking] = useState(false) + + function toggleAsking() { + setAsking((prev) => { + const next = !prev + onExpandChange(next) + return next + }) + } return (
+ + + {asking && } +
{hasReplacement && (