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:
159
web/src/components/Editor/AskPetal.tsx
Normal file
159
web/src/components/Editor/AskPetal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user