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

@@ -78,3 +78,64 @@ export const api = {
dismissSuggestion: (id: string) =>
req<void>(`/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<void> {
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') }
}

View File

@@ -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<ChatMessage[]>([
{ role: 'assistant', content: explanation },
])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div
className="mt-3 flex flex-col"
style={{
borderTop: '1px solid var(--color-border)',
paddingTop: '0.625rem',
fontFamily: CHAT_FONT,
}}
>
<div
ref={scrollRef}
className="flex flex-col gap-2 overflow-y-auto pr-1"
style={{ maxHeight: 220 }}
>
{messages.map((m, i) => (
<Bubble key={i} role={m.role} content={m.content} streaming={streaming && i === messages.length - 1} />
))}
</div>
<form
className="mt-2 flex items-center gap-1.5"
onSubmit={(e) => {
e.preventDefault()
void send()
}}
>
<input
ref={inputRef}
value={input}
onChange={(e) => 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,
}}
/>
<button
type="submit"
disabled={streaming || input.trim() === ''}
className="rounded-full px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
style={{ background: 'var(--color-accent)' }}
>
Send
</button>
</form>
</div>
)
}
// 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 (
<div className={`flex ${isPetal ? 'justify-start' : 'justify-end'}`}>
<div
className="max-w-[85%] rounded-2xl px-3 py-1.5 text-xs leading-snug"
style={{
background: isPetal ? 'var(--color-surface-alt)' : 'var(--color-lavender)',
color: 'var(--color-plum)',
fontFamily: CHAT_FONT,
whiteSpace: 'pre-wrap',
}}
>
{content}
{streaming && content === '' && (
<span className="petal-chat-caret" aria-hidden>
</span>
)}
</div>
</div>
)
}

View File

@@ -55,6 +55,9 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
const [hover, setHover] = useState<HoverState | null>(null)
// Delays card close so the pointer can travel from highlight to card.
const closeTimer = useRef<ReturnType<typeof setTimeout>>(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 (
<div className="flex flex-1 flex-col">
<Toolbar editor={editor} />
@@ -185,6 +211,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
onDismiss={handleDismiss}
onPointerEnter={keepOpen}
onPointerLeave={scheduleClose}
onExpandChange={setPinned}
/>
)}
</div>

View File

@@ -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<SuggestionType, { color: string; label: string }> = {
@@ -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 (
<div
@@ -39,8 +54,9 @@ export function SuggestionCard({
aria-label={`${meta.label} suggestion`}
onMouseEnter={onPointerEnter}
onMouseLeave={onPointerLeave}
className="petal-suggestion-card absolute z-20 w-[300px] p-3.5 text-sm"
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
style={{
width: asking ? 340 : 300,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-card)',
@@ -70,6 +86,20 @@ export function SuggestionCard({
{suggestion.explanation}
</p>
<button
type="button"
onClick={toggleAsking}
className="mt-2 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
style={{
background: asking ? 'var(--color-surface-alt)' : 'transparent',
color: 'var(--color-accent-hover)',
}}
>
{asking ? 'Hide Petal' : 'Ask Petal ✨'}
</button>
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
<div className="mt-3 flex items-center gap-2">
{hasReplacement && (
<button

View File

@@ -136,6 +136,12 @@ button, a, input {
animation: petal-suggestion-in 200ms ease both;
}
/* Blinking caret in the Ask Petal bubble while awaiting the first token. */
.petal-chat-caret {
animation: petal-breathe 1s ease-in-out infinite;
color: var(--color-accent);
}
/* Breathing rose dot shown in the StatusBar while a checkpoint runs. */
.petal-checkpoint-dot {
animation: petal-breathe 2s ease-in-out infinite;