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:
@@ -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] `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)
|
- [x] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky (honey reserved for voice)
|
||||||
|
|
||||||
### Phase 4 — Ask Petal (conversational follow-up)
|
### Phase 4 — Ask Petal (conversational follow-up) ✅
|
||||||
- [ ] `POST /api/suggestions/:id/chat` SSE streaming; server-side context injection
|
- [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.
|
||||||
- [ ] AskPetal component, token-by-token render, no persistence
|
- [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.
|
||||||
- [ ] CJK font fallbacks on chat bubbles (spec Note #17)
|
- [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)
|
### Phase 5 — Voice consistency pass (Tier 1)
|
||||||
- [ ] `POST /api/docs/:id/voice`, whole-document, slow cadence / explicit action
|
- [ ] `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 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 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 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).**
|
||||||
|
|||||||
29
internal/llm/chat.go
Normal file
29
internal/llm/chat.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
126
internal/suggestions/chat.go
Normal file
126
internal/suggestions/chat.go
Normal file
@@ -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])
|
||||||
|
}
|
||||||
148
internal/suggestions/chat_test.go
Normal file
148
internal/suggestions/chat_test.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Post("/{id}/accept", h.accept)
|
r.Post("/{id}/accept", h.accept)
|
||||||
r.Post("/{id}/dismiss", h.dismiss)
|
r.Post("/{id}/dismiss", h.dismiss)
|
||||||
|
r.Post("/{id}/chat", h.chat)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,3 +78,64 @@ export const api = {
|
|||||||
dismissSuggestion: (id: string) =>
|
dismissSuggestion: (id: string) =>
|
||||||
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
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') }
|
||||||
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
|
|||||||
const [hover, setHover] = useState<HoverState | null>(null)
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
// Delays card close so the pointer can travel from highlight to card.
|
// Delays card close so the pointer can travel from highlight to card.
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
@@ -109,7 +112,11 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
|
|||||||
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
|
||||||
)
|
)
|
||||||
const top = elRect.bottom - wrapRect.top + 6
|
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],
|
[suggestions],
|
||||||
)
|
)
|
||||||
@@ -127,8 +134,16 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
|
|||||||
)
|
)
|
||||||
|
|
||||||
const scheduleClose = useCallback(() => {
|
const scheduleClose = useCallback(() => {
|
||||||
|
if (pinned) return // Ask Petal open — keep the card until an explicit close.
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
closeTimer.current = setTimeout(() => setHover(null), 160)
|
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
|
// 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()
|
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHover(null)
|
closeCard()
|
||||||
onAccept(s)
|
onAccept(s)
|
||||||
},
|
},
|
||||||
[editor, onAccept],
|
[editor, onAccept, closeCard],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDismiss = useCallback(
|
const handleDismiss = useCallback(
|
||||||
(s: Suggestion) => {
|
(s: Suggestion) => {
|
||||||
setHover(null)
|
closeCard()
|
||||||
onDismiss(s)
|
onDismiss(s)
|
||||||
},
|
},
|
||||||
[onDismiss],
|
[onDismiss, closeCard],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Toolbar editor={editor} />
|
<Toolbar editor={editor} />
|
||||||
@@ -185,6 +211,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
|
|||||||
onDismiss={handleDismiss}
|
onDismiss={handleDismiss}
|
||||||
onPointerEnter={keepOpen}
|
onPointerEnter={keepOpen}
|
||||||
onPointerLeave={scheduleClose}
|
onPointerLeave={scheduleClose}
|
||||||
|
onExpandChange={setPinned}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import type { Suggestion, SuggestionType } from '../../api/client'
|
import type { Suggestion, SuggestionType } from '../../api/client'
|
||||||
|
import { AskPetal } from './AskPetal'
|
||||||
|
|
||||||
// Per-type accent color + human label, mirroring the design tokens.
|
// Per-type accent color + human label, mirroring the design tokens.
|
||||||
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
const TYPE_META: Record<SuggestionType, { color: string; label: string }> = {
|
||||||
@@ -16,6 +18,9 @@ interface Props {
|
|||||||
onDismiss: (s: Suggestion) => void
|
onDismiss: (s: Suggestion) => void
|
||||||
onPointerEnter: () => void
|
onPointerEnter: () => void
|
||||||
onPointerLeave: () => 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,
|
// SuggestionCard is the hover panel for a single suggestion: a colored type tag,
|
||||||
@@ -29,9 +34,19 @@ export function SuggestionCard({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
|
onExpandChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const meta = TYPE_META[suggestion.type]
|
const meta = TYPE_META[suggestion.type]
|
||||||
const hasReplacement = suggestion.replacement.trim() !== ''
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
||||||
|
const [asking, setAsking] = useState(false)
|
||||||
|
|
||||||
|
function toggleAsking() {
|
||||||
|
setAsking((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
onExpandChange(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -39,8 +54,9 @@ export function SuggestionCard({
|
|||||||
aria-label={`${meta.label} suggestion`}
|
aria-label={`${meta.label} suggestion`}
|
||||||
onMouseEnter={onPointerEnter}
|
onMouseEnter={onPointerEnter}
|
||||||
onMouseLeave={onPointerLeave}
|
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={{
|
style={{
|
||||||
|
width: asking ? 340 : 300,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-card)',
|
borderRadius: 'var(--radius-card)',
|
||||||
@@ -70,6 +86,20 @@ export function SuggestionCard({
|
|||||||
{suggestion.explanation}
|
{suggestion.explanation}
|
||||||
</p>
|
</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">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
{hasReplacement && (
|
{hasReplacement && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ button, a, input {
|
|||||||
animation: petal-suggestion-in 200ms ease both;
|
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. */
|
/* Breathing rose dot shown in the StatusBar while a checkpoint runs. */
|
||||||
.petal-checkpoint-dot {
|
.petal-checkpoint-dot {
|
||||||
animation: petal-breathe 2s ease-in-out infinite;
|
animation: petal-breathe 2s ease-in-out infinite;
|
||||||
|
|||||||
Reference in New Issue
Block a user