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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user