From a4069d5755da6dad129ae244f9fc65825e30912f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:45:30 -0700 Subject: [PATCH] Phase 3: LLM grammar checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (internal/llm): backend-agnostic LLMClient interface + factory with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete + Stream. prompts.go holds the checkpoint and Ask Petal templates; checkpoint.go salvages JSON from model output (brace-matched), enforces a per-doc 30s RateLimiter, and truncates the doc to a latency cap. internal/suggestions: POST /api/docs/:id/check runs a checkpoint and replaces the doc's pending suggestions in one tx (accepted/rejected kept as history); GET /api/docs/:id/suggestions lists pending; POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks return the current set rather than erroring. Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard tokens); SuggestionHighlight renders ProseMirror decorations re-anchored by the `original` string on every doc change (not stored marks), with precise textblock-offset→PM-position mapping; SuggestionCard shows the type tag + diff + explanation and applies the replacement in-editor on accept; breathing rose checkpoint dot in the StatusBar; fade-float + breathe animations. Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit over httptest with a stub client. Smoke-tested end-to-end against a fake vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd --- BUILD_PLAN.md | 17 +- cmd/server/main.go | 14 +- internal/llm/checkpoint.go | 143 ++++++++++ internal/llm/checkpoint_test.go | 93 +++++++ internal/llm/client.go | 110 ++++++++ internal/llm/ollama.go | 124 +++++++++ internal/llm/prompts.go | 63 +++++ internal/llm/vllm.go | 156 +++++++++++ internal/suggestions/handlers.go | 246 ++++++++++++++++++ internal/suggestions/handlers_test.go | 142 ++++++++++ web/src/App.tsx | 43 ++- web/src/api/client.ts | 29 +++ web/src/components/Editor/EditorCore.tsx | 127 ++++++++- web/src/components/Editor/SuggestionCard.tsx | 97 +++++++ .../components/Editor/SuggestionHighlight.ts | 121 +++++++++ web/src/components/StatusBar/StatusBar.tsx | 21 +- web/src/hooks/useCheckpoint.ts | 75 ++++++ web/src/index.css | 38 +++ 18 files changed, 1640 insertions(+), 19 deletions(-) create mode 100644 internal/llm/checkpoint.go create mode 100644 internal/llm/checkpoint_test.go create mode 100644 internal/llm/client.go create mode 100644 internal/llm/ollama.go create mode 100644 internal/llm/prompts.go create mode 100644 internal/llm/vllm.go create mode 100644 internal/suggestions/handlers.go create mode 100644 internal/suggestions/handlers_test.go create mode 100644 web/src/components/Editor/SuggestionCard.tsx create mode 100644 web/src/components/Editor/SuggestionHighlight.ts create mode 100644 web/src/hooks/useCheckpoint.ts diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index c14ee4f..41d1a82 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -36,14 +36,14 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - [x] StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s) - [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together -### Phase 3 — LLM grammar checkpoint -- [ ] `LLMClient` interface + factory (`internal/llm/client.go`) -- [ ] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface -- [ ] `checkpoint.go` (30s/doc rate limit), `prompts.go` -- [ ] `POST /api/docs/:id/check` -- [ ] `useCheckpoint` (4s debounce) + checkpoint indicator -- [ ] SuggestionMark + SuggestionCard (accept/dismiss); **string-anchoring**, not stored pos -- [ ] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky +### Phase 3 — LLM grammar checkpoint ✅ +- [x] `LLMClient` interface + factory (`internal/llm/client.go`) — chat-model fallback in factory; doc/history truncation helpers +- [x] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface; Complete + Stream; no client-level timeout (ctx deadline for Complete, open stream for SSE) +- [x] `checkpoint.go` (30s/doc `RateLimiter`), `prompts.go` — brace-matched JSON salvage from model output, empty-original drop, type normalization +- [x] `POST /api/docs/:id/check` (+ `GET /api/docs/:id/suggestions`, `POST /api/suggestions/:id/{accept,dismiss}`) in `internal/suggestions`; replaces pending set per check, leaves accepted/rejected as history; throttled checks return current set +- [x] `useCheckpoint` (4s debounce) + breathing rose checkpoint dot in StatusBar +- [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 @@ -73,3 +73,4 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user. - 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).** diff --git a/cmd/server/main.go b/cmd/server/main.go index 7aae83d..9d8cc11 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,8 @@ import ( "gitea.parodia.dev/drwily/petal/internal/config" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/docs" + "gitea.parodia.dev/drwily/petal/internal/llm" + "gitea.parodia.dev/drwily/petal/internal/suggestions" "gitea.parodia.dev/drwily/petal/web" ) @@ -38,7 +40,17 @@ func main() { _, _ = w.Write([]byte(`{"status":"ok"}`)) }) - api.Mount("/docs", docs.New(database).Routes()) + llmClient := llm.NewLLMClient(cfg) + sug := suggestions.New(database, llmClient) + + // Document CRUD plus the doc-scoped checkpoint/list suggestion routes, + // both under /api/docs. + docsRouter := docs.New(database).Routes() + sug.RegisterDocRoutes(docsRouter) + api.Mount("/docs", docsRouter) + + // Per-suggestion actions (accept/dismiss) under /api/suggestions. + api.Mount("/suggestions", sug.Routes()) }) // Everything else: serve the embedded SPA (with index.html fallback for client routing). diff --git a/internal/llm/checkpoint.go b/internal/llm/checkpoint.go new file mode 100644 index 0000000..061eb95 --- /dev/null +++ b/internal/llm/checkpoint.go @@ -0,0 +1,143 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" +) + +// CheckpointInterval is the minimum time between grammar checkpoints for a +// single document. The frontend debounces at 4s; this is the server-side floor +// that protects the inference endpoint from rapid repeat checks. +const CheckpointInterval = 30 * time.Second + +// RawSuggestion is one item as the model emits it. Positions are resolved +// server-side from Original; the frontend re-anchors by string at render time. +type RawSuggestion struct { + Original string `json:"original"` + Replacement string `json:"replacement"` + Explanation string `json:"explanation"` + Type string `json:"type"` +} + +// checkpointResponse is the top-level JSON shape the checkpoint prompt requests. +type checkpointResponse struct { + Suggestions []RawSuggestion `json:"suggestions"` +} + +// RunCheckpoint sends the grammar checkpoint and parses the JSON result. It +// applies the latency-guard truncation and the checkpoint sampling parameters +// from the spec. +func RunCheckpoint(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) { + raw, err := client.Complete(ctx, CompletionRequest{ + Messages: CheckpointMessages(TruncateDoc(contentText)), + MaxTokens: 1024, + Temperature: 0.3, + RepetitionPenalty: 1.15, + TopP: 0.9, + Stop: []string{"```", "\n\n\n\n"}, + }) + if err != nil { + return nil, err + } + return ParseCheckpoint(raw) +} + +// ParseCheckpoint extracts the suggestions array from a model response. Smaller +// models sometimes wrap JSON in prose or markdown fences despite instructions, +// so we salvage the outermost {...} object before decoding. +func ParseCheckpoint(raw string) ([]RawSuggestion, error) { + jsonText := extractJSONObject(raw) + if jsonText == "" { + return nil, fmt.Errorf("checkpoint: no JSON object in model output: %q", truncateForError(raw)) + } + var parsed checkpointResponse + if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { + return nil, fmt.Errorf("checkpoint: parse JSON: %w (got %q)", err, truncateForError(jsonText)) + } + // Drop items the model returned with an empty original — they can't be + // anchored — and normalize whitespace the model may have echoed. + out := parsed.Suggestions[:0] + for _, s := range parsed.Suggestions { + s.Original = strings.TrimSpace(s.Original) + s.Replacement = strings.TrimSpace(s.Replacement) + if s.Original == "" { + continue + } + out = append(out, s) + } + return out, nil +} + +// extractJSONObject returns the substring from the first '{' to its matching +// closing '}', or "" if none. Tolerates fences/preamble around the object. +func extractJSONObject(s string) string { + start := strings.IndexByte(s, '{') + if start < 0 { + return "" + } + depth := 0 + inString := false + escaped := false + for i := start; i < len(s); i++ { + c := s[i] + switch { + case escaped: + escaped = false + case c == '\\' && inString: + escaped = true + case c == '"': + inString = !inString + case inString: + // ignore braces inside strings + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + return s[start : i+1] + } + } + } + return "" +} + +func truncateForError(s string) string { + const max = 200 + if len(s) > max { + return s[:max] + "…" + } + return s +} + +// RateLimiter enforces a minimum interval between checkpoints per document. +// Concurrency-safe; one instance is shared across requests. +type RateLimiter struct { + mu sync.Mutex + interval time.Duration + last map[string]time.Time +} + +// NewRateLimiter constructs a limiter with the given per-document minimum gap. +func NewRateLimiter(interval time.Duration) *RateLimiter { + return &RateLimiter{interval: interval, last: make(map[string]time.Time)} +} + +// Allow reports whether a checkpoint may run for docID now. When allowed it +// records the time and returns (true, 0); when throttled it returns +// (false, retryAfter) where retryAfter is the wait until the next allowed run. +func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) { + rl.mu.Lock() + defer rl.mu.Unlock() + now := time.Now() + if last, ok := rl.last[docID]; ok { + if elapsed := now.Sub(last); elapsed < rl.interval { + return false, rl.interval - elapsed + } + } + rl.last[docID] = now + return true, 0 +} diff --git a/internal/llm/checkpoint_test.go b/internal/llm/checkpoint_test.go new file mode 100644 index 0000000..e82283e --- /dev/null +++ b/internal/llm/checkpoint_test.go @@ -0,0 +1,93 @@ +package llm + +import ( + "testing" + "time" +) + +func TestParseCheckpoint(t *testing.T) { + tests := []struct { + name string + raw string + want int + wantErr bool + }{ + { + name: "clean json", + raw: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`, + want: 1, + }, + { + name: "wrapped in markdown fence", + raw: "Here you go:\n```json\n{\"suggestions\":[{\"original\":\"a apple\",\"replacement\":\"an apple\",\"explanation\":\"use an before a vowel\",\"type\":\"grammar\"}]}\n```", + want: 1, + }, + { + name: "empty suggestions", + raw: `{"suggestions":[]}`, + want: 0, + }, + { + name: "drops items with empty original", + raw: `{"suggestions":[{"original":"","replacement":"x","explanation":"e","type":"grammar"},{"original":"teh","replacement":"the","explanation":"typo","type":"grammar"}]}`, + want: 1, + }, + { + name: "no json at all", + raw: "I could not find any issues!", + wantErr: true, + }, + { + name: "braces inside string values", + raw: `{"suggestions":[{"original":"use {x}","replacement":"use x","explanation":"drop the braces","type":"clarity"}]}`, + want: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseCheckpoint(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != tt.want { + t.Fatalf("got %d suggestions, want %d", len(got), tt.want) + } + }) + } +} + +func TestRateLimiter(t *testing.T) { + rl := NewRateLimiter(30 * time.Second) + + if ok, _ := rl.Allow("doc1"); !ok { + t.Fatal("first call should be allowed") + } + if ok, retry := rl.Allow("doc1"); ok || retry <= 0 { + t.Fatalf("immediate second call should be throttled, got ok=%v retry=%v", ok, retry) + } + // A different document is independent. + if ok, _ := rl.Allow("doc2"); !ok { + t.Fatal("different doc should be allowed") + } +} + +func TestTruncateDoc(t *testing.T) { + short := "hello" + if got := TruncateDoc(short); got != short { + t.Fatalf("short doc should be unchanged") + } + long := make([]byte, maxDocChars+500) + for i := range long { + long[i] = 'a' + } + got := TruncateDoc(string(long)) + if len(got) != maxDocChars { + t.Fatalf("truncated length = %d, want %d", len(got), maxDocChars) + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..5d73d47 --- /dev/null +++ b/internal/llm/client.go @@ -0,0 +1,110 @@ +// Package llm wraps the local inference endpoint behind a backend-agnostic +// interface. Two concrete backends — vLLM (OpenAI-compatible) and Ollama +// (native) — implement LLMClient; the rest of the app (the grammar checkpoint, +// Ask Petal) calls the interface only and never knows which one is active. +package llm + +import ( + "context" + "time" + + "gitea.parodia.dev/drwily/petal/internal/config" +) + +// Message is one chat turn sent to the model. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// CompletionRequest is the backend-neutral request shape. Each backend maps +// these fields onto its own wire format (see the parameter cheatsheet in the +// spec). Zero-valued sampling fields fall back to backend defaults. +type CompletionRequest struct { + Messages []Message + MaxTokens int + Temperature float64 + RepetitionPenalty float64 + TopP float64 + Stop []string + Stream bool +} + +// LLMClient is the single surface handler code depends on. +type LLMClient interface { + // Complete returns the full response in one shot (used for the checkpoint + // JSON, which we want whole before parsing). + Complete(ctx context.Context, req CompletionRequest) (string, error) + // Stream returns a channel of text chunks (used for Ask Petal SSE). The + // channel closes when generation ends; on a mid-stream error it closes + // early and the error is reported via the returned error of a future call. + Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) +} + +// Truncation caps. These guard checkpoint latency (prefill time scales with +// input length), not the model window — Qwen 3.5 ships 256K, far larger. +const ( + // maxDocChars ~ 10,000 tokens at ~4 chars/token. Grammar checkpoint only; + // the voice pass sends the whole document uncut. + maxDocChars = 40000 + // maxHistoryMsgs caps the rolling Ask Petal history (5 turns); oldest pairs + // drop first. + maxHistoryMsgs = 10 +) + +// TruncateDoc keeps the recent end of a document — the user is actively writing +// there — when it exceeds the grammar-checkpoint latency cap. Most documents +// fit uncut. The voice pass deliberately does NOT call this. +func TruncateDoc(contentText string) string { + if len(contentText) > maxDocChars { + return contentText[len(contentText)-maxDocChars:] + } + return contentText +} + +// TrimHistory keeps the most recent maxHistoryMsgs messages, dropping the +// oldest first so a long Ask Petal chat stays within budget. +func TrimHistory(msgs []Message) []Message { + if len(msgs) > maxHistoryMsgs { + return msgs[len(msgs)-maxHistoryMsgs:] + } + return msgs +} + +// NewLLMClient selects a backend from config. LLM_CHAT_MODEL falls back to +// LLM_MODEL here, in the factory, so handlers never deal with the fallback. +func NewLLMClient(cfg *config.Config) LLMClient { + chatModel := cfg.LLMChatModel + if chatModel == "" { + chatModel = cfg.LLMModel + } + base := backend{ + endpoint: cfg.LLMEndpoint, + checkpointModel: cfg.LLMModel, + chatModel: chatModel, + timeout: cfg.LLMTimeout, + } + switch cfg.LLMBackend { + case "ollama": + return &OllamaClient{base} + default: // "vllm" + return &VLLMClient{base} + } +} + +// backend holds the fields shared by both concrete clients. +type backend struct { + endpoint string + checkpointModel string // LLM_MODEL — small/fast checkpoint model + chatModel string // LLM_CHAT_MODEL (or LLM_MODEL) — Ask Petal model + timeout time.Duration +} + +// model picks the right model id for a request. Streaming requests are Ask +// Petal (chat); one-shot Complete calls are the grammar checkpoint. +func (b backend) model(req CompletionRequest) string { + if req.Stream { + return b.chatModel + } + return b.checkpointModel +} diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go new file mode 100644 index 0000000..581ef74 --- /dev/null +++ b/internal/llm/ollama.go @@ -0,0 +1,124 @@ +package llm + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// OllamaClient talks to Ollama's native /api/chat endpoint. Selected when +// LLM_BACKEND=ollama. +type OllamaClient struct { + backend +} + +// ollamaRequest mirrors Ollama's /api/chat body. Sampling parameters live under +// "options" (see the cheatsheet in the spec). +type ollamaRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` + Options ollamaOptions `json:"options"` +} + +type ollamaOptions struct { + NumPredict int `json:"num_predict"` + Temperature float64 `json:"temperature"` + RepeatPenalty float64 `json:"repeat_penalty"` + TopP float64 `json:"top_p"` + Stop []string `json:"stop,omitempty"` +} + +func (c *OllamaClient) body(req CompletionRequest) ollamaRequest { + return ollamaRequest{ + Model: c.model(req), + Messages: req.Messages, + Stream: req.Stream, + Options: ollamaOptions{ + NumPredict: req.MaxTokens, + Temperature: req.Temperature, + RepeatPenalty: req.RepetitionPenalty, + TopP: req.TopP, + Stop: req.Stop, + }, + } +} + +// Complete sends a non-streaming request and returns the full message content. +func (c *OllamaClient) Complete(ctx context.Context, req CompletionRequest) (string, error) { + req.Stream = false + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + resp, err := c.post(ctx, c.body(req)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", httpError("ollama", resp) + } + + var out struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("ollama: decode response: %w", err) + } + return out.Message.Content, nil +} + +// Stream sends a streaming request. Ollama emits newline-delimited JSON objects; +// we forward each message.content and stop when done:true. +func (c *OllamaClient) Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) { + req.Stream = true + resp, err := c.post(ctx, c.body(req)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + return nil, httpError("ollama", resp) + } + + ch := make(chan string) + go func() { + defer close(ch) + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var chunk struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + Done bool `json:"done"` + } + if err := json.Unmarshal(line, &chunk); err != nil { + continue + } + if chunk.Message.Content != "" { + select { + case ch <- chunk.Message.Content: + case <-ctx.Done(): + return + } + } + if chunk.Done { + return + } + } + }() + return ch, nil +} + +func (c *OllamaClient) post(ctx context.Context, body any) (*http.Response, error) { + return postJSON(ctx, c.endpoint+"/api/chat", body) +} diff --git a/internal/llm/prompts.go b/internal/llm/prompts.go new file mode 100644 index 0000000..72867c7 --- /dev/null +++ b/internal/llm/prompts.go @@ -0,0 +1,63 @@ +package llm + +import "fmt" + +// checkpointSystemPrompt is the grammar-checkpoint instruction. It asks for +// strict JSON (no fences, no preamble) so Complete's output parses directly. +const checkpointSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` + + `Analyze the text below and identify up to 5 issues: grammar errors, unnatural phrasing, ` + + `incorrect idiom usage, or unclear sentences that are common ESL patterns. + +Be specific, friendly, and explain WHY each suggestion improves the writing. + +Respond ONLY with valid JSON. No preamble, no markdown fences. Format: +{ + "suggestions": [ + { + "original": "exact text from the document that needs fixing", + "replacement": "corrected version", + "explanation": "friendly one-sentence explanation", + "type": "grammar|phrasing|idiom|clarity" + } + ] +} + +If the writing looks good, return: {"suggestions": []}` + +// CheckpointMessages builds the message array for a grammar checkpoint over the +// given (already-truncated) document text. +func CheckpointMessages(contentText string) []Message { + return []Message{ + {Role: "system", Content: checkpointSystemPrompt}, + {Role: "user", Content: contentText}, + } +} + +// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context +// is interpolated in; the user's own messages are appended after this system +// turn by the caller. +const askPetalSystemTemplate = `You are Petal, a warm and patient English writing tutor helping someone who is learning English ` + + `as a second language. You are currently discussing a specific writing suggestion. + +Suggestion context: +- Original text: "%s" +- Suggested replacement: "%s" +- Issue type: %s +- Initial explanation: "%s" +- Surrounding paragraph: "%s" + +The user wants to understand this suggestion better. Detect the language of the user's message ` + + `and respond in that same language. If they write in Mandarin Chinese, respond entirely in ` + + `Mandarin. If they write in English, respond in English. Never mix languages in a single response. + +Explain clearly and kindly. Use simple language appropriate to the user's message. Give examples ` + + `when helpful. If they ask "why" (or "为什么"), explain the grammar rule or idiom behind it. ` + + `If they suggest an alternative phrasing, evaluate it honestly. + +Keep responses concise (2-4 sentences). This is a chat, not an essay. Be encouraging — ` + + `learning a language is hard and they're doing great.` + +// AskPetalSystemPrompt fills the tutor prompt with one suggestion's context. +func AskPetalSystemPrompt(original, replacement, suggestionType, explanation, paragraph string) string { + return fmt.Sprintf(askPetalSystemTemplate, original, replacement, suggestionType, explanation, paragraph) +} diff --git a/internal/llm/vllm.go b/internal/llm/vllm.go new file mode 100644 index 0000000..1f9f950 --- /dev/null +++ b/internal/llm/vllm.go @@ -0,0 +1,156 @@ +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// VLLMClient talks to an OpenAI-compatible /v1/chat/completions endpoint +// (vLLM's API server). It is the default backend. +type VLLMClient struct { + backend +} + +// vllmRequest is the OpenAI-compatible request body. repetition_penalty is a +// vLLM extension to the OpenAI schema, which vLLM accepts. +type vllmRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature"` + RepetitionPenalty float64 `json:"repetition_penalty"` + TopP float64 `json:"top_p"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream"` +} + +func (c *VLLMClient) body(req CompletionRequest) vllmRequest { + return vllmRequest{ + Model: c.model(req), + Messages: req.Messages, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + RepetitionPenalty: req.RepetitionPenalty, + TopP: req.TopP, + Stop: req.Stop, + Stream: req.Stream, + } +} + +// Complete sends a non-streaming request and returns the full message content. +func (c *VLLMClient) Complete(ctx context.Context, req CompletionRequest) (string, error) { + req.Stream = false + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + resp, err := c.post(ctx, c.body(req)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", httpError("vllm", resp) + } + + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("vllm: decode response: %w", err) + } + if len(out.Choices) == 0 { + return "", fmt.Errorf("vllm: empty choices in response") + } + return out.Choices[0].Message.Content, nil +} + +// Stream sends a streaming request and emits delta content chunks on the +// returned channel, which closes when the stream ends ([DONE]) or ctx is done. +func (c *VLLMClient) Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) { + req.Stream = true + resp, err := c.post(ctx, c.body(req)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + return nil, httpError("vllm", resp) + } + + ch := make(chan string) + go func() { + defer close(ch) + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + return + } + var chunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue // skip keep-alives / malformed frames + } + if len(chunk.Choices) == 0 { + continue + } + if text := chunk.Choices[0].Delta.Content; text != "" { + select { + case ch <- text: + case <-ctx.Done(): + return + } + } + } + }() + return ch, nil +} + +func (c *VLLMClient) post(ctx context.Context, body any) (*http.Response, error) { + return postJSON(ctx, c.endpoint+"/v1/chat/completions", body) +} + +// --- shared HTTP helpers (used by both backends) --------------------------- + +// llmHTTP has no client-level timeout: Complete bounds itself with a context +// deadline (LLM_TIMEOUT), and streaming requests must stay open as long as the +// model is generating. Cancellation flows through the request context. +var llmHTTP = &http.Client{} + +func postJSON(ctx context.Context, url string, body any) (*http.Response, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return llmHTTP.Do(req) +} + +func httpError(backend string, resp *http.Response) error { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("%s: %s: %s", backend, resp.Status, strings.TrimSpace(string(b))) +} diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go new file mode 100644 index 0000000..0443fe8 --- /dev/null +++ b/internal/suggestions/handlers.go @@ -0,0 +1,246 @@ +// Package suggestions implements the grammar-checkpoint endpoint and the +// accept/dismiss surface for LLM-proposed edits. Checkpoints run a single LLM +// pass over a document and persist the resulting pending suggestions; the +// frontend re-anchors each one by its `original` string at render time, so the +// stored positions are advisory only (spec Note #6). +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" +) + +// Handler holds the dependencies for the checkpoint + suggestion routes. +type Handler struct { + DB *db.DB + Client llm.LLMClient + Limit *llm.RateLimiter +} + +// New constructs a Handler with a per-document checkpoint rate limiter. +func New(database *db.DB, client llm.LLMClient) *Handler { + return &Handler{ + DB: database, + Client: client, + Limit: llm.NewRateLimiter(llm.CheckpointInterval), + } +} + +// RegisterDocRoutes adds the document-scoped routes (check + list) onto the +// existing /api/docs router so they share its base path. +func (h *Handler) RegisterDocRoutes(r chi.Router) { + r.Post("/{id}/check", h.check) + r.Get("/{id}/suggestions", h.listForDoc) +} + +// Routes returns the router mounted at /api/suggestions for per-suggestion +// actions. +func (h *Handler) Routes() chi.Router { + r := chi.NewRouter() + r.Post("/{id}/accept", h.accept) + r.Post("/{id}/dismiss", h.dismiss) + return r +} + +// check runs a grammar checkpoint over the document and returns the fresh +// pending suggestions. It is rate-limited per document (429 when too soon). +func (h *Handler) check(w http.ResponseWriter, r *http.Request) { + docID := chi.URLParam(r, "id") + + var contentText string + err := h.DB.QueryRow( + `SELECT content_text FROM documents WHERE id = ? AND user_id = ?`, + docID, db.LocalUserID, + ).Scan(&contentText) + if errors.Is(err, sql.ErrNoRows) { + errorJSON(w, http.StatusNotFound, "document not found") + return + } + if err != nil { + serverError(w, err) + return + } + + // Nothing to check on an empty document — skip the LLM round-trip. + if strings.TrimSpace(contentText) == "" { + writeJSON(w, http.StatusOK, []db.Suggestion{}) + return + } + + if ok, _ := h.Limit.Allow(docID); !ok { + // Throttled: return the existing pending set unchanged rather than an + // error, so the frontend keeps showing current suggestions. + existing, err := h.fetchPending(docID) + if err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusOK, existing) + return + } + + raw, err := llm.RunCheckpoint(r.Context(), h.Client, contentText) + if err != nil { + errorJSON(w, http.StatusBadGateway, "checkpoint failed: "+err.Error()) + return + } + + saved, err := h.replacePending(docID, contentText, raw) + if err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusOK, saved) +} + +// replacePending swaps a document's pending suggestions for a fresh batch in one +// transaction. Accepted/rejected suggestions are left untouched (history). +func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion) ([]db.Suggestion, error) { + tx, err := h.DB.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + if _, err := tx.Exec( + `DELETE FROM suggestions WHERE doc_id = ? AND status = ?`, + docID, db.SuggestionStatusPending, + ); err != nil { + return nil, err + } + + out := []db.Suggestion{} + for _, s := range raw { + typ := normalizeType(s.Type) + from, to := locate(contentText, s.Original) + var saved db.Suggestion + err := tx.QueryRow( + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) + VALUES (?, ?, ?, ?, ?, ?, ?) + RETURNING id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at`, + docID, from, to, s.Original, s.Replacement, s.Explanation, typ, + ).Scan( + &saved.ID, &saved.DocID, &saved.FromPos, &saved.ToPos, &saved.Original, + &saved.Replacement, &saved.Explanation, &saved.Type, &saved.Status, &saved.CreatedAt, + ) + if err != nil { + return nil, err + } + out = append(out, saved) + } + + if err := tx.Commit(); err != nil { + return nil, err + } + return out, nil +} + +// listForDoc returns the document's current pending suggestions (used when the +// editor loads a document, before any new checkpoint fires). +func (h *Handler) listForDoc(w http.ResponseWriter, r *http.Request) { + out, err := h.fetchPending(chi.URLParam(r, "id")) + if err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +func (h *Handler) fetchPending(docID string) ([]db.Suggestion, error) { + rows, err := h.DB.Query( + `SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at + FROM suggestions + WHERE doc_id = ? AND status = ? + ORDER BY from_pos ASC, created_at ASC`, + docID, db.SuggestionStatusPending, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []db.Suggestion{} + for rows.Next() { + var s db.Suggestion + if err := rows.Scan( + &s.ID, &s.DocID, &s.FromPos, &s.ToPos, &s.Original, &s.Replacement, + &s.Explanation, &s.Type, &s.Status, &s.CreatedAt, + ); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// accept marks a suggestion accepted (the client applies the replacement text). +func (h *Handler) accept(w http.ResponseWriter, r *http.Request) { + h.setStatus(w, r, db.SuggestionStatusAccepted) +} + +// dismiss marks a suggestion rejected. +func (h *Handler) dismiss(w http.ResponseWriter, r *http.Request) { + h.setStatus(w, r, db.SuggestionStatusRejected) +} + +func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) { + res, err := h.DB.Exec( + `UPDATE suggestions SET status = ? WHERE id = ? AND status = ?`, + status, chi.URLParam(r, "id"), db.SuggestionStatusPending, + ) + if err != nil { + serverError(w, err) + return + } + if n, _ := res.RowsAffected(); n == 0 { + errorJSON(w, http.StatusNotFound, "pending suggestion not found") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// locate finds the plaintext offsets of original within contentText. Returns +// (-1, -1) when not found; the frontend anchors by string regardless, so a miss +// here is non-fatal. +func locate(contentText, original string) (int, int) { + idx := strings.Index(contentText, original) + if idx < 0 { + return -1, -1 + } + return idx, idx + len(original) +} + +// normalizeType maps the model's type string onto a valid suggestion type, +// defaulting unknown values to grammar so a stray label never trips the CHECK. +func normalizeType(t string) string { + switch strings.ToLower(strings.TrimSpace(t)) { + case db.SuggestionTypeGrammar, db.SuggestionTypePhrasing, db.SuggestionTypeIdiom, db.SuggestionTypeClarity: + return strings.ToLower(strings.TrimSpace(t)) + default: + return db.SuggestionTypeGrammar + } +} + +// --- response helpers ------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func errorJSON(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func serverError(w http.ResponseWriter, err error) { + errorJSON(w, http.StatusInternalServerError, err.Error()) +} diff --git a/internal/suggestions/handlers_test.go b/internal/suggestions/handlers_test.go new file mode 100644 index 0000000..feb3c45 --- /dev/null +++ b/internal/suggestions/handlers_test.go @@ -0,0 +1,142 @@ +package suggestions + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + + "gitea.parodia.dev/drwily/petal/internal/db" + "gitea.parodia.dev/drwily/petal/internal/llm" +) + +// stubClient returns a canned checkpoint response; it never touches the network. +type stubClient struct { + response string + calls int +} + +func (s *stubClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) { + s.calls++ + return s.response, nil +} + +func (s *stubClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) { + ch := make(chan string) + close(ch) + return ch, nil +} + +// newTestServer wires a real DB, a seeded document, and the suggestion routes +// (both the doc-scoped and the per-suggestion mounts) onto one router. +func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string) { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + // Seed a document with some content to check. + var docID string + err = database.QueryRow( + `INSERT INTO documents (user_id, content_text) VALUES (?, ?) RETURNING id`, + db.LocalUserID, "I has two apple.", + ).Scan(&docID) + if err != nil { + t.Fatalf("seed doc: %v", err) + } + + h := New(database, client) + r := chi.NewRouter() + r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) }) + r.Mount("/suggestions", h.Routes()) + return r, docID +} + +func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, path, bytes.NewBufferString(body)) + } else { + r = httptest.NewRequest(method, path, nil) + } + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, r) + return rec +} + +func TestCheckpointFlow(t *testing.T) { + client := &stubClient{response: `{"suggestions":[ + {"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}, + {"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"} + ]}`} + srv, docID := newTestServer(t, client) + + // Run a checkpoint → two pending suggestions, with positions located. + rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") + if rec.Code != http.StatusOK { + t.Fatalf("check: code=%d body=%s", rec.Code, rec.Body) + } + var got []db.Suggestion + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 suggestions, got %d: %+v", len(got), got) + } + if got[0].Original != "I has" || got[0].FromPos != 0 { + t.Fatalf("first suggestion anchoring wrong: %+v", got[0]) + } + if got[0].Status != db.SuggestionStatusPending { + t.Fatalf("new suggestion should be pending: %+v", got[0]) + } + + // Listing returns the same pending set. + rec = do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "") + var listed []db.Suggestion + _ = json.Unmarshal(rec.Body.Bytes(), &listed) + if len(listed) != 2 { + t.Fatalf("list pending: want 2, got %d", len(listed)) + } + + // Accept the first, dismiss the second. + if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNoContent { + t.Fatalf("accept: code=%d body=%s", rec.Code, rec.Body) + } + if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[1].ID+"/dismiss", ""); rec.Code != http.StatusNoContent { + t.Fatalf("dismiss: code=%d body=%s", rec.Code, rec.Body) + } + + // Pending list is now empty (accepted/dismissed are excluded). + rec = do(t, srv, http.MethodGet, "/docs/"+docID+"/suggestions", "") + _ = json.Unmarshal(rec.Body.Bytes(), &listed) + if len(listed) != 0 { + t.Fatalf("pending after resolve: want 0, got %d", len(listed)) + } + + // Re-accepting an already-resolved suggestion 404s. + if rec = do(t, srv, http.MethodPost, "/suggestions/"+got[0].ID+"/accept", ""); rec.Code != http.StatusNotFound { + t.Fatalf("re-accept: want 404, got %d", rec.Code) + } +} + +func TestCheckpointRateLimit(t *testing.T) { + client := &stubClient{response: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"x","type":"grammar"}]}`} + srv, docID := newTestServer(t, client) + + // First check runs the model. + do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") + // Immediate second check is throttled — returns the existing set without + // hitting the model again. + do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "") + if client.calls != 1 { + t.Fatalf("rate limit should suppress the second model call, got %d calls", client.calls) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index eb48c21..4d88d81 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { api, type DocSummary, type Document } from './api/client' +import { api, type DocSummary, type Document, type Suggestion } from './api/client' import { useAutoSave } from './hooks/useAutoSave' +import { useCheckpoint } from './hooks/useCheckpoint' import { DocList } from './components/DocList/DocList' import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { StatusBar } from './components/StatusBar/StatusBar' @@ -13,6 +14,12 @@ export default function App() { const [ready, setReady] = useState(false) const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null) + const { + suggestions, + checking, + schedule: scheduleCheckpoint, + removeSuggestion, + } = useCheckpoint(currentDoc?.id ?? null) // Patch a summary in the sidebar list (optimistic title / word-count updates). const patchSummary = useCallback((id: string, patch: Partial) => { @@ -106,9 +113,36 @@ export default function App() { if (currentDoc) { patchSummary(currentDoc.id, { word_count: change.word_count }) schedule(change) + scheduleCheckpoint() } }, - [currentDoc, patchSummary, schedule], + [currentDoc, patchSummary, schedule, scheduleCheckpoint], + ) + + // Accept applies the replacement in the editor (handled in EditorCore) and + // marks the suggestion accepted; dismiss just rejects it. Both drop it locally. + const handleAccept = useCallback( + async (s: Suggestion) => { + removeSuggestion(s.id) + try { + await api.acceptSuggestion(s.id) + } catch (err) { + console.error('accept failed', err) + } + }, + [removeSuggestion], + ) + + const handleDismiss = useCallback( + async (s: Suggestion) => { + removeSuggestion(s.id) + try { + await api.dismissSuggestion(s.id) + } catch (err) { + console.error('dismiss failed', err) + } + }, + [removeSuggestion], ) return ( @@ -148,10 +182,13 @@ export default function App() { docId={currentDoc.id} initialContent={currentDoc.content} onChange={handleEditorChange} + suggestions={suggestions} + onAccept={handleAccept} + onDismiss={handleDismiss} /> - + ) : (
(path: string, init?: RequestInit): Promise { const res = await fetch(`/api${path}`, { headers: { 'Content-Type': 'application/json' }, @@ -48,4 +67,14 @@ export const api = { updateDoc: (id: string, body: DocUpdate) => req(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteDoc: (id: string) => req(`/docs/${id}`, { method: 'DELETE' }), + + // Grammar checkpoint: runs an LLM pass and returns the fresh pending set. + // Rate-limited per document server-side (returns the existing set if too soon). + checkDoc: (id: string) => req(`/docs/${id}/check`, { method: 'POST' }), + // Pending suggestions for a doc, loaded when the editor opens it. + listSuggestions: (id: string) => req(`/docs/${id}/suggestions`), + acceptSuggestion: (id: string) => + req(`/suggestions/${id}/accept`, { method: 'POST' }), + dismissSuggestion: (id: string) => + req(`/suggestions/${id}/dismiss`, { method: 'POST' }), } diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index c324bf8..acd9c3c 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -4,8 +4,11 @@ import Underline from '@tiptap/extension-underline' import TextAlign from '@tiptap/extension-text-align' import Placeholder from '@tiptap/extension-placeholder' import CharacterCount from '@tiptap/extension-character-count' -import { useEffect } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Toolbar } from '../Toolbar/Toolbar' +import { SuggestionCard } from './SuggestionCard' +import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight' +import type { Suggestion } from '../../api/client' export interface EditorChange { content: string // Tiptap JSON, stringified @@ -18,6 +21,11 @@ interface Props { docId: string initialContent: string onChange: (change: EditorChange) => void + // LLM suggestions to highlight; accept/dismiss notify the parent for the API + // call + state removal (accept's text replacement happens here in the editor). + suggestions: Suggestion[] + onAccept: (s: Suggestion) => void + onDismiss: (s: Suggestion) => void } // parseDoc turns the stored content string into a Tiptap doc node, tolerating @@ -33,9 +41,21 @@ function parseDoc(raw: string): object | undefined { return undefined } +interface HoverState { + suggestion: Suggestion + top: number + left: number +} + // EditorCore is the Tiptap instance: StarterKit formatting plus underline, text -// alignment, a placeholder, and character counting (words feed the StatusBar). -export function EditorCore({ docId, initialContent, onChange }: Props) { +// alignment, a placeholder, character counting, and the suggestion-highlight +// decoration layer. Hovering a highlight opens its SuggestionCard. +export function EditorCore({ docId, initialContent, onChange, suggestions, onAccept, onDismiss }: Props) { + const wrapperRef = useRef(null) + const [hover, setHover] = useState(null) + // Delays card close so the pointer can travel from highlight to card. + const closeTimer = useRef>(undefined) + const editor = useEditor({ extensions: [ StarterKit, @@ -43,6 +63,7 @@ export function EditorCore({ docId, initialContent, onChange }: Props) { TextAlign.configure({ types: ['heading', 'paragraph'] }), Placeholder.configure({ placeholder: 'Start writing…' }), CharacterCount, + SuggestionHighlight, ], content: parseDoc(initialContent), editorProps: { @@ -62,13 +83,111 @@ export function EditorCore({ docId, initialContent, onChange }: Props) { useEffect(() => { if (!editor) return editor.commands.setContent(parseDoc(initialContent) ?? '', false) + setHover(null) // eslint-disable-next-line react-hooks/exhaustive-deps }, [docId, editor]) + // Push the current suggestion list into the decoration plugin. + useEffect(() => { + if (!editor) return + setSuggestions(editor.state, editor.view.dispatch, suggestions) + // Close the card if its suggestion is gone. + setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null)) + }, [editor, suggestions]) + + const openCardFor = useCallback( + (id: string, el: HTMLElement) => { + const wrapper = wrapperRef.current + if (!wrapper) return + const suggestion = suggestions.find((s) => s.id === id) + if (!suggestion) return + const elRect = el.getBoundingClientRect() + const wrapRect = wrapper.getBoundingClientRect() + const cardWidth = 300 + const left = Math.max( + 0, + Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth), + ) + const top = elRect.bottom - wrapRect.top + 6 + setHover({ suggestion, top, left }) + }, + [suggestions], + ) + + const handleMouseOver = useCallback( + (e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null + if (!target) return + const id = target.getAttribute('data-suggestion-id') + if (!id) return + clearTimeout(closeTimer.current) + openCardFor(id, target) + }, + [openCardFor], + ) + + const scheduleClose = useCallback(() => { + clearTimeout(closeTimer.current) + closeTimer.current = setTimeout(() => setHover(null), 160) + }, []) + + // Leaving a highlight schedules a close; entering the card cancels it, so the + // pointer can bridge the small gap from text to card. + const handleMouseOut = useCallback( + (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('.petal-suggestion')) scheduleClose() + }, + [scheduleClose], + ) + + const keepOpen = useCallback(() => clearTimeout(closeTimer.current), []) + + // Accept applies the replacement to the document, then notifies the parent. + const handleAccept = useCallback( + (s: Suggestion) => { + if (editor && s.replacement.trim() !== '') { + const range = findRange(editor.state.doc, s.original) + if (range) { + editor.chain().focus().insertContentAt(range, s.replacement).run() + } + } + setHover(null) + onAccept(s) + }, + [editor, onAccept], + ) + + const handleDismiss = useCallback( + (s: Suggestion) => { + setHover(null) + onDismiss(s) + }, + [onDismiss], + ) + + useEffect(() => () => clearTimeout(closeTimer.current), []) + return (
- +
+ + {hover && ( + + )} +
) } diff --git a/web/src/components/Editor/SuggestionCard.tsx b/web/src/components/Editor/SuggestionCard.tsx new file mode 100644 index 0000000..381c894 --- /dev/null +++ b/web/src/components/Editor/SuggestionCard.tsx @@ -0,0 +1,97 @@ +import type { Suggestion, SuggestionType } from '../../api/client' + +// Per-type accent color + human label, mirroring the design tokens. +const TYPE_META: Record = { + grammar: { color: 'var(--color-mint)', label: 'Grammar' }, + phrasing: { color: 'var(--color-peach)', label: 'Phrasing' }, + idiom: { color: 'var(--color-lavender)', label: 'Idiom' }, + clarity: { color: 'var(--color-sky)', label: 'Clarity' }, + voice: { color: 'var(--color-honey)', label: 'Voice' }, +} + +interface Props { + suggestion: Suggestion + style: React.CSSProperties + onAccept: (s: Suggestion) => void + onDismiss: (s: Suggestion) => void + onPointerEnter: () => void + onPointerLeave: () => void +} + +// SuggestionCard is the hover panel for a single suggestion: a colored type tag, +// the original → replacement diff, the friendly explanation, and accept/dismiss +// actions. Voice flags carry no replacement, so the diff row is hidden and only +// Dismiss is offered (awareness-only). +export function SuggestionCard({ + suggestion, + style, + onAccept, + onDismiss, + onPointerEnter, + onPointerLeave, +}: Props) { + const meta = TYPE_META[suggestion.type] + const hasReplacement = suggestion.replacement.trim() !== '' + + return ( +
+ + {meta.label} + + + {hasReplacement && ( +
+ + {suggestion.original} + + + {suggestion.replacement} + +
+ )} + +

+ {suggestion.explanation} +

+ +
+ {hasReplacement && ( + + )} + +
+
+ ) +} diff --git a/web/src/components/Editor/SuggestionHighlight.ts b/web/src/components/Editor/SuggestionHighlight.ts new file mode 100644 index 0000000..2ad87e4 --- /dev/null +++ b/web/src/components/Editor/SuggestionHighlight.ts @@ -0,0 +1,121 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import type { EditorState, Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import type { Node as PMNode } from '@tiptap/pm/model' +import type { Suggestion } from '../../api/client' + +// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not +// stored marks. Decorations are ephemeral overlays recomputed from the live +// document on every change, which is exactly the string-anchoring the spec +// requires (Note #6): each suggestion is re-located by matching its `original` +// text in the current doc, so edits made while a checkpoint is in flight never +// leave a highlight stranded on stale coordinates. + +export const suggestionPluginKey = new PluginKey('petalSuggestions') + +interface PluginState { + suggestions: Suggestion[] + decorations: DecorationSet +} + +// mapOffset converts a character offset within a textblock's flattened text into +// an absolute ProseMirror position, accounting for inline atoms (e.g. hard +// breaks) that occupy a position but contribute no text. +function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number { + let textOffset = 0 + let pmPos = blockPos + 1 // inline content starts just inside the block + let result = pmPos + let done = false + block.forEach((child) => { + if (done) return + const len = child.isText ? (child.text?.length ?? 0) : 0 + if (textOffset + len >= targetOffset) { + result = pmPos + (targetOffset - textOffset) + done = true + } else { + textOffset += len + pmPos += child.nodeSize + } + }) + if (!done) result = pmPos + return result +} + +// findRange locates the first occurrence of `search` within a single textblock +// and returns its ProseMirror range, or null if the string isn't present (the +// user may have edited or removed it since the checkpoint ran). Exported so the +// accept flow can resolve the same span to replace. +export function findRange(doc: PMNode, search: string): { from: number; to: number } | null { + if (!search) return null + let result: { from: number; to: number } | null = null + doc.descendants((node, pos) => { + if (result) return false + if (!node.isTextblock) return true // keep descending to the textblock + const idx = node.textContent.indexOf(search) + if (idx >= 0) { + result = { + from: mapOffset(node, pos, idx), + to: mapOffset(node, pos, idx + search.length), + } + } + return false // never descend into a textblock's inline children + }) + return result +} + +function buildDecorations(doc: PMNode, suggestions: Suggestion[]): DecorationSet { + const decos: Decoration[] = [] + for (const s of suggestions) { + const range = findRange(doc, s.original) + if (!range) continue + decos.push( + Decoration.inline(range.from, range.to, { + class: `petal-suggestion petal-suggestion-${s.type}`, + 'data-suggestion-id': s.id, + }), + ) + } + return DecorationSet.create(doc, decos) +} + +// setSuggestions pushes a new suggestion list into the plugin. Decorations are +// rebuilt against the current document immediately. +export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) { + const tr = state.tr.setMeta(suggestionPluginKey, suggestions) + dispatch(tr) +} + +export const SuggestionHighlight = Extension.create({ + name: 'suggestionHighlight', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: suggestionPluginKey, + state: { + init: () => ({ suggestions: [], decorations: DecorationSet.empty }), + apply(tr, value, _oldState, newState) { + const meta = tr.getMeta(suggestionPluginKey) as Suggestion[] | undefined + if (meta) { + return { suggestions: meta, decorations: buildDecorations(newState.doc, meta) } + } + // On any document change, re-anchor by string against the new doc. + if (tr.docChanged) { + return { + suggestions: value.suggestions, + decorations: buildDecorations(newState.doc, value.suggestions), + } + } + return value + }, + }, + props: { + decorations(state) { + return suggestionPluginKey.getState(state)?.decorations + }, + }, + }), + ] + }, +}) diff --git a/web/src/components/StatusBar/StatusBar.tsx b/web/src/components/StatusBar/StatusBar.tsx index c9d0969..ab65a59 100644 --- a/web/src/components/StatusBar/StatusBar.tsx +++ b/web/src/components/StatusBar/StatusBar.tsx @@ -3,6 +3,8 @@ import type { SaveStatus } from '../../hooks/useAutoSave' interface Props { wordCount: number saveStatus: SaveStatus + // True while a grammar checkpoint is in flight — shows the breathing rose dot. + checking: boolean } const SAVE_LABEL: Record = { @@ -13,9 +15,10 @@ const SAVE_LABEL: Record = { error: "Couldn't save", } -// StatusBar is the slim footer: word count on the left, save state on the right. -// The checkpoint dot (grammar pass) joins it in Phase 3. -export function StatusBar({ wordCount, saveStatus }: Props) { +// StatusBar is the slim footer: word count on the left, save state and the +// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose +// circle that breathes while a check is in flight (spec → Signature animations). +export function StatusBar({ wordCount, saveStatus, checking }: Props) { const label = SAVE_LABEL[saveStatus] return (