Phase 5: voice consistency pass
Tier-1 voice-consistency pass: whole-document LLM review surfacing passages that read tonally out of place (formal/over-polished/paraphrased-too-closely), as honey-decorated `voice` flags with no correction (awareness-only). - internal/llm/voice.go: RunVoice sends the whole document (no TruncateDoc), MaxTokens 2048, 20s per-doc floor (VoiceInterval). Standalone voice prompt in prompts.go (not bundled with the grammar checkpoint, per spec). - internal/suggestions: POST /api/docs/:id/voice. replacePending is now family-scoped (pendingScope) so grammar and voice never clobber each other's pending flags; both passes return the unified pending set. check/voice share one runPass helper. TestVoicePassCoexists covers both directions. - Frontend: api.voiceDoc, useCheckpoint voicing/runVoice, honey "Check my voice" toolbar pill, breathing honey dot in StatusBar. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -52,9 +52,13 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- [x] CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the `'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'` stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body.
|
- [x] CJK font fallbacks on chat bubbles (spec Note #17) — bubbles + input use the `'Nunito','PingFang SC','Microsoft YaHei','Noto Sans CJK SC'` stack (the user asks in Mandarin); applied to the chat surface only, not the serif editor body.
|
||||||
- `internal/llm/chat.go`: `StreamAskPetal` (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop `\n\n\n`) reusing the existing `AskPetalSystemPrompt` + `TrimHistory`. Backend stays interface-only; the SSE handler never touches a concrete client.
|
- `internal/llm/chat.go`: `StreamAskPetal` (max_tokens 512, temp 0.7, rep 1.15, top_p 0.92, stop `\n\n\n`) reusing the existing `AskPetalSystemPrompt` + `TrimHistory`. Backend stays interface-only; the SSE handler never touches a concrete client.
|
||||||
|
|
||||||
### Phase 5 — Voice consistency pass (Tier 1)
|
### Phase 5 — Voice consistency pass (Tier 1) ✅
|
||||||
- [ ] `POST /api/docs/:id/voice`, whole-document, slow cadence / explicit action
|
- [x] `POST /api/docs/:id/voice`, whole-document (no `TruncateDoc`), explicit "Check my voice 🍯" toolbar action — `internal/llm/voice.go` (`RunVoice`, `VoiceInterval` 20s floor, MaxTokens 2048), `voiceSystemPrompt`/`VoiceMessages` in `prompts.go` (standalone — not bundled with the grammar checkpoint per spec)
|
||||||
- [ ] `voice` suggestion type, honey decoration (`--color-honey`)
|
- [x] `voice` suggestion type, honey decoration — type already in schema/CSS; voice flags carry `replacement: null` → stored `""`, `SuggestionCard` hides the diff row + Accept (Dismiss only)
|
||||||
|
- [x] **Family-scoped pending sets**: grammar and voice are independent passes sharing the suggestions table. `replacePending` now scopes its DELETE by family (`pendingScope`: grammar = `type != 'voice'`, voice = `type = 'voice'`), so neither pass wipes the other's pending flags. Both `/check` and `/voice` return the **unified** pending set (grammar + voice) so the client never drops one family's highlights when the other refreshes (also fixes a latent throttle-vs-success inconsistency).
|
||||||
|
- [x] Frontend: `api.voiceDoc`, `useCheckpoint` gains `voicing`/`runVoice` (shares the run-token guard), `Toolbar` honey "Check my voice 🍯" pill (loading→"Reading…"), `StatusBar` breathing honey dot "Reading your voice…". `check`/`voice` collapsed into a shared `runPass` server-side.
|
||||||
|
- Tests: `TestVoicePassCoexists` (grammar+voice coexist, unified response, null→"" replacement, voice re-run scoped). go build/vet/test clean, tsc clean, vite build OK; live smoke vs a fake vLLM (voice anchored at 62, grammar preserved, unified list `[grammar, voice]`).
|
||||||
|
- **Known limitation (carried from Phase 3)**: `findRange` anchors within a single textblock, so a voice passage spanning a paragraph break (`\n\n`) won't decorate. Model passages usually sit within one paragraph; multi-block anchoring is deferred.
|
||||||
|
|
||||||
### Phase 6 — Design system & polish
|
### Phase 6 — Design system & polish
|
||||||
- [ ] Full pastel tokens, Nunito + Lora + JetBrains Mono
|
- [ ] Full pastel tokens, Nunito + Lora + JetBrains Mono
|
||||||
@@ -76,4 +80,5 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
|
- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone.
|
||||||
- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).**
|
- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).**
|
||||||
- 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).**
|
- 2026-06-25: **Phase 3 complete.** Backend `internal/llm`: `LLMClient` interface + factory (vLLM OpenAI-compat + Ollama native, both Complete/Stream), `prompts.go` (checkpoint + Ask Petal templates), `checkpoint.go` (brace-matched JSON salvage, per-doc 30s `RateLimiter`, doc/history truncation). `internal/suggestions`: `/api/docs/:id/check` + `:id/suggestions` + `/api/suggestions/:id/{accept,dismiss}`; each check replaces the pending set in a tx (accepted/rejected kept as history), throttled checks return the current set, positions located by `strings.Index` (advisory only). Frontend: `useCheckpoint` (4s debounce, loads existing on doc open, run-token guards stale responses), `SuggestionHighlight` Tiptap extension rendering ProseMirror **decorations** re-anchored by `original` string on every doc change (precise textblock offset→PM-pos mapping, handles inline atoms), `SuggestionCard` (type-colored tag, original→replacement diff, accept applies replacement in-editor + PATCHes, hover-bridge with close delay), breathing rose checkpoint dot in StatusBar, suggestion fade-float + breathe CSS. Tests: llm parse/rate-limit/truncate, suggestions full flow + rate-limit over httptest with a stub client. go build/vet/test clean, tsc clean, vite build OK; end-to-end smoke-tested against a fake vLLM endpoint (anchoring verified: `I has`→0:5, `two apple`→6:15) and 502 path when LLM unreachable. Next: **Phase 4 (Ask Petal SSE chat).**
|
||||||
|
- 2026-06-25: **Phase 5 complete.** Tier-1 voice-consistency pass. Backend: `internal/llm/voice.go` (`RunVoice` — whole document, no `TruncateDoc`, MaxTokens 2048, `VoiceInterval` 20s per-doc floor), standalone `voiceSystemPrompt`/`VoiceMessages` (not bundled with the grammar checkpoint). `internal/suggestions`: `POST /api/docs/:id/voice` route; `check`/`voice` collapsed into a shared `runPass(limiter, pass, scope)`; `pendingScope` makes `replacePending` family-aware (grammar deletes `type != 'voice'`, voice deletes `type = 'voice'`), so the two passes never clobber each other's pending flags; both endpoints now return the **unified** pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: `api.voiceDoc`, `useCheckpoint` → `voicing`/`runVoice` (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in `Toolbar` (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in `StatusBar`. Voice flags' `replacement: null` round-trips to `""`; `SuggestionCard` already hides the diff row + Accept for those. Tests: `TestVoicePassCoexists` (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified `[grammar@0, voice@62 (empty replacement)]`, grammar preserved. Known limitation: `findRange` is single-textblock, so a voice passage crossing a `\n\n` paragraph break won't decorate (deferred). Next: **Phase 6 (design system & polish).**
|
||||||
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**
|
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**
|
||||||
|
|||||||
@@ -33,6 +33,44 @@ func CheckpointMessages(contentText string) []Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// voiceSystemPrompt drives the Tier-1 voice-consistency pass. It is a distinct
|
||||||
|
// pass from the grammar checkpoint (spec: "do not bundle them") — the model
|
||||||
|
// reads the whole document to learn the writer's natural voice, then flags
|
||||||
|
// passages that read as tonally out of place. `replacement` is null: these are
|
||||||
|
// awareness-only, with no correction to apply.
|
||||||
|
const voiceSystemPrompt = `You are a warm, encouraging writing assistant helping someone who speaks English as a second language. ` +
|
||||||
|
`You are reviewing a COMPLETE document for VOICE CONSISTENCY only — not grammar.
|
||||||
|
|
||||||
|
Read the whole document to learn the writer's natural voice, then identify any passages (2 or more sentences) ` +
|
||||||
|
`that feel tonally inconsistent with the surrounding writing — unusually formal, unusually polished, or phrased ` +
|
||||||
|
`in a way that differs from the writer's established voice elsewhere in the document. These often signal text ` +
|
||||||
|
`that was paraphrased too closely from another source. Do not flag the first paragraph (there is no baseline yet). ` +
|
||||||
|
`Do not flag grammar or spelling mistakes — only voice.
|
||||||
|
|
||||||
|
Respond ONLY with valid JSON. No preamble, no markdown fences. Format:
|
||||||
|
{
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"original": "exact passage from the document that feels inconsistent",
|
||||||
|
"replacement": null,
|
||||||
|
"explanation": "friendly one-sentence note, e.g. 'This passage sounds more formal than the rest of your writing — worth reviewing.'",
|
||||||
|
"type": "voice"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
If the voice is consistent throughout, return: {"suggestions": []}`
|
||||||
|
|
||||||
|
// VoiceMessages builds the message array for a voice-consistency pass. Unlike
|
||||||
|
// the checkpoint, the caller passes the WHOLE document (no truncation) — voice
|
||||||
|
// consistency is judged against the established voice everywhere else.
|
||||||
|
func VoiceMessages(contentText string) []Message {
|
||||||
|
return []Message{
|
||||||
|
{Role: "system", Content: voiceSystemPrompt},
|
||||||
|
{Role: "user", Content: contentText},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
// askPetalSystemTemplate is the Ask Petal tutor prompt. The suggestion context
|
||||||
// is interpolated in; the user's own messages are appended after this system
|
// is interpolated in; the user's own messages are appended after this system
|
||||||
// turn by the caller.
|
// turn by the caller.
|
||||||
|
|||||||
32
internal/llm/voice.go
Normal file
32
internal/llm/voice.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceInterval is the minimum gap between voice-consistency passes for one
|
||||||
|
// document. The pass is whole-document and slow, and it runs on an explicit
|
||||||
|
// user action rather than a typing cadence, so this floor only guards the
|
||||||
|
// inference endpoint against the button being mashed.
|
||||||
|
const VoiceInterval = 20 * time.Second
|
||||||
|
|
||||||
|
// RunVoice sends the WHOLE document — deliberately NOT TruncateDoc'd, since
|
||||||
|
// voice consistency is judged against the established voice everywhere else —
|
||||||
|
// for a Tier-1 voice pass and parses the JSON result. It reuses the checkpoint's
|
||||||
|
// tolerant parser and a larger token budget, since one pass may flag several
|
||||||
|
// passages. Each flag carries a null replacement (awareness-only).
|
||||||
|
func RunVoice(ctx context.Context, client LLMClient, contentText string) ([]RawSuggestion, error) {
|
||||||
|
raw, err := client.Complete(ctx, CompletionRequest{
|
||||||
|
Messages: VoiceMessages(contentText),
|
||||||
|
MaxTokens: 2048,
|
||||||
|
Temperature: 0.3,
|
||||||
|
RepetitionPenalty: 1.15,
|
||||||
|
TopP: 0.9,
|
||||||
|
Stop: []string{"```", "\n\n\n\n"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseCheckpoint(raw)
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ func TestAskPetalChat(t *testing.T) {
|
|||||||
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
|
checkpoint: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"}]}`,
|
||||||
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
|
tokens: []string{"Because ", "\"I\" ", "takes ", "\"have\"."},
|
||||||
}
|
}
|
||||||
srv, docID := newTestServer(t, client)
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
// Seed one suggestion via the checkpoint route, then grab its id.
|
// Seed one suggestion via the checkpoint route, then grab its id.
|
||||||
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
@@ -102,7 +102,7 @@ func TestAskPetalChat(t *testing.T) {
|
|||||||
|
|
||||||
func TestAskPetalChatNotFound(t *testing.T) {
|
func TestAskPetalChatNotFound(t *testing.T) {
|
||||||
client := &streamClient{checkpoint: `{"suggestions":[]}`}
|
client := &streamClient{checkpoint: `{"suggestions":[]}`}
|
||||||
srv, _ := newTestServer(t, client)
|
srv, _, _ := newTestServer(t, client)
|
||||||
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
|
rec := do(t, srv, http.MethodPost, "/suggestions/does-not-exist/chat",
|
||||||
`{"messages":[{"role":"user","content":"hi"}]}`)
|
`{"messages":[{"role":"user","content":"hi"}]}`)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
package suggestions
|
package suggestions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -18,26 +19,31 @@ import (
|
|||||||
"gitea.parodia.dev/drwily/petal/internal/llm"
|
"gitea.parodia.dev/drwily/petal/internal/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds the dependencies for the checkpoint + suggestion routes.
|
// Handler holds the dependencies for the checkpoint + suggestion routes. The
|
||||||
|
// grammar checkpoint and the voice pass each get their own per-document rate
|
||||||
|
// limiter — they are independent passes with different cadences.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
DB *db.DB
|
DB *db.DB
|
||||||
Client llm.LLMClient
|
Client llm.LLMClient
|
||||||
Limit *llm.RateLimiter
|
Limit *llm.RateLimiter // grammar checkpoint floor
|
||||||
|
VoiceLimit *llm.RateLimiter // voice-consistency floor
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Handler with a per-document checkpoint rate limiter.
|
// New constructs a Handler with per-document checkpoint and voice rate limiters.
|
||||||
func New(database *db.DB, client llm.LLMClient) *Handler {
|
func New(database *db.DB, client llm.LLMClient) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
DB: database,
|
DB: database,
|
||||||
Client: client,
|
Client: client,
|
||||||
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
Limit: llm.NewRateLimiter(llm.CheckpointInterval),
|
||||||
|
VoiceLimit: llm.NewRateLimiter(llm.VoiceInterval),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterDocRoutes adds the document-scoped routes (check + list) onto the
|
// RegisterDocRoutes adds the document-scoped routes (check + voice + list) onto
|
||||||
// existing /api/docs router so they share its base path.
|
// the existing /api/docs router so they share its base path.
|
||||||
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
func (h *Handler) RegisterDocRoutes(r chi.Router) {
|
||||||
r.Post("/{id}/check", h.check)
|
r.Post("/{id}/check", h.check)
|
||||||
|
r.Post("/{id}/voice", h.voice)
|
||||||
r.Get("/{id}/suggestions", h.listForDoc)
|
r.Get("/{id}/suggestions", h.listForDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +57,26 @@ func (h *Handler) Routes() chi.Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// check runs a grammar checkpoint over the document and returns the fresh
|
// check runs a grammar checkpoint over the document. Fast, typing-cadence pass.
|
||||||
// pending suggestions. It is rate-limited per document (429 when too soon).
|
|
||||||
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.runPass(w, r, h.Limit, llm.RunCheckpoint, grammarScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// voice runs a Tier-1 voice-consistency pass over the whole document. Slow,
|
||||||
|
// explicit-action pass; replaces only the pending voice flags.
|
||||||
|
func (h *Handler) voice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.runPass(w, r, h.VoiceLimit, llm.RunVoice, voiceScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pass is the signature shared by the grammar checkpoint and the voice pass:
|
||||||
|
// given the document text it returns the model's raw suggestions.
|
||||||
|
type pass func(ctx context.Context, client llm.LLMClient, contentText string) ([]llm.RawSuggestion, error)
|
||||||
|
|
||||||
|
// runPass is the shared body for both LLM passes. It loads the document text,
|
||||||
|
// enforces the pass's per-document rate limit, runs the model, swaps in the
|
||||||
|
// fresh batch scoped to this family, and returns the document's FULL pending set
|
||||||
|
// (both families) so the client always renders a unified picture.
|
||||||
|
func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.RateLimiter, run pass, scope pendingScope) {
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
var contentText string
|
var contentText string
|
||||||
@@ -70,13 +93,13 @@ func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing to check on an empty document — skip the LLM round-trip.
|
// Nothing to analyze on an empty document — skip the LLM round-trip.
|
||||||
if strings.TrimSpace(contentText) == "" {
|
if strings.TrimSpace(contentText) == "" {
|
||||||
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
writeJSON(w, http.StatusOK, []db.Suggestion{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, _ := h.Limit.Allow(docID); !ok {
|
if ok, _ := limiter.Allow(docID); !ok {
|
||||||
// Throttled: return the existing pending set unchanged rather than an
|
// Throttled: return the existing pending set unchanged rather than an
|
||||||
// error, so the frontend keeps showing current suggestions.
|
// error, so the frontend keeps showing current suggestions.
|
||||||
existing, err := h.fetchPending(docID)
|
existing, err := h.fetchPending(docID)
|
||||||
@@ -88,60 +111,77 @@ func (h *Handler) check(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := llm.RunCheckpoint(r.Context(), h.Client, contentText)
|
raw, err := run(r.Context(), h.Client, contentText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorJSON(w, http.StatusBadGateway, "checkpoint failed: "+err.Error())
|
errorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saved, err := h.replacePending(docID, contentText, raw)
|
if err := h.replacePending(docID, contentText, raw, scope); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the unified pending set (grammar + voice), not just this batch, so
|
||||||
|
// a grammar check never drops the voice highlights from the client and the
|
||||||
|
// throttle path above stays consistent with the success path.
|
||||||
|
out, err := h.fetchPending(docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
serverError(w, err)
|
serverError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, saved)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// replacePending swaps a document's pending suggestions for a fresh batch in one
|
// pendingScope describes how one LLM pass touches the shared suggestions table:
|
||||||
// transaction. Accepted/rejected suggestions are left untouched (history).
|
// which family of pending rows it replaces, and the type to stamp on the rows it
|
||||||
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion) ([]db.Suggestion, error) {
|
// inserts. The grammar checkpoint and voice pass each own a disjoint family, so
|
||||||
|
// running one never disturbs the other's pending flags.
|
||||||
|
type pendingScope struct {
|
||||||
|
deleteWhere string // extra WHERE clause scoping the DELETE to this family
|
||||||
|
forceType string // if set, every inserted row gets this type; else normalizeType
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// grammarScope owns the grammar/phrasing/idiom/clarity flags (everything but voice).
|
||||||
|
grammarScope = pendingScope{deleteWhere: "type != 'voice'", forceType: ""}
|
||||||
|
// voiceScope owns the voice flags only.
|
||||||
|
voiceScope = pendingScope{deleteWhere: "type = 'voice'", forceType: db.SuggestionTypeVoice}
|
||||||
|
)
|
||||||
|
|
||||||
|
// replacePending swaps a document's pending suggestions within one family for a
|
||||||
|
// fresh batch in a single transaction. Accepted/rejected suggestions and the
|
||||||
|
// other family's pending rows are left untouched.
|
||||||
|
func (h *Handler) replacePending(docID, contentText string, raw []llm.RawSuggestion, scope pendingScope) error {
|
||||||
tx, err := h.DB.Begin()
|
tx, err := h.DB.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if _, err := tx.Exec(
|
if _, err := tx.Exec(
|
||||||
`DELETE FROM suggestions WHERE doc_id = ? AND status = ?`,
|
`DELETE FROM suggestions WHERE doc_id = ? AND status = ? AND `+scope.deleteWhere,
|
||||||
docID, db.SuggestionStatusPending,
|
docID, db.SuggestionStatusPending,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := []db.Suggestion{}
|
|
||||||
for _, s := range raw {
|
for _, s := range raw {
|
||||||
typ := normalizeType(s.Type)
|
typ := scope.forceType
|
||||||
from, to := locate(contentText, s.Original)
|
if typ == "" {
|
||||||
var saved db.Suggestion
|
typ = normalizeType(s.Type)
|
||||||
err := tx.QueryRow(
|
}
|
||||||
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
from, to := locate(contentText, s.Original)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
if _, err := tx.Exec(
|
||||||
RETURNING id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at`,
|
`INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type)
|
||||||
docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).Scan(
|
docID, from, to, s.Original, s.Replacement, s.Explanation, typ,
|
||||||
&saved.ID, &saved.DocID, &saved.FromPos, &saved.ToPos, &saved.Original,
|
); err != nil {
|
||||||
&saved.Replacement, &saved.Explanation, &saved.Type, &saved.Status, &saved.CreatedAt,
|
return err
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
out = append(out, saved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
return tx.Commit()
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// listForDoc returns the document's current pending suggestions (used when the
|
// listForDoc returns the document's current pending suggestions (used when the
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (s *stubClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan
|
|||||||
|
|
||||||
// newTestServer wires a real DB, a seeded document, and the suggestion routes
|
// newTestServer wires a real DB, a seeded document, and the suggestion routes
|
||||||
// (both the doc-scoped and the per-suggestion mounts) onto one router.
|
// (both the doc-scoped and the per-suggestion mounts) onto one router.
|
||||||
func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string) {
|
func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string, *Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,7 +56,7 @@ func newTestServer(t *testing.T, client llm.LLMClient) (http.Handler, string) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
|
r.Route("/docs", func(dr chi.Router) { h.RegisterDocRoutes(dr) })
|
||||||
r.Mount("/suggestions", h.Routes())
|
r.Mount("/suggestions", h.Routes())
|
||||||
return r, docID
|
return r, docID, h
|
||||||
}
|
}
|
||||||
|
|
||||||
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
||||||
@@ -77,7 +77,7 @@ func TestCheckpointFlow(t *testing.T) {
|
|||||||
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
{"original":"I has","replacement":"I have","explanation":"subject-verb agreement","type":"grammar"},
|
||||||
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
{"original":"two apple","replacement":"two apples","explanation":"plural noun","type":"grammar"}
|
||||||
]}`}
|
]}`}
|
||||||
srv, docID := newTestServer(t, client)
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
// Run a checkpoint → two pending suggestions, with positions located.
|
// Run a checkpoint → two pending suggestions, with positions located.
|
||||||
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
rec := do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
@@ -127,9 +127,88 @@ func TestCheckpointFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVoicePassCoexists proves the voice pass and the grammar checkpoint own
|
||||||
|
// disjoint pending families: running one never wipes the other's flags, and
|
||||||
|
// each endpoint returns the unified pending set.
|
||||||
|
func TestVoicePassCoexists(t *testing.T) {
|
||||||
|
// One client serves both passes; the prompt distinguishes them but the stub
|
||||||
|
// just echoes whatever we set before each call.
|
||||||
|
client := &switchClient{}
|
||||||
|
srv, docID, h := newTestServer(t, client)
|
||||||
|
// Zero the voice floor so the test can re-run the pass without waiting out
|
||||||
|
// the real 20s interval; the family-scoping logic is what's under test here.
|
||||||
|
h.VoiceLimit = llm.NewRateLimiter(0)
|
||||||
|
|
||||||
|
// Grammar checkpoint first → one grammar flag.
|
||||||
|
client.response = `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"agreement","type":"grammar"}]}`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice pass next → one voice flag (null replacement). It must NOT remove the
|
||||||
|
// grammar flag; the response is the unified set of both.
|
||||||
|
client.response = `{"suggestions":[{"original":"two apple","replacement":null,"explanation":"sounds more formal","type":"voice"}]}`
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("voice: 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("voice response should carry both families, got %d: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
var grammar, voice *db.Suggestion
|
||||||
|
for i := range got {
|
||||||
|
switch got[i].Type {
|
||||||
|
case db.SuggestionTypeGrammar:
|
||||||
|
grammar = &got[i]
|
||||||
|
case db.SuggestionTypeVoice:
|
||||||
|
voice = &got[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if grammar == nil || voice == nil {
|
||||||
|
t.Fatalf("want one grammar + one voice flag, got %+v", got)
|
||||||
|
}
|
||||||
|
if voice.Replacement != "" {
|
||||||
|
t.Fatalf("voice flag should have empty replacement, got %q", voice.Replacement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh voice pass that finds nothing replaces only the voice flag; the
|
||||||
|
// grammar one survives.
|
||||||
|
client.response = `{"suggestions":[]}`
|
||||||
|
rec = do(t, srv, http.MethodPost, "/docs/"+docID+"/voice", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second voice: code=%d body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
if len(got) != 1 || got[0].Type != db.SuggestionTypeGrammar {
|
||||||
|
t.Fatalf("after clearing voice, only the grammar flag should remain, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchClient returns a response that can be swapped between calls.
|
||||||
|
type switchClient struct {
|
||||||
|
response string
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *switchClient) Complete(_ context.Context, _ llm.CompletionRequest) (string, error) {
|
||||||
|
s.calls++
|
||||||
|
return s.response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *switchClient) Stream(_ context.Context, _ llm.CompletionRequest) (<-chan string, error) {
|
||||||
|
ch := make(chan string)
|
||||||
|
close(ch)
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckpointRateLimit(t *testing.T) {
|
func TestCheckpointRateLimit(t *testing.T) {
|
||||||
client := &stubClient{response: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"x","type":"grammar"}]}`}
|
client := &stubClient{response: `{"suggestions":[{"original":"I has","replacement":"I have","explanation":"x","type":"grammar"}]}`}
|
||||||
srv, docID := newTestServer(t, client)
|
srv, docID, _ := newTestServer(t, client)
|
||||||
|
|
||||||
// First check runs the model.
|
// First check runs the model.
|
||||||
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
do(t, srv, http.MethodPost, "/docs/"+docID+"/check", "")
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export default function App() {
|
|||||||
const {
|
const {
|
||||||
suggestions,
|
suggestions,
|
||||||
checking,
|
checking,
|
||||||
|
voicing,
|
||||||
schedule: scheduleCheckpoint,
|
schedule: scheduleCheckpoint,
|
||||||
|
runVoice,
|
||||||
removeSuggestion,
|
removeSuggestion,
|
||||||
} = useCheckpoint(currentDoc?.id ?? null)
|
} = useCheckpoint(currentDoc?.id ?? null)
|
||||||
|
|
||||||
@@ -185,10 +187,17 @@ export default function App() {
|
|||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
onAccept={handleAccept}
|
onAccept={handleAccept}
|
||||||
onDismiss={handleDismiss}
|
onDismiss={handleDismiss}
|
||||||
|
onVoiceCheck={runVoice}
|
||||||
|
voicing={voicing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar wordCount={wordCount} saveStatus={status} checking={checking} />
|
<StatusBar
|
||||||
|
wordCount={wordCount}
|
||||||
|
saveStatus={status}
|
||||||
|
checking={checking}
|
||||||
|
voicing={voicing}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ export const api = {
|
|||||||
|
|
||||||
// Grammar checkpoint: runs an LLM pass and returns the fresh pending set.
|
// 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).
|
// Rate-limited per document server-side (returns the existing set if too soon).
|
||||||
|
// Both passes return the UNIFIED pending set (grammar + voice), so the client
|
||||||
|
// never drops one family's highlights when the other refreshes.
|
||||||
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
checkDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
||||||
|
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||||||
|
// unified pending set too. Rate-limited per document server-side.
|
||||||
|
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
||||||
// Pending suggestions for a doc, loaded when the editor opens it.
|
// Pending suggestions for a doc, loaded when the editor opens it.
|
||||||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||||||
acceptSuggestion: (id: string) =>
|
acceptSuggestion: (id: string) =>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ interface Props {
|
|||||||
suggestions: Suggestion[]
|
suggestions: Suggestion[]
|
||||||
onAccept: (s: Suggestion) => void
|
onAccept: (s: Suggestion) => void
|
||||||
onDismiss: (s: Suggestion) => void
|
onDismiss: (s: Suggestion) => void
|
||||||
|
// Triggers the whole-document voice-consistency pass; `voicing` is true while
|
||||||
|
// it runs (drives the toolbar button's loading state).
|
||||||
|
onVoiceCheck: () => void
|
||||||
|
voicing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
||||||
@@ -50,7 +54,16 @@ interface HoverState {
|
|||||||
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
|
||||||
// alignment, a placeholder, character counting, and the suggestion-highlight
|
// alignment, a placeholder, character counting, and the suggestion-highlight
|
||||||
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
// decoration layer. Hovering a highlight opens its SuggestionCard.
|
||||||
export function EditorCore({ docId, initialContent, onChange, suggestions, onAccept, onDismiss }: Props) {
|
export function EditorCore({
|
||||||
|
docId,
|
||||||
|
initialContent,
|
||||||
|
onChange,
|
||||||
|
suggestions,
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
onVoiceCheck,
|
||||||
|
voicing,
|
||||||
|
}: Props) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const [hover, setHover] = useState<HoverState | null>(null)
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
// Delays card close so the pointer can travel from highlight to card.
|
// Delays card close so the pointer can travel from highlight to card.
|
||||||
@@ -195,7 +208,7 @@ export function EditorCore({ docId, initialContent, onChange, suggestions, onAcc
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<Toolbar editor={editor} />
|
<Toolbar editor={editor} onVoiceCheck={onVoiceCheck} voicing={voicing} />
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface Props {
|
|||||||
saveStatus: SaveStatus
|
saveStatus: SaveStatus
|
||||||
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
// True while a grammar checkpoint is in flight — shows the breathing rose dot.
|
||||||
checking: boolean
|
checking: boolean
|
||||||
|
// True while a whole-document voice pass runs — shows a breathing honey dot.
|
||||||
|
voicing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAVE_LABEL: Record<SaveStatus, string> = {
|
const SAVE_LABEL: Record<SaveStatus, string> = {
|
||||||
@@ -18,7 +20,7 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
|
|||||||
// StatusBar is the slim footer: word count on the left, save state and the
|
// 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
|
// 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).
|
// circle that breathes while a check is in flight (spec → Signature animations).
|
||||||
export function StatusBar({ wordCount, saveStatus, checking }: Props) {
|
export function StatusBar({ wordCount, saveStatus, checking, voicing }: Props) {
|
||||||
const label = SAVE_LABEL[saveStatus]
|
const label = SAVE_LABEL[saveStatus]
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@@ -40,6 +42,18 @@ export function StatusBar({ wordCount, saveStatus, checking }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{voicing && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5" title="Petal is reading your voice…">
|
||||||
|
<span
|
||||||
|
className="petal-checkpoint-dot inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: 'var(--color-honey)' }}
|
||||||
|
/>
|
||||||
|
Reading your voice…
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<>
|
<>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useEditorState } from '@tiptap/react'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
|
// Runs the whole-document voice-consistency pass; `voicing` shows its progress.
|
||||||
|
onVoiceCheck: () => void
|
||||||
|
voicing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// A formatting button. `active` gets the rose pill treatment so the writer can
|
// A formatting button. `active` gets the rose pill treatment so the writer can
|
||||||
@@ -47,7 +50,7 @@ const Divider = () => (
|
|||||||
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
||||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||||
// the current selection without re-rendering the whole tree on every keystroke.
|
// the current selection without re-rendering the whole tree on every keystroke.
|
||||||
export function Toolbar({ editor }: Props) {
|
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||||
const state = useEditorState({
|
const state = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor }) =>
|
selector: ({ editor }) =>
|
||||||
@@ -134,6 +137,28 @@ export function Toolbar({ editor }: Props) {
|
|||||||
>
|
>
|
||||||
⇥
|
⇥
|
||||||
</TBtn>
|
</TBtn>
|
||||||
|
<Divider />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Check my voice"
|
||||||
|
disabled={voicing}
|
||||||
|
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||||
|
onClick={onVoiceCheck}
|
||||||
|
className="ml-0.5 inline-flex h-8 items-center gap-1.5 whitespace-nowrap px-3 text-xs font-bold transition-colors disabled:opacity-70"
|
||||||
|
style={{
|
||||||
|
borderRadius: 'var(--radius-pill)',
|
||||||
|
color: 'var(--color-plum)',
|
||||||
|
background: 'var(--color-honey)',
|
||||||
|
}}
|
||||||
|
title="Read the whole document for passages that don't sound like you"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={voicing ? 'petal-checkpoint-dot inline-block h-2 w-2 rounded-full' : 'hidden'}
|
||||||
|
style={{ background: 'var(--color-plum)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{voicing ? 'Reading…' : 'Check my voice 🍯'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const DEBOUNCE_MS = 4000
|
|||||||
export function useCheckpoint(docId: string | null) {
|
export function useCheckpoint(docId: string | null) {
|
||||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
const [checking, setChecking] = useState(false)
|
const [checking, setChecking] = useState(false)
|
||||||
|
// True while a whole-document voice pass is in flight (explicit user action).
|
||||||
|
const [voicing, setVoicing] = useState(false)
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
const docIdRef = useRef(docId)
|
const docIdRef = useRef(docId)
|
||||||
@@ -36,6 +38,26 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Run the voice-consistency pass now (explicit "Check my voice" action). Like
|
||||||
|
// runCheck it returns the unified pending set, so grammar highlights survive.
|
||||||
|
// Shares the run token so navigating away discards a late voice response.
|
||||||
|
const runVoice = useCallback(async () => {
|
||||||
|
const id = docIdRef.current
|
||||||
|
if (!id) return
|
||||||
|
const run = ++runRef.current
|
||||||
|
setVoicing(true)
|
||||||
|
try {
|
||||||
|
const full = await api.voiceDoc(id)
|
||||||
|
if (run === runRef.current && id === docIdRef.current) {
|
||||||
|
setSuggestions(full)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('voice pass failed', err)
|
||||||
|
} finally {
|
||||||
|
if (run === runRef.current) setVoicing(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Call on every edit; schedules a check 4s after typing settles.
|
// Call on every edit; schedules a check 4s after typing settles.
|
||||||
const schedule = useCallback(() => {
|
const schedule = useCallback(() => {
|
||||||
clearTimeout(debounceRef.current)
|
clearTimeout(debounceRef.current)
|
||||||
@@ -49,6 +71,7 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
runRef.current++
|
runRef.current++
|
||||||
setSuggestions([])
|
setSuggestions([])
|
||||||
setChecking(false)
|
setChecking(false)
|
||||||
|
setVoicing(false)
|
||||||
if (!docId) return
|
if (!docId) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -71,5 +94,5 @@ export function useCheckpoint(docId: string | null) {
|
|||||||
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
setSuggestions((prev) => prev.filter((s) => s.id !== id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { suggestions, checking, schedule, removeSuggestion }
|
return { suggestions, checking, voicing, schedule, runVoice, removeSuggestion }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user