Phase 3: LLM grammar checkpoint
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
This commit is contained in:
@@ -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] 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
|
- [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together
|
||||||
|
|
||||||
### Phase 3 — LLM grammar checkpoint
|
### Phase 3 — LLM grammar checkpoint ✅
|
||||||
- [ ] `LLMClient` interface + factory (`internal/llm/client.go`)
|
- [x] `LLMClient` interface + factory (`internal/llm/client.go`) — chat-model fallback in factory; doc/history truncation helpers
|
||||||
- [ ] `vllm.go` (OpenAI-compat), `ollama.go` (native) — both behind interface
|
- [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)
|
||||||
- [ ] `checkpoint.go` (30s/doc rate limit), `prompts.go`
|
- [x] `checkpoint.go` (30s/doc `RateLimiter`), `prompts.go` — brace-matched JSON salvage from model output, empty-original drop, type normalization
|
||||||
- [ ] `POST /api/docs/:id/check`
|
- [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
|
||||||
- [ ] `useCheckpoint` (4s debounce) + checkpoint indicator
|
- [x] `useCheckpoint` (4s debounce) + breathing rose checkpoint dot in StatusBar
|
||||||
- [ ] SuggestionMark + SuggestionCard (accept/dismiss); **string-anchoring**, not stored pos
|
- [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)
|
||||||
- [ ] Suggestion colors: grammar=mint, phrasing=peach, idiom=lavender, clarity=sky
|
- [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
|
- [ ] `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 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 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).**
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/docs"
|
"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"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +40,17 @@ func main() {
|
|||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = 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).
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||||
|
|||||||
143
internal/llm/checkpoint.go
Normal file
143
internal/llm/checkpoint.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
93
internal/llm/checkpoint_test.go
Normal file
93
internal/llm/checkpoint_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
internal/llm/client.go
Normal file
110
internal/llm/client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
124
internal/llm/ollama.go
Normal file
124
internal/llm/ollama.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
63
internal/llm/prompts.go
Normal file
63
internal/llm/prompts.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
156
internal/llm/vllm.go
Normal file
156
internal/llm/vllm.go
Normal file
@@ -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)))
|
||||||
|
}
|
||||||
246
internal/suggestions/handlers.go
Normal file
246
internal/suggestions/handlers.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
142
internal/suggestions/handlers_test.go
Normal file
142
internal/suggestions/handlers_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
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 { useAutoSave } from './hooks/useAutoSave'
|
||||||
|
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||||
import { DocList } from './components/DocList/DocList'
|
import { DocList } from './components/DocList/DocList'
|
||||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
@@ -13,6 +14,12 @@ export default function App() {
|
|||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
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).
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||||
@@ -106,9 +113,36 @@ export default function App() {
|
|||||||
if (currentDoc) {
|
if (currentDoc) {
|
||||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
schedule(change)
|
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 (
|
return (
|
||||||
@@ -148,10 +182,13 @@ export default function App() {
|
|||||||
docId={currentDoc.id}
|
docId={currentDoc.id}
|
||||||
initialContent={currentDoc.content}
|
initialContent={currentDoc.content}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
|
suggestions={suggestions}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar wordCount={wordCount} saveStatus={status} />
|
<StatusBar wordCount={wordCount} saveStatus={status} checking={checking} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -28,6 +28,25 @@ export interface DocUpdate {
|
|||||||
word_count?: number
|
word_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice'
|
||||||
|
|
||||||
|
// A single LLM-proposed edit. `original` is the source of truth for placement —
|
||||||
|
// the editor re-anchors by matching this string in the live document (spec
|
||||||
|
// Note #6); from_pos/to_pos are server-side advisory only. `replacement` is
|
||||||
|
// empty for voice flags (awareness-only, no correction to apply).
|
||||||
|
export interface Suggestion {
|
||||||
|
id: string
|
||||||
|
doc_id: string
|
||||||
|
from_pos: number
|
||||||
|
to_pos: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
explanation: string
|
||||||
|
type: SuggestionType
|
||||||
|
status: 'pending' | 'accepted' | 'rejected'
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -48,4 +67,14 @@ export const api = {
|
|||||||
updateDoc: (id: string, body: DocUpdate) =>
|
updateDoc: (id: string, body: DocUpdate) =>
|
||||||
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),
|
deleteDoc: (id: string) => req<void>(`/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<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
||||||
|
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||||
|
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||||
|
acceptSuggestion: (id: string) =>
|
||||||
|
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||||||
|
dismissSuggestion: (id: string) =>
|
||||||
|
req<void>(`/suggestions/${id}/dismiss`, { method: 'POST' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import Underline from '@tiptap/extension-underline'
|
|||||||
import TextAlign from '@tiptap/extension-text-align'
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import CharacterCount from '@tiptap/extension-character-count'
|
import CharacterCount from '@tiptap/extension-character-count'
|
||||||
import { useEffect } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Toolbar } from '../Toolbar/Toolbar'
|
import { Toolbar } from '../Toolbar/Toolbar'
|
||||||
|
import { SuggestionCard } from './SuggestionCard'
|
||||||
|
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||||
|
import type { Suggestion } from '../../api/client'
|
||||||
|
|
||||||
export interface EditorChange {
|
export interface EditorChange {
|
||||||
content: string // Tiptap JSON, stringified
|
content: string // Tiptap JSON, stringified
|
||||||
@@ -18,6 +21,11 @@ interface Props {
|
|||||||
docId: string
|
docId: string
|
||||||
initialContent: string
|
initialContent: string
|
||||||
onChange: (change: EditorChange) => void
|
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
|
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
||||||
@@ -33,9 +41,21 @@ function parseDoc(raw: string): object | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HoverState {
|
||||||
|
suggestion: Suggestion
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
|
||||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||||
// alignment, a placeholder, and character counting (words feed the StatusBar).
|
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||||
export function EditorCore({ docId, initialContent, onChange }: Props) {
|
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||||
|
export function EditorCore({ docId, initialContent, onChange, suggestions, onAccept, onDismiss }: Props) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
|
// Delays card close so the pointer can travel from highlight to card.
|
||||||
|
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
@@ -43,6 +63,7 @@ export function EditorCore({ docId, initialContent, onChange }: Props) {
|
|||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
|
SuggestionHighlight,
|
||||||
],
|
],
|
||||||
content: parseDoc(initialContent),
|
content: parseDoc(initialContent),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -62,13 +83,111 @@ export function EditorCore({ docId, initialContent, onChange }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||||
|
setHover(null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [docId, editor])
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Toolbar editor={editor} />
|
<Toolbar editor={editor} />
|
||||||
<EditorContent editor={editor} className="flex-1" />
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="relative flex-1"
|
||||||
|
onMouseOver={handleMouseOver}
|
||||||
|
onMouseOut={handleMouseOut}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} className="h-full" />
|
||||||
|
{hover && (
|
||||||
|
<SuggestionCard
|
||||||
|
suggestion={hover.suggestion}
|
||||||
|
style={{ top: hover.top, left: hover.left }}
|
||||||
|
onAccept={handleAccept}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
onPointerEnter={keepOpen}
|
||||||
|
onPointerLeave={scheduleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
97
web/src/components/Editor/SuggestionCard.tsx
Normal file
97
web/src/components/Editor/SuggestionCard.tsx
Normal file
@@ -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<SuggestionType, { color: string; label: string }> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`${meta.label} suggestion`}
|
||||||
|
onMouseEnter={onPointerEnter}
|
||||||
|
onMouseLeave={onPointerLeave}
|
||||||
|
className="petal-suggestion-card absolute z-20 w-[300px] p-3.5 text-sm"
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{hasReplacement && (
|
||||||
|
<div className="mt-2.5 flex flex-col gap-1" style={{ fontFamily: 'var(--font-body)' }}>
|
||||||
|
<span className="text-[0.95rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{suggestion.original}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.95rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{suggestion.replacement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-plum)' }}>
|
||||||
|
{suggestion.explanation}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{hasReplacement && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAccept(suggestion)}
|
||||||
|
className="rounded-full px-3.5 py-1.5 text-xs font-bold text-white"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDismiss(suggestion)}
|
||||||
|
className="rounded-full px-3.5 py-1.5 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
web/src/components/Editor/SuggestionHighlight.ts
Normal file
121
web/src/components/Editor/SuggestionHighlight.ts
Normal file
@@ -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<PluginState>('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<PluginState>({
|
||||||
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,6 +3,8 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
|
|||||||
interface Props {
|
interface Props {
|
||||||
wordCount: number
|
wordCount: number
|
||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
|
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||||
|
checking: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||||
@@ -13,9 +15,10 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
|||||||
error: "Couldn't save",
|
error: "Couldn't save",
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusBar is the slim footer: word count on the left, save state on the right.
|
// StatusBar is the slim footer: word count on the left, save state and the
|
||||||
// The checkpoint dot (grammar pass) joins it in Phase 3.
|
// grammar-checkpoint indicator on the right. The checkpoint dot is a soft rose
|
||||||
export function StatusBar({ wordCount, saveStatus }: Props) {
|
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||||
|
export function StatusBar({ wordCount, saveStatus, checking }: Props) {
|
||||||
const label = SAVE_LABEL[saveStatus]
|
const label = SAVE_LABEL[saveStatus]
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@@ -25,6 +28,18 @@ export function StatusBar({ wordCount, saveStatus }: Props) {
|
|||||||
<span>
|
<span>
|
||||||
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
{wordCount} {wordCount === 1 ? 'word' : 'words'}
|
||||||
</span>
|
</span>
|
||||||
|
{checking && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5" title="Petal is reading your writing…">
|
||||||
|
<span
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-accent)' }}
|
||||||
|
/>
|
||||||
|
Checking…
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
|
|||||||
75
web/src/hooks/useCheckpoint.ts
Normal file
75
web/src/hooks/useCheckpoint.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { api, type Suggestion } from '../api/client'
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 4000
|
||||||
|
|
||||||
|
// useCheckpoint manages the grammar-checkpoint lifecycle for one document:
|
||||||
|
// it loads any existing pending suggestions when the doc opens, then fires a
|
||||||
|
// fresh check 4s after the user stops typing. `checking` drives the breathing
|
||||||
|
// dot in the StatusBar. The server rate-limits per document, so a check that
|
||||||
|
// fires too soon simply returns the current set unchanged.
|
||||||
|
export function useCheckpoint(docId: string | null) {
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const docIdRef = useRef(docId)
|
||||||
|
docIdRef.current = docId
|
||||||
|
|
||||||
|
// Token to discard responses from a doc we've since navigated away from.
|
||||||
|
const runRef = useRef(0)
|
||||||
|
|
||||||
|
const runCheck = useCallback(async () => {
|
||||||
|
const id = docIdRef.current
|
||||||
|
if (!id) return
|
||||||
|
const run = ++runRef.current
|
||||||
|
setChecking(true)
|
||||||
|
try {
|
||||||
|
const fresh = await api.checkDoc(id)
|
||||||
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
|
setSuggestions(fresh)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('checkpoint failed', err)
|
||||||
|
} finally {
|
||||||
|
if (run === runRef.current) setChecking(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Call on every edit; schedules a check 4s after typing settles.
|
||||||
|
const schedule = useCallback(() => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
|
||||||
|
}, [runCheck])
|
||||||
|
|
||||||
|
// Load existing pending suggestions whenever the document changes, and cancel
|
||||||
|
// any in-flight debounce from the previous doc.
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
runRef.current++
|
||||||
|
setSuggestions([])
|
||||||
|
setChecking(false)
|
||||||
|
if (!docId) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const existing = await api.listSuggestions(docId)
|
||||||
|
if (!cancelled && docIdRef.current === docId) setSuggestions(existing)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to load suggestions', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [docId])
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(debounceRef.current), [])
|
||||||
|
|
||||||
|
// Drop one suggestion locally (after accept/dismiss) without a refetch.
|
||||||
|
const removeSuggestion = useCallback((id: string) => {
|
||||||
|
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { suggestions, checking, schedule, removeSuggestion }
|
||||||
|
}
|
||||||
@@ -106,3 +106,41 @@ button, a, input {
|
|||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Suggestion decorations -------------------------------------------------
|
||||||
|
Inline highlights anchored by string at render time (not stored marks). Each
|
||||||
|
type gets a soft underline in its palette color; hovering opens its card. */
|
||||||
|
.petal-suggestion {
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 200ms ease;
|
||||||
|
/* gentle fade + slight upward float as decorations appear */
|
||||||
|
animation: petal-suggestion-in 260ms ease both;
|
||||||
|
}
|
||||||
|
.petal-suggestion:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
.petal-suggestion-grammar { border-bottom-color: var(--color-mint); }
|
||||||
|
.petal-suggestion-phrasing { border-bottom-color: var(--color-peach); }
|
||||||
|
.petal-suggestion-idiom { border-bottom-color: var(--color-lavender); }
|
||||||
|
.petal-suggestion-clarity { border-bottom-color: var(--color-sky); }
|
||||||
|
.petal-suggestion-voice { border-bottom-color: var(--color-honey); }
|
||||||
|
|
||||||
|
@keyframes petal-suggestion-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.petal-suggestion-card {
|
||||||
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breathing rose dot shown in the StatusBar while a checkpoint runs. */
|
||||||
|
.petal-checkpoint-dot {
|
||||||
|
animation: petal-breathe 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes petal-breathe {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user